博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

python 异步 【超级不错】

Posted on 2018-05-23 10:51  bw_0927  阅读(464)  评论(0)    收藏  举报

http://python.jobbole.com/88291/

http://www.cnblogs.com/my_life/articles/5036842.html

http://www.cnblogs.com/my_life/articles/7802958.html

http://www.cnblogs.com/my_life/articles/5026368.html

http://www.cnblogs.com/my_life/articles/7802958.html

http://www.cnblogs.com/my_life/articles/7799051.html

 

 基于epoll事件循环的crawler:

 

 

Django是传统的非异步框架。

 

Python3中新增的asyncio库和async/await语法

 

Python中的协程经历了很长的一段发展历程。最初的生成器yieldsend()语法,然后在Python3.4中加入了asyncio模块,引入@asyncio.coroutine装饰器和yield from语法,在Python3.5上又提供了async/await语法,目前正式发布的Python3.6中asynico也由临时版改为了稳定版。

 

全局解释器锁(英语:Global Interpreter Lock,缩写GIL

Python中的多线程因为GIL的存在,它们并不能利用CPU多核优势,一个Python进程中,只允许有一个线程处于运行状态

在做阻塞的系统调用时,例如sock.connect(),sock.recv()时,当前线程会释放GIL,让别的线程有执行机会。但是单个线程内,在阻塞调用上还是阻塞的。

 

 

基于回调的异步编程

一连串的回调构成一个完整的调用链。

例如上述的 a 到 f。假如 d 抛了异常怎么办?整个调用链断掉,接力传递的状态也会丢失,这种现象称为调用栈撕裂

c 不知道该干嘛,继续异常,然后是 b 异常,接着 a 异常。好嘛,报错日志就告诉你,a 调用出错了,但实际是 d 出错。

所以,为了防止栈撕裂,异常必须以数据的形式返回,而不是直接抛出异常,然后每个回调中需要检查上次调用的返回值,以防错误吞没。

 

所以后来才诞生了后来的Promise、Co-routine等解决方案。

事件循环+回调的基础上衍生出了基于协程的解决方案,代表作有 Tornado、Twisted、asyncio 等。

 

4.2 核心问题

异步编程最大的困难:异步任务何时执行完毕?接下来要对异步调用的返回结果做什么操作

上述问题我们已经通过事件循环和回调解决了。

但是回调会让程序变得复杂。

要异步,必回调,又是否有办法规避其缺点呢?

那需要弄清楚其本质,为什么回调是必须的?还有使用回调时克服的那些缺点又是为了什么?

答案是程序为了知道自己已经干了什么?正在干什么?将来要干什么?

换言之,程序得知道当前所处的状态,而且要将这个状态在不同的回调之间延续下去

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

而且一个回调,不就是一个待处理任务吗任务之间的相互通知,每个任务得有自己的状态。那不就是很古老的编程技法:协作式多任务?

然而要在单线程内做调度,啊哈,协程每个协程(每个任务,每个回调函数)具有自己的栈帧,当然能知道自己处于什么状态,协程之间可以协作那自然可以通知别的协程。

 

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

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

例程就是函数,任务。

 

什么是Coroutine?

Coroutine,又称作协程。从字面上来理解,即协同运行的例程,它是比线程(thread)更细量级的用户态线程,特点是允许用户的主动调用和主动退出,挂起当前的例程然后返回值或去执行其他任务,接着返回原来停下的点继续执行。

等下,这是否有点奇怪?我们都知道一般函数都是线性执行的,不可能说执行到一半返回,等会儿又跑到原来的地方继续执行。

但一些熟悉python(or其他动态语言)的童鞋都知道这可以做到,答案是用yield语句。

其实这里我们要感谢操作系统(OS)为我们做的工作,因为它具有getcontextswapcontext这些特性,通过系统调用,我们可以把上下文和状态保存起来,切换到其他的上下文,这些特性为coroutine的实现提供了底层的基础。

 

 

 

在Python的概念中,这里提到的协程就是生成器。

4.4 基于生成器的协程

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

最初的yield只能返回并暂停函数,并不能实现协程的功能。后来,Python为它定义了新的功能——接收外部发来的值,这样一个生成器就变成了协程。

为了支持用生成器做简单的协程,Python 2.5 对生成器进行了增强(PEP 342),该增强提案的标题是 “Coroutines via Enhanced Generators”。

有了PEP 342的加持,生成器可以通过yield 暂停执行和向外返回数据也可以通过send()向生成器内发送数据还可以通过throw()向生成器内抛出异常以便随时终止生成器的运行

 

4.4.1 未来对象(Future)

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

未来对象有一个result属性,用于存放未来的执行结果。

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

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

 

4.4.2 重构 Crawler  (内含yield,变成了生成器(生成器模仿协程),等待外部通过send()来驱动自身执行

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

============================

分析51行的读:

51:生成一个future对象用来存在未来的结果

56:注册on_readable()

57: 暂停当前操作,等待可读; yield的f对象(51行的future对象)会被task中79行的next_future捕获

54: 可读后,执行f.set_result(sock.recv(4096)),  读到的数据会存在51行的future.result变量中

  可读后,task对象的79行会把读到的result发送给57行,chunk即是读到的数据

============================ 

 

 

 

和先前的回调版本对比,已经有了较大差异。fetch 方法内有了yield表达式,使它成为了生成器

我们知道生成器需要先调用next()迭代一次或者是先send(None)启动遇到yield之后便暂停那这fetch生成器如何再次恢复执行呢?至少 Future 和 Crawler都没看到相关代码。

执行流程分析:

38行: 创建一个未来对象

43行:向事件循环注册on_connected()事件

44行:yield:主动暂停当前协程的执行,等待连接成功。  基于epoll的方式时,事先注册一堆事件:on_connected(), on_read(), on_write(), 然后阻塞在epoll()处,等待事件就绪

。。。。一段时间后连接成功

44行:协程从44行返回继续执行

45行:取消事件监听

47行:发送http请求

50行:循环读http响应

51行:分块读response,每个response对应一个未来对象,代表可读事件发生

53,54行:可读事件就绪时,set_result(),内部进行sock.recv()读数据

56行:注册可读事件

57行:yield:主动暂停当前协程的执行,等待读到数据

 

 

4.4.3 任务对象(Task)  内含send(), 驱动上面的生成器运行

为了解决上述问题,我们只需遵循一个编程规则:单一职责,每种角色各司其职,如果还有工作没有角色来做,那就创建一个角色去做。

没人来恢复这个生成器的执行么?没人来管理生成器的状态么?创建一个,就叫Task好了,很合适的名字。

 

 

 

上述代码中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就完事了,接下来怎么继续?

执行流程:

69,70:封装协程对象coro

71,72:一个临时的未来变量,其值为None。  它的作用是为了驱动外部coro协程的执行,在79行: self.coro.send(future.result) 即self.coro.send(None)

73:  主动驱动所封装协程coro的执行

79: 驱动外部协程的执行,即上图会执行到44行的yield f  处;f 会被这里的next_future捕获

82: 给next_future添加 step回调;  44行的事件一旦就绪就会执行41行的on_connect(): f.set_result(None),  future的set_result()内部会调用callback,即它会驱动执行next_future.step(), 也即调用f.send(None), 继续驱动从44行往下执行

形成了一个循环

 

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

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

 

注:总体耗时约0.43秒。

现在loop有了些许变化,callback()不再传递event_keyevent_mask参数。

也就是说,这里的回调根本不关心是谁触发了这个事件,结合fetch()可以知道,它只需完成对future设置结果值即可f.set_result()

而且future是谁它也不关心,因为协程能够保存自己的状态,知道自己的future是哪个。也不用关心到底要设置什么值,因为要设置什么值也是协程内安排的。

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

99行:  把协程对象封装成Task, Task负责驱动协程的执行

91,100行: loop,负责事件循环的回调:无需把事件类型(可读,可写)和事件源(fd)回调给业务方。  具体的业务回调负责set_result(具体的业务操作)

因为上面的43,56行注册的事件只需要在41,54行进行简单的future.set_result(OPERATIONS)即可,真实的操作在OPERATIONS里执行。

 

 

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

在回调风格中:

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

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

基于生成器的协程风格:

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

 (

执行流程:

1: crawler.fetch():基于yield的生成器

2:task()封装协程和future, step():  next_future = coro.send(None) 启动协程,同时用next_future 接收协程下一次yield的对象

3:fetch:   启动协程:1: f = future()    2:epoll.register(on_connected)   3: yield f

4:  第二步中的next_future捕获到值,等于第三步中的 f; 同时为next_future 添加step()回调

5:epoll返回已连接事件,回调第三步中的on_connected

6: on_connected中为第三步中的future f.set_result(None);  这里的f 等于 第四步中的next_future

7: 第六步中的f.set_result()内部会调用f的callbak, 即第四步中添加的step()

8: step():  next_future = coro.send(f.result);     f为第七步中的f, f.result为第六步中设置的None;  next_future为新的下一步yield的对象

9:fetch()协程继续执行。。。。。。循环到第三步

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 subgen      #yield from后面加可迭代对象或者生成器,等价于for 循环yield
 
def gen_two():
    subgen = range(10)    
    for item in subgen:        
     yield item

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

 

def gen():
    yield from subgen()

def subgen():
    while True:
        x = yield
        yield x+1

def 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连接的功能:

 

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

 

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

 

上面代码整体来讲没什么问题,可复用的代码已经抽象出去,作为子生成器也可以使用 yield from 语法来获取值。

但另外有个点需要注意:在第24和第35行返回future对象的时候,我们了yield from f 而不是原来的yield fyield可以直接作用于普通Python对象,而yield from却不行(必须是可迭代对象或者生成器),所以我们对Future还要进一步改造,把它变成一个iterable对象就可以了。

16_yf_future

只是增加了__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中发展的历史包袱所致,很多人仍然弄不明白生成器协程的联系与区别,也弄不明白yield 和 yield from 的区别。这种混乱的状态也违背Python之禅的一些准则。

于是Python设计者们又快马加鞭地在 3.5 中新增了async/await语法(PEP 492),对协程有了明确而显式的支持,称之为原生协程

async/await 和 yield 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

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

  • 没有了yield 或 yield 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的区别。

参考资料

  • 《A Web Crawler With asyncio Coroutines》
  • 《让 CPU 告诉你硬盘和网络到底有多慢》

相关代码

  • http://github.com/denglj/aiotutorial