Python 对异步 I/O 的优化之路

Python 对异步 I/O 的优化之路

我们将在本节学习到 Python 生态对异步编程的支持是如何继承前文所述的“事件循环+回调”模式演变到 asyncio 的原生协程模式。

回调之痛,以终为始


在第 3 节中,我们已经学会了“事件循环+回调”的基本运行原理,可以基于这种方式在单线程内实现异步编程。也确实能够大大提高程序运行效率。但是,刚才所学的只是最基本的,然而在生产项目中,要应对的复杂度会大大增加。考虑如下问题:

  • 如果回调函数执行不正常该如何?
  • 如果回调里面还要嵌套回调怎么办?要嵌套很多层怎么办?
  • 如果嵌套了多层,其中某个环节出错了会造成什么后果?
  • 如果有个数据需要被每个回调都处理怎么办?
  • ……

在实际编程中,上述系列问题不可避免。在这些问题的背后隐藏着回调编程模式的一些缺点

  • 回调层次过多时代码可读性差

    def callback_1():
    # processing
    def callback_2():
    # processing.....plain
    def callback_3():
    # processing ....
    def callback_4():
    #processing .....
    def callback_5():
    # processing ......
    async_function(callback_5)
    async_function(callback_4)
    async_function(callback_3)
    async_function(callback_2)
    async_function(callback_1)
  • 破坏代码结构

写同步代码时,关联的操作时自上而下运行:

do_a()
do_b()
```plain

如果 b 处理依赖于 a 处理的结果,而 a 过程是异步调用,就不知 a 何时能返回值,需要将后续的处理过程以 callback 的方式传递给 a ,让 a 执行完以后可以执行 b。代码变化为:
```plain
do_a(do_b())

如果整个流程中全部改为异步处理,而流程比较长的话,代码逻辑就会成为这样:

do_a(do_b(do_c(do_d(do_e(do_f(......))))))

上面实际也是回调地狱式的风格,但这不是主要矛盾。主要在于,原本从上而下的代码结构,要改成从内到外的。先 f,再 e,再 d,……,直到最外层 a 执行完成。在同步版本中,执行完 a 后执行 b,这是线程的指令指针控制着的流程,而在回调版本中,流程就是程序猿需要注意和安排的。

  • 共享状态管理困难
    回顾第 3 节爬虫代码,同步阻塞版的sock对象从头使用到尾,而在回调的版本中,我们必须在Crawler实例化后的对象self里保存它自己的sock对象。如果不是采用 OOP 的编程风格,那需要把要共享的状态接力似的传递给每一个回调。多个异步调用之间,到底要共享哪些状态,事先就得考虑清楚,精心设计。
  • 错误处理困难
    一连串的回调构成一个完整的调用链。例如上述的 a 到 f。假如 d 抛了异常怎么办?整个调用链断掉,接力传递的状态也会丢失,这种现象称为调用栈撕裂。 c 不知道该干嘛,继续异常,然后是 b 异常,接着 a 异常。好嘛,报错日志就告诉你,a 调用出错了,但实际是 d 出错。所以,为了防止栈撕裂,异常必须以数据的形式返回,而不是直接抛出异常,然后每个回调中需要检查上次调用的返回值,以防错误吞没。

如果说代码风格难看是小事,但栈撕裂和状态管理困难这两个缺点会让基于回调的异步编程很艰难。所以不同编程语言的生态都在致力于解决这个问题。才诞生了后来的Promise、Co-routine等解决方案。

Python 生态也以终为始,秉承着“程序猿不必难程序猿”的原则,让语言和框架开发者苦逼一点,也要让应用开发者舒坦。在事件循环+回调的基础上衍生出了基于协程的解决方案,代表作有 Tornado、Twisted、asyncio 等。接下来我们随着 Python 生态异步编程的发展过程,深入理解 Python 异步编程。

4.2 核心问题

通过前面的学习,我们清楚地认识到异步编程最大的困难:异步任务何时执行完毕?接下来要对异步调用的返回结果做什么操作?

上述问题我们已经通过事件循环和回调解决了。但是回调会让程序变得复杂。要异步,必回调,又是否有办法规避其缺点呢?那需要弄清楚其本质,为什么回调是必须的?还有使用回调时克服的那些缺点又是为了什么?

答案是程序为了知道自己已经干了什么?正在干什么?将来要干什么?换言之,程序得知道当前所处的状态,而且要将这个状态在不同的回调之间延续下去。

多个回调之间的状态管理困难,那让每个回调都能管理自己的状态怎么样?链式调用会有栈撕裂的困难,让回调之间不再链式调用怎样?不链式调用的话,那又如何让被调用者知道已经完成了?那就让这个回调通知那个回调如何?而且一个回调,不就是一个待处理任务吗?

任务之间得相互通知,每个任务得有自己的状态。那不就是很古老的编程技法:协作式多任务?然而要在单线程内做调度,啊哈,协程!每个协程具有自己的栈帧,当然能知道自己处于什么状态,协程之间可以协作那自然可以通知别的协程。

4.3 协程

  • 协程(Co-routine),即是协作式的例程。

它是非抢占式的多任务子例程的概括,可以允许有多个入口点在例程中确定的位置来控制程序的暂停与恢复执行。

例程是什么?编程语言定义的可被调用的代码段,为了完成某个特定功能而封装在一起的一系列指令。一般的编程语言都用称为函数或方法的代码结构来体现。

4.4 基于生成器的协程

早期的 Pythoner 发现 Python 中有种特殊的对象——生成器(Generator),它的特点和协程很像。每一次迭代之间,会暂停执行,继续下一次迭代的时候还不会丢失先前的状态。

为了支持用生成器做简单的协程,Python 2.5 对生成器进行了增强(PEP 342),该增强提案的标题是 “Coroutines via Enhanced Generators”。有了 PEP 342 的加持,生成器可以通过yield 暂停执行和向外返回数据,也可以通过send()向生成器内发送数据,还可以通过throw()向生成器内抛出异常以便随时终止生成器的运行。

接下来,我们用基于生成器的协程来重构先前的爬虫代码。

4.4.1 未来对象(Future)

不用回调的方式了,怎么知道异步调用的结果呢?先设计一个对象,异步调用执行完的时候,就把结果放在它里面。这种对象称之为未来对象。

[

09_future

](http://jbcdn2.b0.upaiyun.com/2017/08/815c8151acb7d4fec01f637bec5bd4a3.png)

未来对象有一个result属性,用于存放未来的执行结果。还有个set_result()方法,是用于设置result的,并且会在给result绑定值以后运行事先给 future 添加的回调。回调是通过未来对象的add_done_callback()方法添加的。

不要疑惑此处的callback,说好了不回调的嘛?难道忘了我们曾经说的要异步,必回调。不过也别急,此处的回调,和先前学到的回调,还真有点不一样。

4.4.2 重构 Crawler

现在不论如何,我们有了未来对象可以代表未来的值。先用Future来重构爬虫代码。

[

10_gen_crawler

](http://jbcdn2.b0.upaiyun.com/2017/08/13a05fa1bcc865ccf7940839c7529104.png)

和先前的回调版本对比,已经有了较大差异。fetch 方法内有了 yield 表达式,使它成为了生成器。我们知道生成器需要先调用next()迭代一次或者是先send(None)启动,遇到 yield 之后便暂停。那这 fetch 生成器如何再次恢复执行呢?至少 FutureCrawler都没看到相关代码。

4.4.3 任务对象(Task)

为了解决上述问题,我们只需遵循一个编程规则:单一职责,每种角色各司其职,如果还有工作没有角色来做,那就创建一个角色去做。没人来恢复这个生成器的执行么?没人来管理生成器的状态么?创建一个,就叫Task好了,很合适的名字。

[

11_task

](http://jbcdn2.b0.upaiyun.com/2017/08/2702feaec0918e6ac1efd009e9222b0a.png)

上述代码中Task封装了coro对象,即初始化时传递给他的对象,被管理的任务是待执行的协程,故而这里的 coro 就是fetch()生成器。它还有个step()方法,在初始化的时候就会执行一遍。step()内会调用生成器的send()方法,初始化第一次发送的是 None 就驱动了 coro 即fetch()的第一次执行。

send()完成之后,得到下一次的future,然后给下一次的future添加step()回调。原来add_done_callback()不是给写爬虫业务逻辑用的。此前的callback可就干的是业务逻辑呀。

再看fetch()生成器,其内部写完了所有的业务逻辑,包括如何发送请求,如何读取响应。而且注册给 selector 的回调相当简单,就是给对应的future对象绑定结果值。两个 yield 表达式都是返回对应的 future 对象,然后返回Task.step()之内,这样Task, Future, Coroutine三者精妙地串联在了一起。

初始化 Task 对象以后,把fetch()给驱动到了第 44 行yied f就完事了,接下来怎么继续?

4.4.4 事件循环(Event Loop)驱动协程运行

该事件循环上场了。接下来,只需等待已经注册的 EVENT_WRITE 事件发生。事件循环就像心脏一般,只要它开始跳动,整个程序就会持续运行。

[

12_evloop

](http://jbcdn2.b0.upaiyun.com/2017/08/f73d1e5d440798c92bc0a4dfb334270f.png)

注:总体耗时约 0.43 秒。

现在 loop 有了些许变化,callback()不再传递event_keyevent_mask参数。也就是说,这里的回调根本不关心是谁触发了这个事件,结合fetch()可以知道,它只需完成对future设置结果值即可f.set_result()。而且future是谁它也不关心,因为协程能够保存自己的状态,知道自己的future是哪个。也不用关心到底要设置什么值,因为要设置什么值也是协程内安排的。

此时的 loop(),真的成了一个心脏,它只管往外泵血,不论这份血液是要输送给大脑还是要给脚趾,只要它还在跳动,生命就能延续。

4.4.5 生成器协程风格和回调风格对比总结

在回调风格中:

  • 存在链式回调(虽然示例中嵌套回调只有一层)
  • 请求和响应也不得不分为两个回调以至于破坏了同步代码那种结构
  • 程序员必须在回调之间维护必须的状态。

还有更多示例中没有展示,但确实存在的问题,参见 4.1 节。

而基于生成器协程的风格:

  • 无链式调用
  • selector的回调里只管给future设置值,不再关心业务逻辑
  • loop 内回调callback()不再关注是谁触发了事件
  • 已趋近于同步代码的结构
  • 无需程序员在多个协程之间维护状态,例如哪个才是自己的sock

4.4.6 碉堡了,但是代码很丑!能不能重构

如果说fetch的容错能力要更强,业务功能也需要更完善,怎么办?而且技术处理的部分(socket 相关的)和业务处理的部分(请求与返回数据的处理)混在一起。

  • 创建socket连接可以抽象复用吧?
  • 循环读取整个response可以抽象复用吧?
  • 循环内处理socket.recv()的可以抽象复用吧?

但是这些关键节点的地方都有yield,抽离出来的代码也需要是生成器。而且fetch()自己也得是生成器。生成器里玩生成器,代码好像要写得更丑才可以……

Python 语言的设计者们也认识到了这个问题,再次秉承着“程序猿不必为难程序猿”的原则,他们捣鼓出了一个yield from来解决生成器里玩生成器的问题。

4.5 用 yield from 改进生成器协程

4.5.1 yield from 语法介绍

yield from 是 Python 3.3 新引入的语法(PEP 380)。它主要解决的就是在生成器里玩生成器不方便的问题。它有两大主要功能。

第一个功能是:让嵌套生成器不必通过循环迭代 yield,而是直接 yield from。以下两种在生成器里玩子生成器的方式是等价的。

def gen\_one():

subgen = range(10) yield from subgendef gen_two():

subgen = range(10) for item in subgen: yield item

第二个功能就是在子生成器和原生成器的调用者之间打开双向通道,两者可以直接通信。


def gen():

yield from subgen()def subgen():

while True:

x = yield

yield x+1def main():

g = gen()

next(g) # 驱动生成器g开始执行到第一个 yield

retval = g.send(1) # 看似向生成器 gen() 发送数据

print(retval) # 返回2

g.throw(StopIteration) # 看似向gen()抛入异常

通过上述代码清晰地理解了 yield from 的双向通道功能。关键字 yield from 在 gen()内部为 subgen()和 main()开辟了通信通道。main()里可以直接将数据 1 发送给 subgen(),subgen()也可以将计算后的数据 2 返回到 main()里,main()里也可以直接向 subgen()抛入异常以终止 subgen()。

顺带一提,yield from 除了可以 yield from 还可以 yield from 。

4.5.2 重构代码

抽象 socket 连接的功能:

[

13_yf_conn

](http://jbcdn2.b0.upaiyun.com/2017/08/46d52cc58abf84225a529c280f199c7c.png)

抽象单次recv()和读取完整的 response 功能:

[

14_yf_read

](http://jbcdn2.b0.upaiyun.com/2017/08/429685e8f353d20d8bce9990ceb529e1.png)

三个关键点的抽象已经完成,现在重构Crawler类:

[

15_yf_crawler

](http://jbcdn2.b0.upaiyun.com/2017/08/47558d12801fd122257c3feff7c94a61.png)

上面代码整体来讲没什么问题,可复用的代码已经抽象出去,作为子生成器也可以使用 yield from 语法来获取值。但另外有个点需要注意:在第 24 和第 35 行返回future对象的时候,我们了yield from f而不是原来的yield fyield可以直接作用于普通 Python 对象,而yield from却不行,所以我们对Future还要进一步改造,把它变成一个iterable对象就可以了。

[

16_yf_future

](http://jbcdn2.b0.upaiyun.com/2017/08/b80205ab137273a4224d7284203c6ed9.png)

只是增加了**iter()方法的实现。如果不把Future改成iterable也是可以的,还是用原来的yield** f 即可。那为什么需要改进呢?

首先,我们是在基于生成器做协程,而生成器还得是生成器,如果继续混用yieldyield from 做协程,代码可读性和可理解性都不好。其次,如果不改,协程内还得关心它等待的对象是否可被 yield,如果协程里还想继续返回协程怎么办?如果想调用普通函数动态生成一个Future对象再返回怎么办?

所以,在 Python 3.3 引入yield from新语法之后,就不再推荐用 yield 去做协程。全都使用yield from由于其双向通道的功能,可以让我们在协程间随心所欲地传递数据。

4.5.3 yield from 改进协程总结

yield from改进基于生成器的协程,代码抽象程度更高。使业务逻辑相关的代码更精简。由于其双向通道功能可以让协程之间随心所欲传递数据,使 Python 异步编程的协程解决方案大大向前迈进了一步。

于是 Python 语言开发者们充分利用yield from,使 Guido 主导的 Python 异步编程框架Tulip迅速脱胎换骨,并迫不及待得让它在 Python 3.4 中换了个名字asyncio以“实习生”角色出现在标准库中。

4.5.4 asyncio 介绍

asyncio是 Python 3.4 试验性引入的异步 I/O 框架(PEP 3156),提供了基于协程做异步 I/O 编写单线程并发代码的基础设施。其核心组件有事件循环(Event Loop)、协程(Coroutine)、任务(Task)、未来对象(Future)以及其他一些扩充和辅助性质的模块。

在引入 asyncio 的时候,还提供了一个装饰器**@asyncio.coroutine用于装饰使用了yield from**的函数,以标记其为协程。但并不强制使用这个装饰器。

虽然发展到 Python 3.4 时有了yield from的加持让协程更容易了,但是由于协程在 Python 中发展的历史包袱所致,很多人仍然弄不明白生成器协程的联系与区别,也弄不明白yieldyield from 的区别。这种混乱的状态也违背 Python 之禅的一些准则。

于是 Python 设计者们又快马加鞭地在 3.5 中新增了async/await语法(PEP 492),对协程有了明确而显式的支持,称之为原生协程async/awaityield from这两种风格的协程底层复用共同的实现,而且相互兼容。

在 Python 3.6 中asyncio库“转正”,不再是实验性质的,成为标准库的正式一员。

4.6 总结

行至此处,我们已经掌握了asyncio的核心原理,学习了它的原型,也学习了异步 I/O 在 CPython 官方支持的生态下是如何一步步发展至今的。

实际上,真正的asyncio比我们前几节中学到的要复杂得多,它还实现了零拷贝、公平调度、异常处理、任务状态管理等等使 Python 异步编程更完善的内容。理解原理和原型对我们后续学习有莫大的帮助。

5 asyncio 和原生协程初体验

本节中,我们将初步体验asyncio库和新增语法async/await给我们带来的便利。由于 Python2-3 的过度期间,Python3.0-3.4 的使用者并不是太多,也为了不让更多的人困惑,也因为aysncio在 3.6 才转正,所以更深入学习 asyncio 库的时候我们将使用async/await定义的原生协程风格,yield from风格的协程不再阐述(实际上它们可用很小的代价相互代替)。

[

17_aio

](http://jbcdn2.b0.upaiyun.com/2017/08/7e131eb91da34645a3d4fa24280fec62.png)

对比生成器版的协程,使用asyncio库后变化很大:

  • 没有了yieldyield from,而是async/await
  • 没有了自造的loop(),取而代之的是asyncio.get_event_loop()
  • 无需自己在socket上做异步操作,不用显式地注册和注销事件,aiohttp库已经代劳
  • 没有了显式的** Future** 和** Task,asyncio**已封装
  • 更少量的代码,更优雅的设计

说明:我们这里发送和接收 HTTP 请求不再自己操作socket的原因是,在实际做业务项目的过程中,要处理妥善地 HTTP 协议会很复杂,我们需要的是功能完善的异步 HTTP 客户端,业界已经有了成熟的解决方案,DRY 不是吗?

和同步阻塞版的代码对比:

  • 异步化
  • 代码量相当(引入aiohttp框架后更少)
  • 代码逻辑同样简单,跟同步代码一样的结构、一样的逻辑
  • 接近 10 倍的性能提升

结语

到此为止,我们已经深入地学习了异步编程是什么、为什么、在 Python 里是怎么样发展的。我们找到了一种让代码看起来跟同步代码一样简单,而效率却提升 N 倍(具体提升情况取决于项目规模、网络环境、实现细节)的异步编程方法。它也没有回调的那些缺点。

本系列教程接下来的一篇将是学习asyncio库如何的使用,快速掌握它的主要内容。后续我们还会深入探究asyncio的优点与缺点,也会探讨 Python 生态中其他异步 I/O 方案和 asyncio 的区别。

参考

深入理解 Python 异步编程(上)
A Web Crawler With asyncio Coroutines