前记
Python Asyncio
不仅提供了简单的Socket
接口,还基于Asyncio.Socket
提供了Protocol
&Transport
接口以及更高级的Stream
接口,这些接口大大的减轻了开发者进行网络编程的心理负担。 本文主要介绍了Asyncio
这些接口的简单使用以及对应的原理分析。
1.简单介绍
Python Asyncio
提供了一套完整的高性能网络编程接口,它包括了兼容位于网络编程最底层的Socket
–Asyncio.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 Asyncio
的Stream
接口只支持流传输,所以本文只采用Socket
进行TCP传输的编程实例进行讲述,其他的编程方式和Socket
介绍见下文:
Socket
是计算机之间进行通信的一种协议,通过Socket
开发者可以在无需关心底层是如何实现的情况下在不同的计算机进行端到端之间的通信, Socket
常见的交互流程如下图:
在交互的流程的示例图中,Socket
分为五个交互阶段,每个阶段的作用如下:
- 创建
Socket
: 初始化一个Socket
对象。 - 初始化
Socket
: 客户端无需任何操作,而服务端的Socket
在初始化时比客户端的Socket
多了两个方法–bind
和listen
,它们的作用分别是绑定一个端口,以及监听这个端口建立的新连接。 - 建立连接: 客户端
Socket
的专属方法为connect
,这个方法会直接与服务端建立连接。而服务端的专属方法为accept
,accept
这个方法比较特殊,因为其他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
有两个缓冲区,所以关闭阶段分为close
和shutdowm
两个方法,其中close
为关闭两个缓冲区,而shuwdown
可以关闭指定的缓冲区(详细的流程见后文)。示例中的例子是服务端先调用了close
方法,然后服务端会发送一个EOF
事件给客户端,客户端从读缓冲区读到EOF
事件后发现读通道已经关闭了,才调用close
方法把整个socket
一起关闭。
2.Asyncio Socket
在文章《初识Python协程的实现》中介绍了如何把同步请求通过selector
库和yield
语法改造成一个简单的基于协程的异步请求,但是改造后的代码增加了很多监听和移除文件描述符的回调代码,编写起来比较麻烦,很不易懂。
不过在采用了Asyncio
的思想并引入了Task
和Future
后,异步回调的代码都被消除了,但是大量的监听和移除文件描述符的代码还是存在,而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
。
可以看到,代码的改动并没有很大,除了传染性的async
和await
语法外,其它逻辑并没有什么明显的变化,在运行代码之后可以看到程序运行成功,并输出如下响应结果:
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_resolved
是sock_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是阻塞的,且没有提供异步选项,如果直接执行这个方法会卡住整个<