原文出处: 阿驹nginx
不少朋友对异步编程都处于“据说很强大”的认知状态。鲜有在生产项目中使用它。而使用它的同窗,则大多数都停留在知道如何使用 Tornado、Twisted、Gevent 这类异步框架上,出现各类古怪的问题难以解决。并且使用了异步框架的部分同窗,因为用法不对,感受它并没牛逼到哪里去,因此不少同窗作 Web 后端服务时仍是采用 Flask、Django等传统的非异步框架。git
从上两届 PyCon 技术大会看来,异步编程已经成了 Python 生态下一阶段的主旋律。如新兴的 Go、Rust、Elixir 等编程语言都将其支持异步和高并发做为主要“卖点”,技术变化趋势如此。Python 生态为不落人后,从2013年起由 Python 之父 Guido 亲自操刀主持了Tulip(asyncio)项目的开发。程序员
本系列教程分为上中下篇,让读者深刻理解Python异步编程,解决在使用异步编程中的疑惑,深刻学习Python3中新增的asyncio库和async/await语法,尽情享受 Python 带来的简洁优雅和高效率。github
掌握 Python异步编程的一些指导细则算法
经过学习相关概念,咱们逐步解释异步编程是什么。编程
阻塞是无处不在的,包括CPU切换上下文时,全部的进程都没法真正干事情,它们也会被阻塞。(若是是多核CPU则正在执行上下文切换操做的核不可被利用。)windows
非阻塞的存在是由于阻塞存在,正由于某个操做阻塞致使的耗时与效率低下,咱们才要把它变成非阻塞的。后端
上文提到的“通讯方式”一般是指异步和并发编程提供的同步原语,如信号量、锁、同步队列等等。咱们需知道,虽然这些通讯方式是为了让多个程序在必定条件下同步执行,但正由于是异步的存在,才须要这些通讯方式。若是全部程序都是按序执行,其自己就是同步的,又何需这些同步信号呢?缓存
并发提供了一种程序组织结构方式,让问题的解决方案能够并行执行,但并行执行不是必须的。并发:交替作不一样事的能力,并行:同时作不一样事的能力。服务器
要支持并发,必须拆分为多任务,不一样任务相对而言才有阻塞/非阻塞、同步/异步。因此,并发、异步、非阻塞三个词老是如影随形。
若是在某程序的运行时,能根据已经执行的指令准确判断它接下来要进行哪一个具体操做,那它是同步程序,反之则为异步程序。(无序与有序的区别)
同步/异步、阻塞/非阻塞并不是水火不容,要看讨论的程序所处的封装级别。例如购物程序在处理多个用户的浏览请求能够是异步的,而更新库存时必须是同步的。
因此,几乎全部的异步框架都将异步编程模型简化:一次只容许处理一个事件。故而有关异步的讨论几乎都集中在了单线程内。
因此,一旦采起异步编程,每一个异步调用必须“足够小”,不能耗时过久。如何拆分异步任务成了难题。
如上文所述,异步编程面临诸多难点,Python 之父亲自上阵打磨4年才使 asyncio 模块在Python 3.6中“转正”,如此苦心为何?答案只有一个:它值得!下面咱们看看为什么而值得。
咱们将一个 2.6GHz 的 CPU 拟人化,假设它执行一条命令的时间,他它感受上过了一秒钟。CPU是计算机的处理核心,也是最宝贵的资源,若是有浪费CPU的运行时间,致使其利用率不足,那程序效率必然低下(由于实际上有资源能够使效率更高)。
如上图所示,在千兆网上传输2KB数据,CPU感受过了14个小时,若是是在10M的公网上呢?那效率会低百倍!若是在这么长的一段时间内,CPU只是傻等结果而不能去干其余事情,是否是在浪费CPU的青春?
鲁迅说,浪费“CPU”的时间等于谋财害命。而凶手就是程序猿。
若是一个程序不能有效利用一台计算机资源,那必然须要更多的计算机经过运行更多的程序实例来弥补需求缺口。例如我前不久主导重写的项目,使用Python异步编程,改版后由原来的7台服务器削减至3台,成本骤降57%。一台AWS m4.xlarge 型通用服务器按需付费实例一年价格约 1.2 万人民币。
若是不在意钱的消耗,那也会在乎效率问题。当服务器数量堆叠到必定规模后,若是不改进软件架构和实现,加机器是徒劳,并且运维成本会骤然增长。好比别人家的电商平台支持6000单/秒支付,而自家在下单量才支撑2000单/秒,在双十一这种活动的时候,钱送上门也赚不到。
C10k(concurrently handling 10k connections)是一个在1999年被提出来的技术挑战,如何在一颗1GHz CPU,2G内存,1gbps网络环境下,让单台服务器同时为1万个客户端提供FTP服务。而到了2010年后,随着硬件技术的发展,这个问题被延伸为C10M,即如何利用8核心CPU,64G内存,在10gbps的网络上保持1000万并发链接,或是每秒钟处理100万的链接。(两种类型的计算机资源在各自的时代都约为1200美圆)
成本和效率问题是从企业经营角度讲,C10k/C10M问题则是从技术角度出发挑战软硬件极限。C10k/C10M 问题得解,成本问题和效率问题迎刃而解。
《约束理论与企业优化》中指出:“除了瓶颈以外,任何改进都是幻觉。”
CPU告诉咱们,它本身很快,而上下文切换慢、内存读数据慢、磁盘寻址与取数据慢、网络传输慢……总之,离开CPU 后的一切,除了一级高速缓存,都很慢。咱们观察计算机的组成能够知道,主要由运算器、控制器、存储器、输入设备、输出设备五部分组成。运算器和控制器主要集成在CPU中,除此以外全是I/O,包括读写内存、读写磁盘、读写网卡全都是I/O。I/O成了最大的瓶颈。
异步程序能够提升效率,而最大的瓶颈在I/O,业界诞生的解决方案没出意料:异步I/O吧,异步I/O吧,异步I/O吧吧!
现在,地球上最发达、规模最庞大的计算机程序,莫过于因特网。而从CPU的时间观中可知,网络I/O是最大的I/O瓶颈,除了宕机没有比它更慢的。因此,诸多异步框架都对准的是网络I/O。
咱们从一个爬虫例子提及,从因特网上下载10篇网页。
最容易想到的解决方案就是依次下载,从创建socket链接到发送网络请求再到读取响应数据,顺序进行。
注:整体耗时约为4.5秒。(因网络波动每次测试结果有所变更,本文取屡次平均值)
如上图所示,blocking_way() 的做用是创建 socket 链接,发送HTTP请求,而后从 socket 读取HTTP响应并返回数据。示例中咱们请求了 example.com 的首页。在sync_way() 执行了10次,即下载 example.com 首页10次。
在示例代码中有两个关键点。一是第10行的 sock.connect((‘example.com’, 80)),该调用的做用是向example.com主机的80端口发起网络链接请求。 二是第14行、第18行的sock.recv(4096),该调用的做用是从socket上读取4K字节数据。
咱们知道,建立网络链接,多久能建立完成不是客户端决定的,而是由网络情况和服务端处理能力共同决定。服务端何时返回了响应数据并被客户端接收到可供程序读取,也是不可预测的。因此sock.connect()和sock.recv()这两个调用在默认状况下是阻塞的。
注:sock.send()函数并不会阻塞过久,它只负责将请求数据拷贝到TCP/IP协议栈的系统缓冲区中就返回,并不等待服务端返回的应答确认。
假设网络环境不好,建立网络链接须要1秒钟,那么sock.connect()就得阻塞1秒钟,等待网络链接成功。这1秒钟对一颗2.6GHz的CPU来说,仿佛过去了83年,然而它不能干任何事情。sock.recv()也是同样的必须得等到服务端的响应数据已经被客户端接收。咱们下载10篇网页,这个阻塞过程就得重复10次。若是一个爬虫系统天天要下载1000万篇网页呢?!
上面说了不少,咱们力图说明一件事:同步阻塞的网络交互方式,效率低十分低下。特别是在网络交互频繁的程序中。这种方式根本不可能挑战C10K/C10M。
在一个程序内,依次执行10次太耗时,那开10个同样的程序同时执行不就好了。因而咱们想到了多进程编程。为何会先想到多进程呢?发展脉络如此。在更早的操做系统(Linux 2.4)及其之前,进程是 OS 调度任务的实体,是面向进程设计的OS。
注:整体耗时约为 0.6 秒。
改善效果立竿见影。但仍然有问题。整体耗时并无缩减到原来的十分之一,而是九分之一左右,还有一些时间耗到哪里去了?进程切换开销。
进程切换开销不止像“CPU的时间观”所列的“上下文切换”那么低。CPU从一个进程切换到另外一个进程,须要把旧进程运行时的寄存器状态、内存状态所有保存好,再将另外一个进程以前保存的数据恢复。对CPU来说,几个小时就干等着。当进程数量大于CPU核心数量时,进程切换是必然须要的。
除了切换开销,多进程还有另外的缺点。通常的服务器在可以稳定运行的前提下,能够同时处理的进程数在数十个到数百个规模。若是进程数量规模更大,系统运行将不稳定,并且可用内存资源每每也会不足。
多进程解决方案在面临天天须要成百上千万次下载任务的爬虫系统,或者须要同时搞定数万并发的电商系统来讲,并不适合。
除了切换开销大,以及可支持的任务规模小以外,多进程还有其余缺点,如状态共享等问题,后文会有说起,此处再也不细究。
因为线程的数据结构比进程更轻量级,同一个进程能够容纳多个线程,从进程到线程的优化由此展开。后来的OS也把调度单位由进程转为线程,进程只做为线程的容器,用于管理进程所需的资源。并且OS级别的线程是能够被分配到不一样的CPU核心同时运行的。
注:整体运行时间约0.43秒。
结果符合预期,比多进程耗时要少些。从运行时间上看,多线程彷佛已经解决了切换开销大的问题。并且可支持的任务数量规模,也变成了数百个到数千个。
可是,多线程仍有问题,特别是Python里的多线程。首先,Python中的多线程由于GIL的存在,它们并不能利用CPU多核优点,一个Python进程中,只容许有一个线程处于运行状态。那为何结果仍是如预期,耗时缩减到了十分之一?
由于在作阻塞的系统调用时,例如sock.connect(),sock.recv()时,当前线程会释放GIL,让别的线程有执行机会。可是单个线程内,在阻塞调用上仍是阻塞的。
小提示:Python中 time.sleep 是阻塞的,都知道使用它要谨慎,但在多线程编程中,time.sleep 并不会阻塞其余线程。
除了GIL以外,全部的多线程还有通病。它们是被OS调度,调度策略是抢占式的,以保证同等优先级的线程都有均等的执行机会,那带来的问题是:并不知道下一时刻是哪一个线程被运行,也不知道它正要执行的代码是什么。因此就可能存在竞态条件。
例如爬虫工做线程从任务队列拿待抓取URL的时候,若是多个爬虫线程同时来取,那这个任务到底该给谁?那就须要用到“锁”或“同步队列”来保证下载任务不会被重复执行。
并且线程支持的多任务规模,在数百到数千的数量规模。在大规模的高频网络交互系统中,仍然有些吃力。固然,多线程最主要的问题仍是竞态条件。
终于,咱们来到了非阻塞解决方案。先来看看最原始的非阻塞如何工做的。
注:整体耗时约4.3秒。
首先注意到两点,就感受被骗了。一是耗时与同步阻塞至关,二是代码更复杂。要非阻塞何用?且慢。
上图第9行代码sock.setblocking(False)告诉OS,让socket上阻塞调用都改成非阻塞的方式。以前咱们说到,非阻塞就是在作一件事的时候,不阻碍调用它的程序作别的事情。上述代码在执行完 sock.connect() 和 sock.recv() 后的确再也不阻塞,能够继续往下执行请求准备的代码或者是执行下一次读取。
代码变得更复杂也是上述缘由所致。第11行要放在try语句内,是由于socket在发送非阻塞链接请求过程当中,系统底层也会抛出异常。connect()被调用以后,当即能够往下执行第15和16行的代码。
须要while循环不断尝试 send(),是由于connect()已经非阻塞,在send()之时并不知道 socket 的链接是否就绪,只有不断尝试,尝试成功为止,即发送数据成功了。recv()调用也是同理。
虽然 connect() 和 recv() 再也不阻塞主程序,空出来的时间段CPU没有空闲着,但并无利用好这空闲去作其余有意义的事情,而是在循环尝试读写 socket (不停判断非阻塞调用的状态是否就绪)。还得处理来自底层的可忽略的异常。也不能同时处理多个 socket 。
而后10次下载任务仍然按序进行。因此整体执行时间和同步阻塞至关。若是非得这样子,那还不如同步阻塞算了。
判断非阻塞调用是否就绪若是 OS 能作,是否是应用程序就能够不用本身去等待和判断了,就能够利用这个空闲去作其余事情以提升效率。
因此OS将I/O状态的变化都封装成了事件,如可读事件、可写事件。而且提供了专门的系统模块让应用程序能够接收事件通知。这个模块就是select。让应用程序能够经过select注册文件描述符和回调函数。当文件描述符的状态发生变化时,select 就调用事先注册的回调函数。
select因其算法效率比较低,后来改进成了poll,再后来又有进一步改进,BSD内核改进成了kqueue模块,而Linux内核改进成了epoll模块。这四个模块的做用都相同,暴露给程序员使用的API也几乎一致,区别在于kqueue 和 epoll 在处理大量文件描述符时效率更高。
鉴于 Linux 服务器的广泛性,以及为了追求更高效率,因此咱们经常听闻被探讨的模块都是 epoll 。
把I/O事件的等待和监放任务交给了 OS,那 OS 在知道I/O状态发生改变后(例如socket链接已创建成功可发送数据),它又怎么知道接下来该干吗呢?只能回调。
须要咱们将发送数据与读取数据封装成独立的函数,让epoll代替应用程序监听socket状态时,得告诉epoll:“若是socket状态变为能够往里写数据(链接创建成功了),请调用HTTP请求发送函数。若是socket 变为能够读数据了(客户端已收到响应),请调用响应处理函数。”
因而咱们利用epoll结合回调机制重构爬虫代码:
此处和前面稍有不一样的是,咱们将下载不一样的10个页面,相对URL路径存放于urls_todo集合中。如今看看改进在哪。
首先,不断尝试send() 和 recv() 的两个循环被消灭掉了。
其次,导入了selectors模块,并建立了一个DefaultSelector 实例。Python标准库提供的selectors模块是对底层select/poll/epoll/kqueue的封装。DefaultSelector类会根据 OS 环境自动选择最佳的模块,那在 Linux 2.5.44 及更新的版本上都是epoll了。
而后,在第25行和第31行分别注册了socket可写事件(EVENT_WRITE)和可读事件(EVENT_READ)发生后应该采起的回调函数。
虽然代码结构清晰了,阻塞操做也交给OS去等待和通知了,可是,咱们要抓取10个不一样页面,就得建立10个Crawler实例,就有20个事件将要发生,那如何从selector里获取当前正发生的事件,而且获得对应的回调函数去执行呢?
为了解决上述问题,那咱们只得采用老办法,写一个循环,去访问selector模块,等待它告诉咱们当前是哪一个事件发生了,应该对应哪一个回调。这个等待事件通知的循环,称之为事件循环。
上述代码中,咱们用stopped全局变量控制事件循环什么时候中止。当urls_todo消耗完毕后,会标记stopped为True。
重要的是第49行代码,selector.select() 是一个阻塞调用,由于若是事件不发生,那应用程序就没事件可处理,因此就干脆阻塞在这里等待事件发生。那能够推断,若是只下载一篇网页,必定要connect()以后才能send()继而recv(),那它的效率和阻塞的方式是同样的。由于不在connect()/recv()上阻塞,也得在select()上阻塞。
因此,selector机制(后文以此称呼代指epoll/kqueue)是设计用来解决大量并发链接的。当系统中有大量非阻塞调用,能随时产生事件的时候,selector机制才能发挥最大的威力。
下面是如何启建立10个下载任务和启动事件循环的:
注:整体耗时约0.45秒。
上述执行结果使人振奋。在单线程内用 事件循环+回调 搞定了10篇网页同时下载的问题。这,已是异步编程了。虽然有一个for 循环顺序地建立Crawler 实例并调用 fetch 方法,可是fetch 内仅有connect()和注册可写事件,并且从执行时间明显能够推断,多个下载任务确实在同时进行!
上述代码异步执行的过程:
目前为止,咱们已经从同步阻塞学习到了异步非阻塞。掌握了在单线程内同时并发执行多个网络I/O阻塞型任务的黑魔法。并且与多线程相比,连线程切换都没有了,执行回调函数是函数调用开销,在线程的栈内完成,所以性能也更好,单机支持的任务规模也变成了数万到数十万个。(不过咱们知道:没有免费午饭,也没有银弹。)
部分编程语言中,对异步编程的支持就止步于此(不含语言官方以外的扩展)。须要程序猿直接使用epoll去注册事件和回调、维护一个事件循环,而后大多数时间都花在设计回调函数上。
经过本节的学习,咱们应该认识到,不论什么编程语言,但凡要作异步编程,上述的“事件循环+回调”这种模式是逃不掉的,尽管它可能用的不是epoll,也可能不是while循环。若是你找到了一种不属于 “等会儿告诉你” 模型的异步方式,请当即给我打电话(注意,打电话是Call)。
为何咱们在某些异步编程中并无看到 CallBack 模式呢?这就是咱们接下来要探讨的问题。本节是学习异步编程的一个终点,也是另外一个起点。毕竟我们讲 Python 异步编程,还没提到其主角协程的用武之地。
咱们将在本节学习到 Python 生态对异步编程的支持是如何继承前文所述的“事件循环+回调”模式演变到asyncio的原生协程模式。
在第3节中,咱们已经学会了“事件循环+回调”的基本运行原理,能够基于这种方式在单线程内实现异步编程。也确实可以大大提升程序运行效率。可是,刚才所学的只是最基本的,然而在生产项目中,要应对的复杂度会大大增长。考虑以下问题:
在实际编程中,上述系列问题不可避免。在这些问题的背后隐藏着回调编程模式的一些缺点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
def callback_1():
# processing ...
def callback_2():
# processing.....
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)
|
写同步代码时,关联的操做时自上而下运行:
1
2
|
do_a()
do_b()
|
若是 b 处理依赖于 a 处理的结果,而 a 过程是异步调用,就不知 a 什么时候能返回值,须要将后续的处理过程以callback的方式传递给 a ,让 a 执行完之后能够执行 b。代码变化为:
1
|
do_a(do_b())
|
若是整个流程中所有改成异步处理,而流程比较长的话,代码逻辑就会成为这样:
1
|
do_a(do_b(do_c(do_d(do_e(do_f(......))))))
|
上面实际也是回调地狱式的风格,但这不是主要矛盾。主要在于,本来从上而下的代码结构,要改为从内到外的。先f,再e,再d,…,直到最外层 a 执行完成。在同步版本中,执行完a后执行b,这是线程的指令指针控制着的流程,而在回调版本中,流程就是程序猿须要注意和安排的。
若是说代码风格难看是小事,但栈撕裂和状态管理困难这两个缺点会让基于回调的异步编程很艰难。因此不一样编程语言的生态都在致力于解决这个问题。才诞生了后来的Promise、Co-routine等解决方案。
Python 生态也以终为始,秉承着“程序猿没必要难程序猿”的原则,让语言和框架开发者苦逼一点,也要让应用开发者舒坦。在事件循环+回调的基础上衍生出了基于协程的解决方案,表明做有 Tornado、Twisted、asyncio 等。接下来咱们随着 Python 生态异步编程的发展过程,深刻理解Python异步编程。
经过前面的学习,咱们清楚地认识到异步编程最大的困难:异步任务什么时候执行完毕?接下来要对异步调用的返回结果作什么操做?
上述问题咱们已经经过事件循环和回调解决了。可是回调会让程序变得复杂。要异步,必回调,又是否有办法规避其缺点呢?那须要弄清楚其本质,为何回调是必须的?还有使用回调时克服的那些缺点又是为了什么?
答案是程序为了知道本身已经干了什么?正在干什么?未来要干什么?换言之,程序得知道当前所处的状态,并且要将这个状态在不一样的回调之间延续下去。
多个回调之间的状态管理困难,那让每一个回调都能管理本身的状态怎么样?链式调用会有栈撕裂的困难,让回调之间再也不链式调用怎样?不链式调用的话,那又如何让被调用者知道已经完成了?那就让这个回调通知那个回调如何?并且一个回调,不就是一个待处理任务吗?
任务之间得相互通知,每一个任务得有本身的状态。那不就是很古老的编程技法:协做式多任务?然而要在单线程内作调度,啊哈,协程!每一个协程具备本身的栈帧,固然能知道本身处于什么状态,协程之间能够协做那天然能够通知别的协程。
它是非抢占式的多任务子例程的归纳,能够容许有多个入口点在例程中肯定的位置来控制程序的暂停与恢复执行。
例程是什么?编程语言定义的可被调用的代码段,为了完成某个特定功能而封装在一块儿的一系列指令。通常的编程语言都用称为函数或方法的代码结构来体现。
一句话说明什么是协程:协程是一种用户态的轻量级线程。
一句话并不能彻底归纳协程的所有,可是起码能让咱们对协程这个概念有一个基本的印象。
从硬件发展来看,从最初的单核单CPU,到单核多CPU,多核多CPU,彷佛已经到了极限了,可是单核CPU性能却还在不断提高。server端也在不断的发展变化。若是将程序分为IO密集型应用和CPU密集型应用,两者的server的发展以下:
IO密集型应用: 多进程->多线程->事件驱动->协程
CPU密集型应用:多进程-->多线程
若是说多进程对于多CPU,多线程对应多核CPU,那么事件驱动和协程则是在充分挖掘不断提升性能的单核CPU的潜力。
如下的讨论如无特别说明,不考虑cpu密集型应用。
异步 vs 同步
不管是线程仍是进程,使用的都是同步进制,当发生阻塞时,性能会大幅度下降,没法充分利用CPU潜力,浪费硬件投资,更重要形成软件模块的铁板化,紧耦合,没法切割,不利于往后扩展和变化。无论是进程仍是线程,每次阻塞、切换都须要陷入系统调用(system call),先让CPU跑操做系统的调度程序,而后再由调度程序决定该跑哪个进程(线程)。多个线程之间在一些访问互斥的代码时还须要加上锁,这也是致使多线程编程难的缘由之一。
现下流行的异步server都是基于事件驱动的(如nginx)。事件驱动简化了编程模型,很好地解决了多线程难于编程,难于调试的问题。异步事件驱动模型中,把会致使阻塞的操做转化为一个异步操做,主线程负责发起这个异步操做,并处理这个异步操做的结果。因为全部阻塞的操做都转化为异步操做,理论上主线程的大部分时间都是在处理实际的计算任务,少了多线程的调度时间,因此这种模型的性能一般会比较好。
总的说来,当单核cpu性能提高,cpu不在成为性能瓶颈时,采用异步server可以简化编程模型,也能提升IO密集型应用的性能。
协程 vs 线程
以前说到,协程是一种用户级的轻量级线程。协程拥有本身的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其余地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。所以:
协程能保留上一次调用时的状态(即全部局部状态的一个特定组合),每次过程重入时,就至关于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。
在并发编程中,协程与线程相似,每一个协程表示一个执行单元,有本身的本地数据,与其它协程共享全局数据和其它资源。目前主流语言基本上都选择了多线程做为并发设施,与线程相关的概念是抢占式多任务(Preemptive multitasking),而与协程相关的是协做式多任务。
无论是进程仍是线程,每次阻塞、切换都须要陷入系统调用(system call),先让CPU跑操做系统的调度程序,而后再由调度程序决定该跑哪个进程(线程)。
并且因为抢占式调度执行顺序没法肯定的特色,使用线程时须要很是当心地处理同步问题,而协程彻底不存在这个问题(事件驱动和异步程序也有一样的优势)。
咱们在本身在进程里面完成逻辑流调度,碰着i\o我就用非阻塞式的。那么咱们便可以利用到异步优点,又能够避免反复系统调用,还有进程切换形成的开销,分分钟给你上几千个逻辑流不费力。这就是协程。
协程 vs 事件驱动
以nginx为表明的事件驱动的异步server正在横扫天下,那么事件驱动模型会是server端模型的终点吗?
咱们能够深刻了解下,事件驱动编程的模型。
事件驱动编程的架构是预先设计一个事件循环,这个事件循环程序不断地检查目前要处理的信息,根据要处理的信息运行一个触发函数。其中这个外部信息可能来自一个目录夹中的文件,可能来自键盘或鼠标的动做,或者是一个时间事件。这个触发函数,能够是系统默认的也能够是用户注册的回调函数。
事件驱动程序设计着重于弹性以及异步化上面。许多GUI框架(如windows的MFC,Android的GUI框架),Zookeeper的Watcher等都使用了事件驱动机制。将来还会有其余的基于事件驱动的做品出现。
基于事件驱动的编程是单线程思惟,其特色是异步+回调。
协程也是单线程,可是它能让原来要使用异步+回调方式写的非人类代码,能够用看似同步的方式写出来。它是实现推拉互动的所谓非抢占式协做的关键。
总结
协程的好处:
缺点:
早期的 Pythoner 发现 Python 中有种特殊的对象——生成器(Generator),它的特色和协程很像。每一次迭代之间,会暂停执行,继续下一次迭代的时候还不会丢失先前的状态。
为了支持用生成器作简单的协程,Python 2.5 对生成器进行了加强(PEP 342),该加强提案的标题是 “Coroutines via Enhanced Generators”。有了PEP 342的加持,生成器能够经过yield 暂停执行和向外返回数据,也能够经过send()向生成器内发送数据,还能够经过throw()向生成器内抛出异常以便随时终止生成器的运行。
接下来,咱们用基于生成器的协程来重构先前的爬虫代码。
不用回调的方式了,怎么知道异步调用的结果呢?先设计一个对象,异步调用执行完的时候,就把结果放在它里面。这种对象称之为将来对象。
将来对象有一个result属性,用于存放将来的执行结果。还有个set_result()方法,是用于设置result的,而且会在给result绑定值之后运行事先给future添加的回调。回调是经过将来对象的add_done_callback()方法添加的。
不要疑惑此处的callback,说好了不回调的嘛?难道忘了咱们曾经说的要异步,必回调。不过也别急,此处的回调,和先前学到的回调,还真有点不同。
如今不论如何,咱们有了将来对象能够表明将来的值。先用Future来重构爬虫代码。
和先前的回调版本对比,已经有了较大差别。fetch 方法内有了yield表达式,使它成为了生成器。咱们知道生成器须要先调用next()迭代一次或者是先send(None)启动,遇到yield以后便暂停。那这fetch生成器如何再次恢复执行呢?至少 Future 和 Crawler都没看到相关代码。
为了解决上述问题,咱们只需遵循一个编程规则:单一职责,每种角色各司其职,若是还有工做没有角色来作,那就建立一个角色去作。没人来恢复这个生成器的执行么?没人来管理生成器的状态么?建立一个,就叫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就完事了,接下来怎么继续?
该事件循环上场了。接下来,只需等待已经注册的EVENT_WRITE事件发生。事件循环就像心脏通常,只要它开始跳动,整个程序就会持续运行。
注:整体耗时约0.43秒。
如今loop有了些许变化,callback()再也不传递event_key和event_mask参数。也就是说,这里的回调根本不关心是谁触发了这个事件,结合fetch()能够知道,它只需完成对future设置结果值便可f.set_result()。并且future是谁它也不关心,由于协程可以保存本身的状态,知道本身的future是哪一个。也不用关心到底要设置什么值,由于要设置什么值也是协程内安排的。
此时的loop(),真的成了一个心脏,它只管往外泵血,不论这份血液是要输送给大脑仍是要给脚趾,只要它还在跳动,生命就能延续。
在回调风格中:
还有更多示例中没有展现,但确实存在的问题,参见4.1节。
而基于生成器协程的风格:
若是说fetch的容错能力要更强,业务功能也须要更完善,怎么办?并且技术处理的部分(socket相关的)和业务处理的部分(请求与返回数据的处理)混在一块儿。
可是这些关键节点的地方都有yield,抽离出来的代码也须要是生成器。并且fetch()本身也得是生成器。生成器里玩生成器,代码好像要写得更丑才能够……
Python 语言的设计者们也认识到了这个问题,再次秉承着“程序猿没必要为难程序猿”的原则,他们捣鼓出了一个yield from来解决生成器里玩生成器的问题。
yield from 是Python 3.3 新引入的语法(PEP 380)。它主要解决的就是在生成器里玩生成器不方便的问题。它有两大主要功能。
第一个功能是:让嵌套生成器没必要经过循环迭代yield,而是直接yield from。如下两种在生成器里玩子生成器的方式是等价的。
1
2
3
4
5
6
7
|
def gen_one():
subgen
= range(10)
yield from subgen
def gen_two():
subgen = range(10)
for item in subgen:
yield item
|
第二个功能就是在子生成器和原生成器的调用者之间打开双向通道,二者能够直接通讯。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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 <generator> 还能够 yield from <iterable>。
抽象socket链接的功能:
抽象单次recv()和读取完整的response功能:
三个关键点的抽象已经完成,如今重构Crawler类:
上面代码总体来说没什么问题,可复用的代码已经抽象出去,做为子生成器也能够使用 yield from 语法来获取值。但另外有个点须要注意:在第24和第35行返回future对象的时候,咱们了yield from f 而不是原来的yield f。yield能够直接做用于普通Python对象,而yield from却不行,因此咱们对Future还要进一步改造,把它变成一个iterable对象就能够了。
只是增长了__iter__()方法的实现。若是不把Future改为iterable也是能够的,仍是用原来的yield f便可。那为何须要改进呢?
首先,咱们是在基于生成器作协程,而生成器还得是生成器,若是继续混用yield和yield from 作协程,代码可读性和可理解性都很差。其次,若是不改,协程内还得关心它等待的对象是否可被yield,若是协程里还想继续返回协程怎么办?若是想调用普通函数动态生成一个Future对象再返回怎么办?
因此,在Python 3.3 引入yield from新语法以后,就再也不推荐用yield去作协程。全都使用yield from因为其双向通道的功能,可让咱们在协程间为所欲为地传递数据。
用yield from改进基于生成器的协程,代码抽象程度更高。使业务逻辑相关的代码更精简。因为其双向通道功能可让协程之间为所欲为传递数据,使Python异步编程的协程解决方案大大向前迈进了一步。
因而Python语言开发者们充分利用yield from,使Guido主导的Python异步编程框架Tulip迅速脱胎换骨,并火烧眉毛得让它在 Python 3.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库“转正”,再也不是实验性质的,成为标准库的正式一员。
行至此处,咱们已经掌握了asyncio的核心原理,学习了它的原型,也学习了异步I/O在 CPython 官方支持的生态下是如何一步步发展至今的。
实际上,真正的asyncio比咱们前几节中学到的要复杂得多,它还实现了零拷贝、公平调度、异常处理、任务状态管理等等使 Python 异步编程更完善的内容。理解原理和原型对咱们后续学习有莫大的帮助。
本节中,咱们将初步体验asyncio库和新增语法async/await给咱们带来的便利。因为Python2-3的过分期间,Python3.0-3.4的使用者并非太多,也为了避免让更多的人困惑,也由于aysncio在3.6才转正,因此更深刻学习asyncio库的时候咱们将使用async/await定义的原生协程风格,yield from风格的协程再也不阐述(实际上它们可用很小的代价相互代替)。
对比生成器版的协程,使用asyncio库后变化很大:
说明:咱们这里发送和接收HTTP请求再也不本身操做socket的缘由是,在实际作业务项目的过程当中,要处理妥善地HTTP协议会很复杂,咱们须要的是功能完善的异步HTTP客户端,业界已经有了成熟的解决方案,DRY不是吗?
和同步阻塞版的代码对比:
到此为止,咱们已经深刻地学习了异步编程是什么、为何、在Python里是怎么样发展的。咱们找到了一种让代码看起来跟同步代码同样简单,而效率却提高N倍(具体提高状况取决于项目规模、网络环境、实现细节)的异步编程方法。它也没有回调的那些缺点。
本系列教程接下来的一篇将是学习asyncio库如何的使用,快速掌握它的主要内容。后续咱们还会深刻探究asyncio的优势与缺点,也会探讨Python生态中其余异步I/O方案和asyncio的区别。