Python Asyncio 之网络编程方法详解

本文详细介绍了Python的Asyncio库在网络编程中的应用,包括Socket的基础知识、Asyncio Socket的使用、Protocol与Transport的原理分析。重点探讨了Asyncio如何提高性能,通过源码分析揭示了Asyncio优于传统Socket编程的原因,并通过示例展示了Stream接口的使用,帮助开发者更好地理解和使用Asyncio进行异步网络编程。
摘要由CSDN通过智能技术生成

前记

Python Asyncio不仅提供了简单的Socket接口,还基于Asyncio.Socket提供了Protocol&Transport接口以及更高级的Stream接口,这些接口大大的减轻了开发者进行网络编程的心理负担。 本文主要介绍了Asyncio这些接口的简单使用以及对应的原理分析。

1.简单介绍

Python Asyncio提供了一套完整的高性能网络编程接口,它包括了兼容位于网络编程最底层的SocketAsyncio.Socket,以及在Asyncio.Socket上层封装的Protocol&Transport接口,还有在Protocol&Transport上层封装的Stream接口。 这三套接口各有特色,开发者可以根据自己的需求选择其中一套接口来使用,进而减少网络编程的一些心理负担。

Python Asyncio三套接口的关系就跟套娃一样,Stream 套在Protocol&Transport上面 ,而Protocol&Transport套在Socket上面,由于Stream是最上层的封装,所以它的易用性最高,不过适用范围最少,其次是Protocol&Transport,最后是Socket,它的易用性最差,但是适用范围最广,不过它们的性能却跟套娃顺序无关。 根据uvloop的性能比较得出他们的性能关系为Protocol&Transport > Stream > Socket,具体结果如图:

在第一次见到这个性能的比较结果时我觉得是非常神奇的,因为对于一些分层架构来说,越上层的封装越多,易用性越好,而性能反而越低,但在性能比较结果中性能最好的却是处于中间的Protocol&Transport,然后是Stream,最后才是Asycnio Socket。为了了解这个原因,需要通过网络编程接口的使用方法和源码一起分析。

1.1.Socket的简单介绍

无论Asyncio的网络编程接口是怎么封装,如果要了解它是怎么实现的,那么需要对Socket有一定的了解。 不过本文只对Socket进行简单的介绍,并不会对Socket的原理进行详细的描述,同时Python AsyncioStream接口只支持流传输,所以本文只采用Socket进行TCP传输的编程实例进行讲述,其他的编程方式和Socket介绍见下文:

Socket是计算机之间进行通信的一种协议,通过Socket开发者可以在无需关心底层是如何实现的情况下在不同的计算机进行端到端之间的通信, Socket常见的交互流程如下图:

asyncio网络编程-socket.png

在交互的流程的示例图中,Socket分为五个交互阶段,每个阶段的作用如下:

  • 创建Socket: 初始化一个Socket对象。
  • 初始化Socket: 客户端无需任何操作,而服务端的Socket在初始化时比客户端的Socket多了两个方法–bindlisten,它们的作用分别是绑定一个端口,以及监听这个端口建立的新连接。
  • 建立连接: 客户端Socket的专属方法为connect,这个方法会直接与服务端建立连接。而服务端的专属方法为acceptaccept这个方法比较特殊,因为其他socket的方法都是针对于socket进行操作,而accept方法除了针对socket进行操作外还会额外返回一个新的socket。 同时服务端原先的socket只携带服务端的IP和地址信息,而新的socket携带的是客户端与服务端两个端点的四元组信息(客户端IP,客户端端口,服务端IP,服务端端口)。 这一点是非常重要的,因为这两个socket对应的文件描述符是不一样的,它们的责任也是不一样的, 原来的socket只用于跟客户端建立新的连接,而新的socket用于客户端与服务端进行数据交互,这意味着服务端的事件循环在处理的时候,对两个socket的读事件的触发时机也是不一样的。其中服务端原先socket的读事件被触发时意味着有新的连接可以被accept,而新socket的读事件被触发则是代表当前连接有新的数据可以被接收,这与客户端的Socket读事件的一样的,这意味着在Socket建立成功后,客户端和服务端的连接的读写逻辑都可以统一,不用进行区分了。
  • 数据交互阶段:由于服务端accept方法返回的Socket与客户端的类似,所以这个阶段的客户端与服务端的逻辑是类似的,不过双端程序的数据只是与各自的Socket进行交互,而不是直接进行交互的。因为每个Socket都维护着读和写两个缓冲区,缓冲区的底层数据结构与队列类似,创建Socket的程序只能把数据投递到缓冲区或者从缓冲区获取数据,而无法触碰到网卡发送/接收数据的领域。 这也意味着在把Socket设置为非阻塞的情况下,当Socket的写缓冲区不满时,Socket的写操作是不会阻塞的,同样当Socket的读缓冲区拥有的量大于Socket读方法需要的量时,读操作也是不会阻塞的。
  • 关闭阶段:由于Socket有两个缓冲区,所以关闭阶段分为closeshutdowm两个方法,其中close为关闭两个缓冲区,而shuwdown可以关闭指定的缓冲区(详细的流程见后文)。示例中的例子是服务端先调用了close方法,然后服务端会发送一个EOF事件给客户端,客户端从读缓冲区读到EOF事件后发现读通道已经关闭了,才调用close方法把整个socket一起关闭。

2.Asyncio Socket

在文章《初识Python协程的实现》中介绍了如何把同步请求通过selector库和yield语法改造成一个简单的基于协程的异步请求,但是改造后的代码增加了很多监听和移除文件描述符的回调代码,编写起来比较麻烦,很不易懂。

不过在采用了Asyncio的思想并引入了TaskFuture后,异步回调的代码都被消除了,但是大量的监听和移除文件描述符的代码还是存在,而Asyncio.Socket则封装了大量的读写事件的监听和移除的操作,只暴露了与Socket类似的方法,开发者通过这些方法可以简单快速的把同步请求改为基于协程的异步请求,比如《初识Python协程的实现》中的同步请求,它的源码如下:

import socket

def request(host: str) -> None:
    url: str = f"http://{host}"
    sock: socket.SocketType = socket.socket()
    sock.connect((host, 80))
    sock.send(f"GET {url} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode("ascii"))

    response_bytes: bytes = b""
    chunk: bytes = sock.recv(4096)
    while chunk:
        response_bytes += chunk
        chunk = sock.recv(4096)
    print("\n".join([i for i in response_bytes.decode().split("\r\n")]))

if __name__ == "__main__":
    request("so1n.me")

这份代码只对Socket进行简单的调用,其中涉及到Socket的调用方法有:

名称 作用 是否涉及到IO
socket.socket 初始化socket
socket.connect 建立连接
socket.send 发送数据
socket.recv 接收数据

在把它改为Asyncio.Socket时,只需要把涉及到IO的Socket方法以loop.sock_xxx(sock, *param)的形式进行修改,其中xxx是原来的方法名,sock则是通过socket.socket实例化的一个sock对象,而param则保持跟之前的一样的参数,更改后的代码如下:

import asyncio
import socket

async def request(host: str) -> None:
    url: str = f"http://{host}"
    loop = asyncio.get_event_loop()
    sock: socket.SocketType = socket.socket()
    await loop.sock_connect(sock, (host, 80))
    await loop.sock_sendall(sock, f"GET {url} HTTP/1.0\r\nHost: {host}\r\n\r\n".encode("ascii"))

    response_bytes: bytes = b""
    chunk: bytes = await loop.sock_recv(sock, 4096)
    while chunk:
        response_bytes += chunk
        chunk = await loop.sock_recv(sock, 4096)
    print("\n".join([i for i in response_bytes.decode().split("\r\n")]))

if __name__ == "__main__":
    asyncio.run(request("so1n.me"))

Asyncio Socket没有提供send方法,这里需要改为sendall

可以看到,代码的改动并没有很大,除了传染性的asyncawait语法外,其它逻辑并没有什么明显的变化,在运行代码之后可以看到程序运行成功,并输出如下响应结果:

HTTP/1.1 301 Moved Permanently
Connection: close
Content-Length: 162
Server: GitHub.com
Content-Type: text/html
Location: https://so1n.me/
X-GitHub-Request-Id: 9E20:2767:4FED3D:55C800:64E46CAF
Accept-Ranges: bytes
Date: Tue, 22 Aug 2023 08:11:04 GMT
Via: 1.1 varnish
Age: 233
X-Served-By: cache-hkg17935-HKG
X-Cache: HIT
X-Cache-Hits: 1
X-Timer: S1692691865.899948,VS0,VE1
Vary: Accept-Encoding
X-Fastly-Request-ID: 7180dce567d15eacaf44c9b93a2fb84bd67ab444

<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>

可以看到程序是正常运行的,为了了解Asyncio.Socket做了什么工作,接下来会翻阅源码,探究它的处理方法,首先是loop.sock_connect,它的源码如下

# 位于:Lib/asyncio/selector_events.py
async def sock_connect(self, sock, address):
	# 检查ssl sock以及检查是否为阻塞的sock
    base_events._check_ssl_socket(sock)
    if self._debug and sock.gettimeout() != 0:
        raise ValueError("the socket must be non-blocking")

    if sock.family == socket.AF_INET or (
            base_events._HAS_IPv6 and sock.family == socket.AF_INET6):
		# 通过dns将域名转为ip地址
        resolved = await self._ensure_resolved(
            address, family=sock.family, type=sock.type, proto=sock.proto,
            loop=self,
        )
        _, _, _, _, address = resolved[0]

	# 创建一个future,这个future会等待soc连连接成功才返回数据。
    fut = self.create_future()
    self._sock_connect(fut, sock, address)
	# 通过future等待soc床创建成功
    return await fut

这个方法分为三部分,首先是检查Socket的ssl并进行一些参数校验,然后通过self._ensure_resolved方法进行dns解析,最后才通过self._sock_connect方法进行真正建立连接。其中,dns解析方法self._ensure_resolvedsock_connect方法与其他Socket方法的不同点,它的源码如下:

# 位于:Lib/asyncio/selector_events.py
    async def _ensure_resolved(self, address, *, family=0, type=socket.SOCK_STREAM, proto=0, flags=0, loop):
        host, port = address[:2]
    	# 判断是否已经解析,已经解析了就直接使用
        info = _ipaddr_info(host, port, family, type, proto, *address[2:])
        if info is not None:
            # "host" is already a resolved IP.
            return [info]
        else:
    		# 没有解析则调用socket.getaddrinfo进行解析
            return await loop.getaddrinfo(host, port, family=family, type=type, proto=proto, flags=flags)
    
# 位于:Lib/asyncio/base_events.py
    async def getaddrinfo(self, host, port, *, family=0, type=0, proto=0, flags=0):
        if self._debug:
            getaddr_func = self._getaddrinfo_debug
        else:
            getaddr_func = socket.getaddrinfo

        return await self.run_in_executor(
            None, getaddr_func, host, port, family, type, proto, flags)

通过源码发现,dns解析的逻辑中涉及到了run_in_executor方法,这个方法是把任务交给线程池进行处理。 在这里使用run_in_executor方法的原因是POSIX的DNS解析API是阻塞的,且没有提供异步选项,如果直接执行这个方法会卡住整个<

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值