初探 Python 3 的异步 IO 编程
admin
2023-07-31 00:37:55
0
上周终于把知乎日报的新版本做完了,于是趁着这几天的休息,有精力折腾一些感兴趣的玩意了。
虽然工作时并不会接触到 Python 3,但还是对它抱有不少好奇心,于是把 Python 版本更新到了 3.4,开始了折腾之旅。在各种更新中,我最感兴趣的当属 asyncio 模块了,所以就从异步 IO 开始探索吧。 

探索之前,先简单介绍下各种 IO 模型:
最容易做的是阻塞 IO,即读写数据时,需要等待操作完成,才能继续执行。进阶的做法就是用多线程来处理需要 IO 的部分,缺点是开销会有些大。
接着是非阻塞 IO,即读写数据时,如果暂时不可读写,则立刻返回,而不等待。因为不知道什么时候是可读写的,所以轮询时可能会浪费 CPU 时间。
然后是 IO 复用,即在读写数据前,先检查哪些描述符是可读写的,再去读写。select 和 poll 就是这样做的,它们会遍历所有被监视的描述符,查看是否满足,这个检查的过程是阻塞的。而 epoll、kqueue 和 /dev/poll 则做了些改进,事先注册需要检查哪些描述符的哪些事件,当状态发生变化时,内核会调用对应的回调函数,将这些描述符保存下来;下次获取可用的描述符时,直接返回这些发生变化的描述符即可。
再之后是信号驱动,即描述符就绪时,内核发送 SIGIO 信号,再由信号处理程序去处理这些信号即可。不过信号处理的时机是从内核态返回用户态时,感觉也得把这些事件收集起来才好处理,有点像模拟 IO 复用了。
最后是异步 IO,即读写数据时,只注册事件,内核完成读写后(读取的数据会复制到用户态),再调用事件处理函数。这整个过程都不会阻塞调用线程,不过实现它的操作系统比较少,Windows 上有比较成熟的 IOCP,Linux 上的 AIO 则有不少缺点。
虽然真正的异步 IO 需要中间任何步骤都没有阻塞,这对于某些只是偶尔需要处理 IO 请求的情况确实有用(比如文本编辑器偶尔保存一下文件);但对于服务器端编程的大多数情况而言,它的主线程就是用来处理 IO 请求的,如果在空闲时不阻塞在 IO 等待上,也没有别的事情能做,所以本文就不纠结这个异步是否名副其实了。

在 Python 2 的时代,高性能的网络编程主要是使用 Twisted、Tornado 和 gevent 这三个库。
我对 Twisted 不熟,只知道它的缺点是比较重,性能相对而言并不算好。
Tornado 平时用得比较多,缺点是写异步调用时特别麻烦。
gevent 我只能算接触过,缺点是不太干净。
由于它们都各自有一个 IO loop,不好混用,而 Tornado 的 web 框架相对而言比较完善,因此成了我的首选。

而从 Python 3.4 开始,标准库里又新增了 asyncio 这个模块。
从原理上来说,它和 Tornado 其实差不多,都是注册 IO 事件,然后在 IO loop 中等待事件发生,然后调用相应的处理函数。
不同之处在于 Python 3 增加了一些新的特性,而 Tornado 需要兼容 Python 2,所以写起来会比较麻烦。
举例来说,Python 3.3 可以在 generator 中 return 返回值(相当于 raise StopIteration),而 Tornado 中需要 raise 一个 Return 对象。此外,Python 3.3 还增加了 yield from 语法,减轻了在 generator 中处理另一个 generator 的工作量(省去了循环和 try … except …)。

不过,虽然 asyncio 有那么多得天独厚的优势,却不一定比 Tornado 的性能更好,所以我写个简单的例子测试一下。
比较方法就是写个最简单的 HTTP 服务器,不做任何检查,读取到任何内容都输出一个 hello world,并断开连接。
测试的客户端就懒得写了,直接用 ab 即可:

1 ab n 10000 c 10 \”http://0.0.0.0:8000/\”

Tornado 版是这样:

1234567891011121314151617 from tornado.gen import coroutinefrom tornado.ioloop import IOLoopfrom tornado.tcpserver import TCPServer class Server(TCPServer):    @coroutine    def handle_stream(self, stream, address):        try:            yield stream.read_bytes(1024, partial=True)            yield stream.write(b\’HTTP 1.0 200 OKrnrnhello world\’)        finally:            stream.close() server = Server()server.bind(8000)server.start(1)IOLoop.current().start()

在我的电脑上大概 4000 QPS。

asyncio 版是这样:

12345678910111213141516 import asyncio class Server(asyncio.Protocol):    def connection_made(self, transport):        self.transport = transport     def data_received(self, data):        try:            self.transport.write(b\’HTTP/1.1 200 OKrnrnhello world\’)        finally:            self.transport.close() loop = asyncio.get_event_loop()server = loop.create_server(Server, \’\’, 8000)loop.run_until_complete(server)loop.run_forever()

在我的电脑上大概 3000 QPS,比 Tornado 版慢了一些。此外,asyncio 的 transport 在 write 时不用 yield from,这点可能有些不一致。

asyncio 还有个高级版的 API:

12345678910111213 import asyncio @asyncio.coroutinedef handle(reader, writer):    yield from reader.read(1024)    writer.write(b\’HTTP/1.1 200 OKrnrnhello world\’)    yield from writer.drain()    writer.close() loop = asyncio.get_event_loop()task = asyncio.start_server(handle, \’\’, 8000, loop=loop)server = loop.run_until_complete(task)loop.run_forever()

在我的电脑上大概 2200 QPS。这下读写都要 yield from 了,一致性上来说会好些。

以框架的性能而言,其实都够用,开销都不超过 1 毫秒,而 web 请求一般都需要 10 毫秒的以上的处理时间。
于是顺便再测一下和 MySQL 的搭配,即在每个请求内调用一下 SELECT 1,然后输出返回值。
因为自己懒得写客户端了,于是就用现成的 tornado_mysql 和 aiomysql 来测试了。原理应该都差不多,发送写请求后就返回,等收到可读事件时再获取内容。

Tornado 版是这样:

12345678910111213141516171819202122232425 from tornado.gen import coroutinefrom tornado.ioloop import IOLoopfrom tornado.tcpserver import TCPServerfrom tornado_mysql import pools class Server(TCPServer):    @coroutine    def handle_stream(self, stream, address):        try:            yield stream.read_bytes(1024, partial=True)            cursor = yield POOL.execute(b\’SELECT 1\’)            data = cursor.fetchone()            yield stream.write(\’HTTP/1.1 200 OKrnrn{0[0]}\’.format(data).encode())  # Python 3.5 的 bytes 才能用 % 格式化        finally:            stream.close() POOL = pools.Pool(    dict(host=\’127.0.0.1\’, port=3306, user=\’root\’, passwd=\’123\’, db=\’mysql\’),    max_idle_connections=10,    max_open_connections=10) server = Server()server.bind(8000)server.start(1)IOLoop.current().start()

在我的电脑上大概 680 QPS。

asyncio 版是这样:

1234567891011121314151617181920212223242526272829303132333435 import asyncio import aiomysql class Server(asyncio.Protocol):    def connection_made(self, transport):        self.transport = transport class Server(asyncio.Protocol):    def connection_made(self, transport):        self.transport = transport     def data_received(self, data):        @asyncio.coroutine        def handle():            with (yield from pool) as conn:                cursor = yield from conn.cursor()                yield from cursor.execute(b\’SELECT 1\’)                result = yield from cursor.fetchone()            try:                self.transport.write(\’HTTP/1.1 200 OKrnrn{0[0]}\’.format(result).encode())            finally:                self.transport.close()        loop.create_task(handle())  # 或者 asyncio.async(handle()) @asyncio.coroutinedef get_pool():    return(yield from aiomysql.create_pool(host=\’127.0.0.1\’, port=3306, user=\’root\’, password=\’123\’, loop=loop)) loop = asyncio.get_event_loop()pool = loop.run_until_complete(get_pool()) server = loop.create_server(Server, \’\’, 8000)loop.run_until_complete(server)loop.run_forever()

在我的电脑上大概 1250 QPS,比 Tornado 版快了不少。不过写起来比较蛋疼,因为 data_received 方法里不能直接用 yield from。

用 cProfile 看了下,Tornado 版在 tornado.gen 和 functools 模块里花了不少时间,可能是异步调用过多了吧。
但如果不做异步库的开发者,而只就使用者的体验而言,Tornado 会显得更加灵活和易用。不过 asyncio 的高级 API 应该也能提供类似的体验。

顺便再用底层 socket 模块写个服务器试试。
先用 poll 看看,错误处理什么的就先不做了:

相关内容

热门资讯

500 行 Python 代码... 语法分析器描述了一个句子的语法结构,用来帮助其他的应用进行推理。自然语言引入了很多意外的歧义,以我们...
定时清理删除C:\Progra... C:\Program Files (x86)下面很多scoped_dir开头的文件夹 写个批处理 定...
65536是2的几次方 计算2... 65536是2的16次方:65536=2⁶ 65536是256的2次方:65536=256 6553...
Mobi、epub格式电子书如... 在wps里全局设置里有一个文件关联,打开,勾选电子书文件选项就可以了。
scoped_dir32_70... 一台虚拟机C盘总是莫名奇妙的空间用完,导致很多软件没法再运行。经过仔细检查发现是C:\Program...
小程序支付时提示:appid和... [Q]小程序支付时提示:appid和mch_id不匹配 [A]小程序和微信支付没有进行关联,访问“小...
pycparser 是一个用... `pycparser` 是一个用 Python 编写的 C 语言解析器。它可以用来解析 C 代码并构...
微信小程序使用slider实现... 众所周知哈,微信小程序里面的音频播放是没有进度条的,但最近有个项目呢,客户要求音频要有进度条控制,所...
python查找阿姆斯特朗数 题目解释 如果一个n位正整数等于其各位数字的n次方之和,则称该数为阿姆斯特朗数。 例如1^3 + 5...
Apache Doris 2.... 亲爱的社区小伙伴们,我们很高兴地向大家宣布,Apache Doris 2.0.0 版本已于...