摘自深入理解Python异步编程(上)
1 什么是异步编程
1.1 阻塞
- 程序未得到所需计算资源时被挂起的状态。
- 程序在等待某个操作完成期间,自身无法继续干别的事情,则称该程序在该操作上是阻塞的。
- 常见的阻塞形式有:网络I/O阻塞、磁盘I/O阻塞、用户输入阻塞等。
阻塞是无处不在的,包括CPU切换上下文时,所有的进程都无法真正干事情,它们也会被阻塞。(如果是多核CPU则正在执行上下文切换操作的核不可被利用。)
1.2 非阻塞
- 程序在等待某操作过程中,自身不被阻塞,可以继续运行干别的事情,则称该程序在该操作上是非阻塞的。
- 非阻塞并不是在任何程序级别、任何情况下都可以存在的。
- 仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。
非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。
1.3 同步
- 不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,称这些程序单元是同步执行的。
- 例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。
- 简言之,同步意味着有序。
1.4 异步
- 为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式。
- 不相关的程序单元之间可以是异步的。
- 例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。
- 简言之,异步意味着无序。
上文提到的“通信方式”通常是指异步和并发编程提供的同步原语,如信号量、锁、同步队列等等。我们需知道,虽然这些通信方式是为了让多个程序在一定条件下同步执行,但正因为是异步的存在,才需要这些通信方式。如果所有程序都是按序执行,其本身就是同步的,又何需这些同步信号呢?
1.5 并发
- 并发描述的是程序的组织结构。指程序要被设计成多个可独立执行的子任务。
- 以利用有限的计算机资源使多个任务可以被实时或近实时执行为目的。
1.6 并行
- 并行描述的是程序的执行状态。指多个任务同时被执行。
- 以利用富余计算资源(多核CPU)加速完成多个任务为目的。
并发提供了一种程序组织结构方式,让问题的解决方案可以并行执行,但并行执行不是必须的。
1.7 概念总结
- 并行是为了利用多核加速多任务完成的进度
- 并发是为了让独立的子任务都有机会被尽快执行,但不一定能加速整体进度
- 非阻塞是为了提高程序整体执行效率
- 异步是高效地组织非阻塞任务的方式
要支持并发,必须拆分为多任务,不同任务相对而言才有阻塞/非阻塞、同步/异步。所以,并发、异步、非阻塞三个词总是如影随形。
1.8 异步编程
- 以进程、线程、协程、函数/方法作为执行任务程序的基本单位,结合回调、事件循环、信号量等机制,以提高程序整体执行效率和并发能力的编程方式。
如果在某程序的运行时,能根据已经执行的指令准确判断它接下来要进行哪个具体操作,那它是同步程序,反之则为异步程序。(无序与有序的区别)
同步/异步、阻塞/非阻塞并非水火不容,要看讨论的程序所处的封装级别。例如购物程序在处理多个用户的浏览请求可以是异步的,而更新库存时必须是同步的。
1.9 异步之难(nán)
- 控制不住“计几”写的程序,因为其执行顺序不可预料,当下正要发生什么事件不可预料。在并行情况下更为复杂和艰难。
所以,几乎所有的异步框架都将异步编程模型简化:一次只允许处理一个事件。故而有关异步的讨论几乎都集中在了单线程内。
- 如果某事件处理程序需要长时间执行,所有其他部分都会被阻塞。
所以,一旦采取异步编程,每个异步调用必须“足够小”,不能耗时太久。如何拆分异步任务成了难题。
- 程序下一步行为往往依赖上一步执行结果,如何知晓上次异步调用已完成并获取结果?
- 回调(Callback)成了必然选择。那又需要面临“回调地狱”的折磨。
3 异步I/O进化之路
如今,地球上最发达、规模最庞大的计算机程序,莫过于因特网。而从CPU的时间观中可知,网络I/O是最大的I/O瓶颈,除了宕机没有比它更慢的。所以,诸多异步框架都对准的是网络I/O。
我们从一个爬虫例子说起,从因特网上下载10篇网页。
3.1 同步阻塞方式
最容易想到的解决方案就是依次下载,从建立socket连接到发送网络请求再到读取响应数据,顺序进行。
注:总体耗时约为4.5秒。
我们知道,创建网络连接,多久能创建完成不是客户端决定的,而是由网络状况和服务端处理能力共同决定。服务端什么时候返回了响应数据并被客户端接收到可供程序读取,也是不可预测的。所以sock.connect()
和sock.recv()
这两个调用在默认情况下是阻塞的。
注:sock.send()
函数并不会阻塞太久,它只负责将请求数据拷贝到TCP/IP协议栈的系统缓冲区中就返回,并不等待服务端返回的应答确认。
3.2 改进方式:多进程
在一个程序内,依次执行10次太耗时,那开10个一样的程序同时执行不就行了。于是我们想到了多进程编程。为什么会先想到多进程呢?发展脉络如此。在更早的操作系统(Linux 2.4)及其以前,进程是 OS 调度任务的实体,是面向进程设计的OS。
注:总体耗时约为 0.6 秒。
改善效果立竿见影。但仍然有问题。总体耗时并没有缩减到原来的十分之一,而是九分之一左右,还有一些时间耗到哪里去了?进程切换开销。
当进程数量大于CPU核心数量时,进程切换是必然需要的。
除了切换开销,一般的服务器在能够稳定运行的前提下,可以同时处理的进程数在数十个到数百个规模。如果进程数量规模更大,系统运行将不稳定,而且可用内存资源往往也会不足。
多进程解决方案在面临每天需要成百上千万次下载任务的爬虫系统,或者需要同时搞定数万并发的电商系统来说,并不适合。
除了切换开销大,以及可支持的任务规模小之外,多进程还有其他缺点,如状态共享等问题。
3.3 继续改进:多线程
由于线程的数据结构比进程更轻量级,同一个进程可以容纳多个线程,从进程到线程的优化由此展开。后来的OS也把调度单位由进程转为线程,进程只作为线程的容器,用于管理进程所需的资源。而且OS级别的线程是可以被分配到不同的CPU核心同时运行的。
注:总体运行时间约0.43秒。
结果符合预期,比多进程耗时要少些。从运行时间上看,多线程似乎已经解决了切换开销大的问题。而且可支持的任务数量规模,也变成了数百个到数千个。
但是,多线程仍有问题,特别是Python里的多线程。首先,Python中的多线程因为GIL的存在,它们并不能利用CPU多核优势,一个Python进程中,只允许有一个线程处于运行状态。那为什么结果还是如预期,耗时缩减到了十分之一?
因为在做阻塞的系统调用时,例如sock.connect()
,sock.recv()
时,当前线程会释放GIL,让别的线程有执行机会。但是单个线程内,在阻塞调用上还是阻塞的。
另外,线程是被OS调度,调度策略是抢占式的,以保证同等优先级的线程都有均等的执行机会,那带来的问题是:并不知道下一时刻是哪个线程被运行,也不知道它正要执行的代码是什么。所以就可能存在竞态条件。
例如爬虫工作线程从任务队列拿待抓取URL的时候,如果多个爬虫线程同时来取,那这个任务到底该给谁?那就需要用到“锁”或“同步队列”来保证下载任务不会被重复执行。
而且线程支持的多任务规模,在数百到数千的数量规模。在大规模的高频网络交互系统中,仍然有些吃力。当然,多线程最主要的问题还是竞态条件。
3.4 非阻塞方式
先来看看最原始的非阻塞如何工作的。
总体耗时约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次下载任务仍然按序进行。所以总体执行时间和同步阻塞相当。
3.5 非阻塞改进
3.5.1 epoll
判断非阻塞调用是否就绪如果 OS 能做,是不是应用程序就可以不用自己去等待和判断了,就可以利用这个空闲去做其他事情以提高效率。
所以OS将I/O状态的变化都封装成了事件,如可读事件、可写事件。并且提供了专门的系统模块让应用程序可以接收事件通知。这个模块就是select
。让应用程序可以通过select
注册文件描述符和回调函数。当文件描述符的状态发生变化时,select
就调用事先注册的回调函数。
select
因其算法效率比较低,后来改进成了poll
,再后来又有进一步改进,Linux内核改进成了epoll
模块。
3.5.2 回调(Callback)
把I/O事件的等待和监听任务交给了 OS,那 OS 在知道I/O状态发生改变后(例如socket连接已建立成功可发送数据),它又怎么知道接下来该干嘛呢?回调。
需要我们将发送数据与读取数据封装成独立的函数,让epoll
代替应用程序监听socket
状态时,得告诉epoll
:“如果socket
状态变为可以往里写数据(连接建立成功了),请调用HTTP请求发送函数。如果socket
变为可以读数据了(客户端已收到响应),请调用响应处理函数。”
于是我们利用epoll
结合回调机制重构爬虫代码:
来看看改进在哪。
首先,不断尝试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
里获取当前正发生的事件,并且得到对应的回调函数去执行呢?
3.5.3 事件循环(Event Loop)
为了解决上述问题,我们写一个循环,去访问selector
模块,等待它告诉我们当前是哪个事件发生了,应该对应哪个回调。这个等待事件通知的循环,称之为事件循环。
selector.select()
是一个阻塞调用,因为如果事件不发生,那应用程序就没事件可处理,所以就干脆阻塞在这里等待事件发生。所以,selector
机制(后文以此称呼代指epoll/kqueue
)是设计用来解决大量并发连接的。当系统中有大量非阻塞调用,能随时产生事件的时候,selector
机制才能发挥最大的威力。
下面是如何启创建10个下载任务和启动事件循环的:
总体耗时约0.45秒。
上述代码异步执行的过程:
- 创建
Crawler
实例; - 调用
fetch
方法,会创建socket
连接和在selector
上注册可写事件; fetch
内并无阻塞操作,该方法立即返回;- 重复上述3个步骤,将10个不同的下载任务都加入事件循环;
- 启动事件循环,进入第1轮循环,阻塞在事件监听上;
- 当某个下载任务
EVENT_WRITE
被触发,回调其connected
方法,第一轮事件循环结束; - 进入第2轮事件循环,当某个下载任务有事件触发,执行其回调函数;此时已经不能推测是哪个事件发生,因为有可能是上次
connected
里的EVENT_READ
先被触发,也可能是其他某个任务的EVENT_WRITE
被触发;(此时,原来在一个下载任务上会阻塞的那段时间被利用起来执行另一个下载任务了) - 循环往复,直至所有下载任务被处理完成
3.5.4 总结
目前为止,我们已经从同步阻塞学习到了异步非阻塞。掌握了在单线程内同时并发执行多个网络I/O阻塞型任务的黑魔法。而且与多线程相比,连线程切换都没有了,执行回调函数是函数调用开销,在线程的栈内完成,因此性能也更好,单机支持的任务规模也变成了数万到数十万个。(不过我们知道:没有免费午餐,也没有银弹。)
部分编程语言中,对异步编程的支持就止步于此(不含语言官方之外的扩展)。需要程序猿直接使用epoll
去注册事件和回调、维护一个事件循环,然后大多数时间都花在设计回调函数上。
通过本节的学习,我们应该认识到,不论什么编程语言,但凡要做异步编程,上述的“事件循环+回调”这种模式是逃不掉的,尽管它可能用的不是epoll
,也可能不是while
循环。
为什么我们在某些异步编程中并没有看到 CallBack 模式呢?这就是我们接下来要探讨的问题。
4 Python 对异步I/O的优化之路
4.1 回调之痛,以终为始
考虑如下问题:
- 如果回调函数执行不正常该如何?
- 如果回调里面还要嵌套回调怎么办?要嵌套很多层怎么办?
- 如果嵌套了多层,其中某个环节出错了会造成什么后果?
- 如果有个数据需要被每个回调都处理怎么办?
- ……
在实际编程中,上述系列问题不可避免。在这些问题的背后隐藏着回调编程模式的一些缺点:
- 回调层次过多时代码可读性差
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)
- 破坏代码结构
写同步代码时,关联的操作时自上而下运行:
do_a()
do_b()
如果 b 处理依赖于 a 处理的结果,而 a 过程是异步调用,就不知 a 何时能返回值,需要将后续的处理过程以callback的方式传递给 a ,让 a 执行完以后可以执行 b。代码变化为:
do_a(do_b())
Jesse comment:应该是do_b(do_a())吧??额。。。。
如果整个流程中全部改为异步处理,而流程比较长的话,代码逻辑就会成为这样:
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
等解决方案。
to be continued...