Python异步编程详解
一、异步编程相关概念
1、I/O模型
- IO操作实际过程涉及到内核和调用这个IO操作的进程。对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
- 1.等待数据准备 (Waiting for the data to be ready)
- 2.将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
- 正式因为这两个阶段,linux系统产生了下面五种网络模式的方案:
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)
- 阻塞 I/O:当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。当进程进入阻塞状态,是不占用CPU资源的。
- 非阻塞 I/O:当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。
- I/O 多路复用:IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
- 信号驱动 I/O:
等待数据就绪阶段不阻塞,数据就绪后内核给进程发信号,复制数据阶段阻塞。
- 异步 I/O:用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
也就是说异步 I/O两个阶段都不阻塞。
- 可用下图表示:
2、同步和异步、阻塞和非阻塞
- 同步和异步:对于同步和异步而言,往往是一个函数调用之后,是否直接返回结果,如果函数挂起,直到获得结果,这是同步;如果函数马上返回,等数据到达再通知函数,那么这是异步的路程。实际上同步与异步是针对应用程序与内核的交互而言的。同步过程中进程触发IO操作并等待或者轮询的去查看IO操作是否完成。异步过程中进程触发IO操作以后,直接返回,做自己的事情,IO交给内核来处理,完成后内核通知进程IO完成。
- 阻塞和非阻塞:至于阻塞和非阻塞,则是函数是否让线程挂起不再往下执行。通常同步阻塞,异步非阻塞。简单理解为需要做一件事能不能立即得到返回应答,如果不能立即获得返回,需要等待,那就阻塞了,否则就可以理解为非阻塞。
- 区分阻塞和非阻塞只要区分函数调用之后是否挂起返回就可以了,区分异步和同步,则是函数调用之后,数据或条件满足之后如何通知函数。等待数据返回则是同步,通过回调则是异步。对于同步模型,主要是第一阶段处理方法不一样。而异步模型,两个阶段都不一样。
3、并发和并行
- 并发:并发描述的是程序的组织结构。指程序要被设计成多个可独立执行的子任务。指的是
同一时间段
可执行多个任务。 - 并行:并行描述的是程序的执行状态。指多个任务同时被执行。指的是
同一时刻
可执行多个任务。 - 并发包含并行,是比并行跟广泛的概念。同一个CPU核心上多线程切换执行时并发,多个CPU核心上多进程同时执行是并行。
4、进程、线程和协程
- 进程:进程是一种古老而典型的上下文系统,每个进程有独立的地址空间,资源句柄,他们互相之间不发生干扰。每个进程在内核中会有一个数据结构进行描述,我们称其为进程描述符。这些描述符包含了系统管理进程所需的信息,并且放在一个叫做任务队列的队列里面。进程是系统资源分配的最小单位。进程的创建和销毁都是相对于系统资源,所以是一种比较昂贵的操作。进程是抢占式的争夺CPU运行自身,而CPU单核的情况下同一时间只能执行一个进程的代码,但是多进程的实现则是通过CPU飞快的切换不同进程,因此使得看上去就像是多个进程在同时进行。进程适用于CPU密集型的任务和IO密集型的任务。
- 线程:线程是一种轻量进程,是进程的一个实体,线程属于进程,多个线程共享进程的内存地址空间和资源。线程是CPU调度的最小单位。线程的切换需要操作系统调度,要陷入内核空间,由内核进行切换,程序无法控制。线程适用于IO密集型的任务。
- 协程:
协程是为非抢占式多任务产生子程序的计算机程序组件,协程允许不同入口点在不同位置暂停或开始执行程序。
从技术的角度来说,“协程就是你可以暂停执行的函数”。协程是属于线程的。协程程序是在线程里面运行的,因此协程又称微线程和纤程等,是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈,没有线程的上下文切换消耗。协程的调度切换是用户(程序员)手动切换的,因此更加灵活,因此又叫用户空间线程。由于协程是用户调度的,所以不会出现执行一半的代码片段被强制中断了,因此无需原子操作锁。简单来说,协程是一种允许在特定位置暂停或恢复的子程序
。协程适用于IO密集型的任务。
5、异步编程
- 回调:回调函数可以理解为是IO事件完毕后执行提前注册的回调函数。把I/O事件的等待和监听任务交给了操作系统,操作系统在知道I/O状态发生改变后,通过回调通知调用程序。
- 事件循环:事件循环 “是一种等待程序分配事件或消息的编程架构”。基本上来说事件循环就是,“当A发生时,执行B”。 事件循环提供一种循环机制,让你可以“在A发生时,执行B”。基本上来说事件循环就是监听当有什么发生时,同时事件循环也关心这件事并执行相应的代码。事件循环被认为是一种循环是因为它不停地收集事件并通过循环来查找如何应对这些事件。对 Python 来说,用来提供事件循环的 asyncio 被加入标准库中。asyncio 重点解决网络服务中的问题,事件循环在这里将来自socket的 I/O 已经准备好读和/或写作为“当A发生时”(通过selectors模块)。
- 异步编程:异步编程是一种IO模型,异步IO模型需要一个消息循环,在消息循环中,主线程不断地重复“读取消息-处理消息”这一过程。不论什么编程语言,但凡要做异步编程,
事件循环+回调
这种模式是逃不掉的,尽管它可能用的不是epoll,也可能不是while循环。但是由于基于回调的异步模型会出现回调地狱、错误处理困难、堆栈撕裂等问题,所以Python在事件循环+回调的基础上衍生出了基于协程的解决方案。 在Python中协程和事件循环一起使用构成了异步编程。
Python 3.4 以后通过标准库 asyncio 获得了事件循环的特性(主要通过selectors模块来实现)。
二、Python异步编程进化史
1、生成器yield
协程是为非抢占式多任务产生子程序的计算机程序组件,协程允许不同入口点在不同位置暂停或开始执行程序。
从技术的角度来说,“协程就是你可以暂停执行的函数”。是不是和生成器的特性很像?- 生成器第一次在PEP 255中提出(那时也把它成为迭代器,因为它实现了迭代器协议)。生成器允许创建一个在计算下一个值时不会浪费内存空间的迭代器。让函数遇到
yield
表达式时暂停执行,并且能够在后面重新执行,这对于减少内存使用、生成无限序列非常有用。 - 为了支持用生成器做简单的协程,Python 2.5 对生成器进行了增强(PEP 342)。有了PEP 342的加持,生成器可以通过
yield
暂停执行和向外返回数据,也可以通过send()
向生成器内发送数据,还可以通过throw()
向生成器内抛出异常以便随时终止生成器的运行。 yield关键字产生的协程是如何运行的,如下图:
从上图可以看出,调用生成器函数时,并不会立即执行函数,而是返回一个生成器对象,然后通过next()函数触发生成器,函数执行到yield表达式时会暂停,并将yield后面的值返回给触发者,通过send()函数将值从外部传入到生成器内部,生成器继续执行,直到遇到下一个yield表达式,当生成器函数所有的代码执行完没有遇到下一个yield,就会抛出异常,如果对生成器使用for循环,for循环会自动处理异常。
- 其实next()和send()在一定意义上作用是相似的,区别是send()可以传递yield表达式的值进去,而next()不能传递特定的值,只能传递None进去。因此,我们可以看做c.next() 和 c.send(None) 作用是一样的。 因此也可以不通过next()函数,通过send(None)来触发生成器,使用send()触发第一次传入的参数必须是None,否则会报错,因为第一次触发没有yield语句来接收其他非None的值。
- send(msg)和next()都会有返回值,返回值是下一个yield表达式的参数,比如说yield 5 则返回5 。另外,在一个生成器中,如果没有 return,则默认执行至函数完毕,如果在执行过程中 return,则直接抛出 StopIteration 终止迭代。
2、yield from
- Python3.3版本的PEP 380中添加了yield from语法。允许一个generator生成器将其部分操作委派给另一个生成器。其产生的主要动力在于使生成器能够很容易分为多个拥有send和throw方法的子生成器,像一个大函数可以分为多个子函数一样简单。Python的生成器是协程coroutine的一种形式,但它的局限性在于只能向它的直接调用者yield值。这意味着那些包含yield的代码不能想其他代码那样被分离出来放到一个单独的函数中。这也正是yield from要解决的。
- yield from 后面需要加的是可迭代对象,它可以是普通的可迭代对象,也可以是迭代器,甚至是生成器。
- 虽然yield from主要设计用来向子生成器委派操作任务,但yield from可以向任意的迭代器委派操作。
2.1、替代for循环
- 对于简单的迭代器,
yield from iterable
本质上等于for item in iterable: yield item
的缩写版:1234567891011121314151617#使用yield语句def gen():for c in 'AB':yield cfor i in range(1, 3):yield i...list(gen())['A', 'B', 1, 2]>>>#使用yield from实现相同的功能def gen():yield from 'AB'yield from range(1, 3)...list(gen())['A', 'B', 1, 2]
2.2、打开双通道
- 先明确几个概念:
- 调用方:调用委派生成器的客户端(调用方)代码
- 委托生成器:包含yield from表达式的生成器函数
- 子生成器:yield from后面加的生成器函数
- 但是yield from不仅仅如此,还有更加强大的功能,不同于普通的循环,
yield from允许子生成器直接从调用者接收其发送的信息或者抛出调用时遇到的异常,并且返回给委派生产器一个值。
- 代码如下:12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061from collections import namedtupleResult = namedtuple('Result', 'count average')# the subgeneratordef averager():total = 0.0count = 0average = Nonewhile True:term = yieldif term is None:breaktotal += termcount += 1average = total / countreturn Result(count, average)# the delegating generatordef grouper(results, key):while True:#只有当生成器averager()结束,才会返回结果给results赋值results[key] = yield from averager()def main(data):results = {}for key, values in data.items():group = grouper(results, key)next(group)for value in values:group.send(value)group.send(None)report(results)#如果不使用yield from,仅仅通过yield实现相同的效果,如下:def main2(data):for key, values in data.items():aver = averager()next(aver)for value in values:aver.send(value)try: #通过异常接受返回的数据aver.send(None)except Exception as e:result = e.valueprint(result)def report(results):for key, result in sorted(results.items()):group, unit = key.split(';')print('{:2} {:5} averaging {:.2f}{}'.format(result.count, group, result.average, unit))data = {'girls;kg':[40.9, 38.5, 44.3, 42.2, 45.2, 41.7, 44.5, 38.0, 40.6, 44.5],'girls;m':[1.6, 1.51, 1.4, 1.3, 1.41, 1.39, 1.33, 1.46, 1.45, 1.43],'boys;kg':[39.0, 40.8, 43.2, 40.8, 43.1, 38.6, 41.4, 40.6, 36.3],'boys;m':[1.38, 1.5, 1.32, 1.25, 1.37, 1.48, 1.25, 1.49, 1.46],}if __name__ == '__main__':main(data)
- 总结如下:
- 1.迭代器(即可指子生成器)产生的值直接返还给调用者
- 2.任何使用send()方法发给委派生产器(即外部生产器)的值被直接传递给迭代器。如果send值是None,则调用迭代器next()方法;如果不为None,则调用迭代器的send()方法。如果对迭代器的调用产生StopIteration异常,委派生产器恢复继续执行yield from后面的语句;若迭代器产生其他任何异常,则都传递给委派生产器。
- 3.除了GeneratorExit 异常外的其他抛给委派生产器的异常,将会被传递到迭代器的throw()方法。如果迭代器throw()调用产生了StopIteration异常,委派生产器恢复并继续执行,其他异常则传递给委派生产器。
- 4.如果GeneratorExit异常被抛给委派生产器,或者委派生产器的close()方法被调用,如果迭代器有close()的话也将被调用。如果close()调用产生异常,异常将传递给委派生产器。否则,委派生产器将抛出GeneratorExit 异常。
- 5.当迭代器结束并抛出异常时,yield from表达式的值是其StopIteration 异常中的第一个参数。
- 6.一个生成器中的return expr语句将会从生成器退出并抛出 StopIteration(expr)异常。
3、asyncio框架
- 用yield from改进基于生成器的协程,代码抽象程度更高。至此,Python已经具备异步编程的基础能力,于是Python语言开发者们充分利用yield from,在Python 3.4 试验性引入的异步I/O框架asyncio(PEP 3156),提供了基于协程做异步I/O编写单线程并发代码的基础设施。
- Python 3.4 中,
asyncio.coroutine
修饰器用来标记作为协程的函数,这里的协程是和asyncio及其事件循环一起使用的。这赋予了Python第一个对于协程的明确定义:实现了PEP 342添加到生成器中的这一方法的对象,并通过collections.abc.Coroutine这一抽象基类
表征的对象。这意味着突然之间所有实现了协程接口的生成器,即便它们并不是要以协程方式应用,都符合这一定义。为了修正这一点,asyncio 要求所有要用作协程的生成器必须由asyncio.coroutine修饰。 - 有了对协程明确的定义(能够匹配生成器所提供的API),你可以对任何asyncio.Future对象使用 yield from,从而将其传递给事件循环,暂停协程的执行来等待某些事情的发生( future 对象并不重要,只是asyncio细节的实现)。一旦 future 对象获取了事件循环,它会一直在那里监听,直到完成它需要做的一切。当 future 完成自己的任务之后,事件循环会察觉到,暂停并等待在那里的协程会通过send()方法获取future对象的返回值并开始继续执行。
在 Python 3.4 中,用于异步编程并被标记为协程的函数看起来是这样的:
12345678910111213141516import asyncio# Borrowed from http://curio.readthedocs.org/en/latest/tutorial.html.def countdown(number, n):while n > 0:print('T-minus', n, '({})'.format(number))yield from asyncio.sleep(1)n -= 1loop = asyncio.get_event_loop()tasks = [asyncio.ensure_future(countdown("A", 2)),asyncio.ensure_future(countdown("B", 3))]loop.run_until_complete(asyncio.wait(tasks))loop.close()虽然发展到 Python 3.4 时有了yield from的加持让协程更容易了,但是由于协程在Python中发展的历史包袱所致,迭代器的过度重载,使用生成器实现协程功能有很多缺点:
- 协程与常规的生成器在相同语法时用以混淆,尤其是对心开发者而言。
- 一个函数是否是协程需要通过是否主体代码中使用了yield或者yield from语句进行检测,这样在重构代码中添加、去除过程中容易出现不明显的错误
- 异步调用的支持被yield支持的语法先定了,导致我们无法使用更多的语法特性,比如with和for语句。
- 于是根据Python 3.5 Beta期间的反馈,进行了重新设计:明确的把协程从生成器里独立出来—原生协程现在拥有了自己完整的独立类型,而不再是一种新的生成器类型。
4、async/await 原生协程
- Python设计者们在 3.5 中新增了async/await语法(PEP 492),将协程作为原生Python语言特性,并且将他们与生成器明确的区分开。它避免了生成器/协程中间的混淆,方便编写出不依赖于特定库的协程代码,称之为原生协程。async/await 和 yield from这两种风格的协程底层复用共同的实现,而且相互兼容。在Python 3.6 中asyncio库“转正”,不再是实验性质的,成为标准库的正式一员。
- Python 3.5 添加了
types.coroutine
修饰器,也可以像 asyncio.coroutine 一样将生成器标记为协程。你可以用 async def 来定义一个协程函数,虽然这个函数不能包含任何形式的 yield 语句;只有 return 和 await 可以从协程中返回值。 下面的新语法用于声明原生协程:
12async def read_data(db):pass协程的主要属性包括:
async def函数始终为协程,即使它不包含await表达式。
如果在async函数中使用yield或者yield from表达式会产生SyntaxError错误。
- 在内部,引入了两个新的代码对象标记:
- CO_COROUTINE用于标记原生协程(和新语法一起定义)
- CO_ITERABLE_COROUTINE用于标记基于生成器的协程,兼容原生协程。(通过types.coroutine()函数设置)
- 常规生成器在调用时会返回一个genertor对象,同理,协程在调用时会返回一个coroutine对象。
- 协程不再抛出StopIteration异常,而是替代为RuntimeError。常规生成器实现类似的行为需要进行引入future(PEP-3156)
- 当协程进行垃圾回收时,一个从未被await的协程会抛出RuntimeWarning异常
types.coroutine():
在types模块中新添加了一个函数coroutine(fn)用于asyncio中基于生成器的协程与本PEP中引入的原生携协程互通。
使用它,“生成器实现的协程”和“原生协程”之间可以进行互操作。1234def process_data(db):data = yield from read_data(db)...这个函数将生成器函数对象设置CO_ITERABLE_COROUTINE标记,将返回对象变为coroutine对象。如果fn不是一个生成器函数,那么它会对其进行封装。如果它返回一个生成器,那么它会封装一个awaitable代理对象。
- 注意:CO_COROUTINE标记不能通过types.coroutine()进行设置,这就可以将新语法定义的原生协程与基于生成器的协程进行区分。
- await与yield from相似,await关键字的行为类似标记了一个断点,挂起协程的执行直到其他awaitable对象完成并返回结果数据。它复用了yield from的实现,并且添加了额外的验证参数。await只接受以下之一的awaitable对象:
- 一个原生协程函数返回的原生协程对象。
- 一个使用types.coroutine()修饰器的函数返回的基于生成器的协程对象。
- 一个包含返回迭代器的await方法的对象。
- 协程链:协程的一个关键特性是它们可以组成协程链,就像函数调用链一样,一个协程对象是awaitable的,因此其他协程可以await另一个协程对象。
- 任意一个yield from链都会以一个yield结束,这是Future实现的基本机制。因此,协程在内部中是一种特殊的生成器。
每个await最终会被await调用链条上的某个yield语句挂起。
关于基于生成器的协程和async定义的原生协程之间的差异,关键点是只有基于生成器的协程可以真正的暂停执行并强制性返回给事件循环。所以每个await最终会被await调用链条上的某个由types.coroutine()装饰的包含yield语句的协程函数挂起。
- 为了启用协程的这一特点,一个新的魔术方法
__await__
被添加进来。在asyncio中,对于对象在await语句启用Future对象只需要添加await = iter这行到asyncio.Future类中。带有await方法的对象也叫做Future-like对象。 - 另外还新增了异步上下文管理 async with 和异步迭代器 async for。异步生成器和异步推导式都让迭代变得并发,他们所做的只是提供同步对应的外观,但是有问题的循环能够放弃对事件循环的控制,以便运行其他协程。
- 更多有关原生协程的实现细节,可参考PEP 0492。
- 关于何时以及如何能够和不能使用async / await,有一套严格的规则:
- 使用async关键字创建一个协程函数,里面包含await或者return,调用协程函数,必须使用await获得函数返回结果。
- 在async异步函数中使用yield并不常见,这会创建一个异步生成器,可以使用async for来迭代异步生成器。
- 在async异步函数中使用yield from会抛出语法错误。同样在普通函数中使用await也是语法错误。
5、将 async/await 看做异步编程的 API
- Python的核心开发者David指出:
async/await 实际上是异步编程的 API,人们不应该将async/await等同于asyncio,而应该将asyncio看作是一个利用async/await API 进行异步编程的框架。async/await 的设计意图就是为了让其足够灵活从而不需要依赖asyncio或者仅仅是为了适应这一框架而扭曲关键的设计决策。
- 下面是将async/await 看做异步编程的 API的一个完整的异步编程的例子,包括事件循环,三个countdown()协程函数并发运行:12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879import datetimeimport heapqimport typesimport timeclass Task:"""相当于asyncio.Task,存储协程和要执行的时间"""def __init__(self, wait_until, coro):self.coro = coroself.waiting_until = wait_untildef __eq__(self, other):return self.waiting_until == other.waiting_untildef __lt__(self, other):return self.waiting_until < other.waiting_untilclass SleepingLoop:"""一个事件循环,每次执行最先需要执行的协程,时间没到就阻塞等待,相当于asyncio中的事件循环"""def __init__(self, *coros):self._new = corosself._waiting = []def run_until_complete(self):# 启动所有的协程for coro in self._new:print(coro)wait_for = coro.send(None)heapq.heappush(self._waiting, Task(wait_for, coro))# 保持运行,直到没有其他事情要做while self._waiting:now = datetime.datetime.now()# 每次取出最先执行的协程task = heapq.heappop(self._waiting)if now < task.waiting_until:# 阻塞等待指定的休眠时间delta = task.waiting_until - nowtime.sleep(delta.total_seconds())print(task.coro, delta.total_seconds())now = datetime.datetime.now()try:# 恢复不需要等待的协程wait_until = task.coro.send(now)heapq.heappush(self._waiting, Task(wait_until, task.coro))except StopIteration:# 捕捉协程结束的抛出异常passdef sleep(seconds):"""暂停一个协程指定时间,可把他当做asyncio.sleep()"""now = datetime.datetime.now()wait_until = now + datetime.timedelta(seconds=seconds)actual = yield wait_untilreturn actual - nowasync def countdown(label, length, *, delay=0):"""协程函数,实现具体的任务"""print(label, 'waiting', delay, 'seconds before starting countdown')delta = await sleep(delay)print(label, 'starting after waiting', delta)while length:print(label, 'T-minus', length)waited = await sleep(1)length -= 1print(label, 'lift-off!')def main():"""启动事件循环,运行三个协程"""loop = SleepingLoop(countdown('A', 5), countdown('B', 3, delay=2),countdown('C', 4, delay=1))start = datetime.datetime.now()loop.run_until_complete()print('Total elapsed time is', datetime.datetime.now() - start)if __name__ == '__main__':main()
6、总结Python异步编程版本细节
- Python 2.5:增强生成器yield。
- Python 3.3:引入yield from表达式。
- Python 3.4:asyncio作为具有临时API的状态引入Python标准库中。
- Python 3.5:async和await成为Python语法的一部分,用于表示和协程,但它们还不是保留关键字。
- Python 3.6:引入异步生成器和异步推导式,asyncio的API被宣布为稳定版本而非临时。
- Python 3.7:async和await成为保留关键字,它们旨在替换asyncio.coroutine()装饰器。 asyncio.run()被引入asyncio包,简化协程运行,其中还包括许多其他功能。
三、asyncio工作原理
- 前面提到异步编程是通过
事件循环+回调
这种模式实现的,但是这种模式会出现回调地狱、错误处理困难、堆栈撕裂等问题,所以Python在事件循环+回调的基础上衍生出了基于协程的解决方案,并不是没有回调,而是巧妙的通过Future对象将回调隐藏其中,方便使用和理解。 - asyncio框架中有三个主要组件:
协程对象、事件循环和Future&Task对象。
协程对象
- 协程对象:指一个使用async关键字定义的异步函数,是需要执行的任务,它的调用不会立即执行函数,而是会返回一个协程对象。协程不能直接运行,协程对象需要注册到事件循环,由事件循环调用。
- 有两种方法可以从协程读取异步函数的输出:
- 1、第一种方法是使用await关键字,这只能在异步函数中使用,等待协程终止并返回结果。
- 2、第二种方法是将协程添加到事件循环中。
- 在Python中编写异步函数时要记住的一件事是,在def之前使用了async关键字并不意味着你的异步函数将同时运行。如果采用普通函数并在其前面添加async,则事件循环将运行函数而不会中断,因为你没有指定允许循环中断你的函数以运行另一个协同程序的位置。指定允许事件循环中断运行的位置非常简单,每次使用关键字await等待事件循环都可以停止运行你的函数并切换到运行另一个注册到循环的协同程序。
事件循环
- 事件循环是执行我们的异步代码并决定如何在异步函数之间切换的对象。如果某个协程在等待某些资源,我们需要暂停它的执行,在事件循环中注册这个事件,以便当事件发生的时候,能再次唤醒该协程的执行。
- 运行异步函数我们首先需要创建一个协程,然后创建future或task对象,将它们添加到事件循环中,到目前为止,我们的异步函数中没有任何代码被执行过,只有调用loop.run_until_completed启动事件循环,才会开始执行future或task对象,loop.run_until_completed会阻塞程序直到所有的协程对象都执行完毕。
流程如下图:
1、事件循环是在线程中执行
- 2、从队列中取得任务
- 3、每个任务在协程中执行下一步动作
- 4、如果在一个协程中调用另一个协程(await
),会触发上下文切换,挂起当前协程,并保存现场环境(变量,状态),然后载入被调用协程 - 5、如果协程的执行到阻塞部分(阻塞I/O,Sleep),当前协程会挂起,并将控制权返回到线程的消息循环中,然后消息循环继续从队列中执行下一个任务...以此类推
- 6、队列中的所有任务执行完毕后,消息循环返回第一个任务
Future & Task对象
Future对象
- Future对象:Future对象封装了一个未来会被计算的可调用的异步执行对象,他们能被放入队列,他们的状态、结果或者异常能被查询。 Future对象有一个
result
属性,用于存放未来的执行结果。还有个set_result()
方法,是用于设置result
的,并且会在给result
绑定值以后运行事先给Future对象添加的回调。回调是通过Future对象的add_done_callback()
方法添加的。 重要的是Future对象不能被我们创建,只能被异步框架创建,有两种方法:
1234# 该函数在 Python 3.7 中被加入,更加高层次的函数,返回Task对象future1 = asyncio.create_task(my_coroutine)# 在Python 3.7 之前,是更加低级的函数,返回Future对象或者Task对象future2 = asyncio.ensure_future(my_coroutine)第一种方法在循环中添加一个协程并返回一个task对象,task对象是future的子类型。第二种方法非常相似,当传入协程对象时返回一个Task对象,唯一的区别是它也可以接受Future对象或Task对象,在这种情况下它不会做任何事情并且返回Future对象或者Task对象不变。
- Future对象有几个状态:
- Pending:就绪
- Running:运行
- Done:完成
- Cancelled:取消
- 创建Future对象的时候,状态为pending,事件循环调用执行的时候就是running,调用完毕就是done,如果需要取消Future对象的调度执行,可调用Future对象的cancel()函数。
- 除此之外,Future对象还有下面一些常用的方法:
- result():立即返回Future对象运行结果或者抛出执行时的异常,没有timeout参数,如果Future没有完成,不会阻塞等待结果,而是直接抛出InvalidStateError异常。最好的方式是通过await获取运行结果,await会自动等待Future完成返回结果,也不会阻塞事件循环,因为在asyncio中,await被用来将控制权返回给事件循环。
- done():非阻塞的返回Future对象是否成功取消或者运行结束或被设置异常,而不是查看future是否已经执行完成。
- cancelled():判断Future对象是否被取消。
- add_done_callback():传入一个可回调对象,当Future对象done时被调用。
- exception():获取Future对象中的异常信息,只有当Future对象done时才会返回。
- get_loop():获取当前Future对象绑定的事件循环。
需要注意的是,当在协程内部引发未处理的异常时,它不会像正常的同步编程那样破坏我们的程序,相反,它存储在future内部,如果在程序退出之前没有处理异常,则会出现以下错误:
1Task exception was never retrieved有两种方法可以解决此问题,在访问future对象的结果时捕获异常或调用future对象的异常函数:
12345678try:# 调用结果时捕获异常my_promise.result()catch Exception:pass# 获取在协程执行过程中抛出的异常my_promise.exception()
Task对象
- Task对象:Task对象是Future对象的子类型,coroutine和Future联系在一起。与 Future 不同的是它包含了一个将要执行的协程,从而组成一个需要被调度的任务,
Task类用来管理协同程序运行的状态,负责在事件循环中执行一个协程对象,是一个协程驱动器,用来恢复继续执行生成器,管理生成器的状态。
- Task对象被用来在事件循环中运行协程。如果一个协程在等待一个Future对象,Task对象会挂起该协程的执行并等待该Future对象完成。当该Future对象完成,被暂停的协程将恢复执行。事件循环使用协作调度: 一个事件循环每次运行一个Task对象。当一个Task对象等待一个Future对象完成时,该事件循环会运行其他Task、回调或执行IO操作。
- 使用高层级的asyncio.create_task()函数来创建Task对象,也可用低层级的loop.create_task()或ensure_future()函数。不建议手动实例化 Task 对象。
实例分析
这里举一个 Python 官方文档 的例子:
123456789101112131415161718192021import asyncioimport timeasync def compute(x, y):print("Compute {} + {}...".format(x, y))await asyncio.sleep(2.0)return x+yasync def print_sum(x, y):result = await compute(x, y)print("{} + {} = {}".format(x, y, result))start = time.time()loop = asyncio.get_event_loop()tasks = [asyncio.ensure_future(print_sum(0, 0)),asyncio.ensure_future(print_sum(1, 1)),asyncio.ensure_future(print_sum(2, 2)),]loop.run_until_complete(asyncio.wait(tasks))loop.close()print("Total elapsed time {}".format(time.time() - start))上面的代码的执行流程是:
详细的流程应该是这样的:
详细代码步骤可参看下面资料:
四、asyncio使用详解
1、基本使用
- 协程完整的工作流程是这样:
- 1、定义/创建协程对象
- 2、将协程转为task任务
- 3、定义事件循环对象容器
- 4、将task任务扔进事件循环对象中触发运行
- 协程:协程通过 async/await 语法进行声明,是编写异步应用的推荐方式。注意:简单地调用一个协程并不会将其加入执行队列。
- 要真正运行一个协程,asyncio 提供了三种主要机制:
- 1、
asyncio.run() 函数用来运行最高层级的入口点协程函数。
该函数运行传入的协程,负责管理asyncio事件循环并结束异步生成器。当有其他asyncio事件循环在同一线程中运行时,此函数不能被调用。如果debug为True,事件循环将以调试模式运行。该函数总是会创建一个新的事件循环并在结束时关闭。它应当被用作asyncio程序的主入口点,理想情况下应当只被调用一次。该函数在Python 3.7被引入,会隐式处理事件循环。 - 2、
使用await关键字等待一个协程。
- 3、
asyncio.create_task()函数用来并发运行作为asyncio任务的多个协程。
当一个协程通过asyncio.create_task()等函数被打包为一个Task对象,该协程将自动排入队列准备立即运行。然后通过await或者asyncio.run()自动运行该任务。该函数在Python 3.7中被加入。在Python 3.7之前,可以改用低层级的asyncio.ensure_future()函数。
- 1、
三种运行方式代码如下:
1234567891011121314151617181920212223242526272829303132333435import asyncioimport timeasync def say_after(delay, what):await asyncio.sleep(delay)print(what)return delayasync def main():print(f"started at {time.strftime('%X')}")# 通过await等待运行,此时两个任务按顺序运行result1 = await say_after(2, 'hello')result2 = await say_after(1, 'world')print(result1, result2)task1 = asyncio.create_task(say_after(2, 'hello2'))task2 = asyncio.create_task(say_after(1, 'world2'))# 通过asyncio.task()包装为task然后await等待运行,此时两个任务并发运行result3 = await task1result4 = await task2print(result3, result4)print(f"finished at {time.strftime('%X')}")# 通过asyncio.run()函数运行asyncio.run(main())# 下面相当于上面的asyncio.run()函数# loop = asyncio.get_event_loop()# try:# loop.run_until_complete(main())# finally:# loop.close()可等待对象:如果一个对象可以在await语句中使用,那么它就是可等待对象。许多 asyncio API 都被设计为接受可等待对象。可等待对象有三种主要类型:
协程、Task对象和Future对象。
- Future对象:Future对象是一种特殊的低层级可等待对象,表示一个异步操作的最终结果。当一个Future对象被等待,这意味着协程将保持等待直到该Future对象在其他地方操作完毕。在asyncio中需要Future对象以便允许通过async/await使用基于回调的代码。
通常情况下没有必要在应用层级的代码中创建Future对象。
asyncio.sleep()
:阻塞delay指定的秒数。如果指定了result,则当协程完成时将其返回给调用者。sleep()总是会挂起当前任务,以允许其他任务运行。注意
:如果不在main()函数中await其他协程,其他协程可能来不及运行就被取消了。因为asyncio.run(main())调用的式是loop.run_until_complete(main()),在没有await的情况下,事件循环只关注main()函数一个协程的结束,而不管main()函数中的其他协程任务,没有await,其他协程任务可能在任务完成前被取消。如果需要获取当前待处理Task对象的列表,可以使用asyncio.all_tasks()
函数,使用asyncio.current_task()
函数获取当前运行的Task实例,如果没有正在运行的任务则返回None。
并发运行任务
asyncio.gather(*aws, loop=None, return_exceptions=False)
:并发运行aws序列中的可等待对象。如果aws中的某个可等待对象为协程,它将自动作为一个Task加入队列。如果所有可等待对象都成功完成,结果将是一个由所有返回值聚合而成的列表。结果值的顺序与aws中可等待对象的顺序一致。- 代码如下:12345678910111213141516171819202122import asyncioasync def factorial(name, number):f = 1for i in range(2, number + 1):print(f"Task {name}: Compute factorial({i})...")await asyncio.sleep(1)f *= iprint(f"Task {name}: factorial({number}) = {f}")return fasync def main():# 并发运行三个任务result = await asyncio.gather(factorial("A", 5),factorial("B", 3),factorial("C", 4),)print(result)asyncio.run(main())
等待任务
asyncio.wait(aws, *, loop=None, timeout=None, return_when=ALL_COMPLETED)
:并发运行aws指定的可等待对象并阻塞线程直到满足return_when指定的条件。如果aws中的某个可等待对象为协程,它将自动作为任务加入日程。直接向wait()传入协程对象已弃用,因为这会导致令人迷惑的行为。返回两个Task/Future集合: (done, pending),(已完成的,未完成的)代码示例如下:
12345678910111213141516171819202122import asyncioasync def factorial(name, number):f = 1for i in range(2, number + 1):print(f"Task {name}: Compute factorial({i})...")await asyncio.sleep(1)f *= iprint(f"Task {name}: factorial({number}) = {f}")return fasync def main():tasks = list()for i in range(2, 5):tasks.append(asyncio.create_task(factorial("Task" + str(i), i)))done, pending = await asyncio.wait(tasks)for d in done:result = await dprint(result)asyncio.run(main())return_when 指定此函数应在何时返回。它必须为以下常数之一:
- FIRST_COMPLETED:函数将在任意可等待对象结束或取消时返回。
- FIRST_EXCEPTION:函数将在任意可等待对象因引发异常而结束时返回。当没有引发任何异常时它就相当于 ALL_COMPLETED。
- ALL_COMPLETED:函数将在所有可等待对象结束或取消时返回。默认是该参数。
asyncio.wait_for(aw, timeout, *, loop=None)
:等待aw可等待对象完成,指定timeout秒数后超时。asyncio.as_completed(aws, *, loop=None, timeout=None)
:并发地运行aws集合中的可等待对象。返回一个Future对象的迭代器。返回的每个Future对象代表来自剩余可等待对象集合的最早结果。
回调
- 可通过task.add_done_callback()方法给任务添加回调函数,当任务执行完成时会自动调用该函数并传入Task对象。123456789101112131415import asynciodef callback(task):print(task, "done")async def hello():print("hello")await asyncio.sleep(0)async def main():task = asyncio.create_task(hello())task.add_done_callback(callback)await taskasyncio.run(main())
2、队列
- asyncio框架的队列设计的和queue模块的很类似。尽管asyncio模块的队列不是线程安全的,它们被设计为专门用于async/await代码。需要注意的是asyncio的队列没有timeout参数,可使用asyncio.wait_for()函数进行超时等待。
官方示例代码如下:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455import asyncioimport itertools as itimport osimport randomimport timeasync def makeitem(size: int = 5) -> str:return os.urandom(size).hex()async def randint(a: int, b: int) -> int:return random.randint(a, b)async def randsleep(a: int = 1, b: int = 5, caller=None) -> None:i = await randint(a, b)if caller:print(f"{caller} sleeping for {i} seconds.")await asyncio.sleep(i)async def produce(name: int, q: asyncio.Queue) -> None:"""生产者"""n = await randint(1, 5)for _ in it.repeat(None, n): # 同步添加任务await randsleep(caller=f"Producer {name}")i = await makeitem()t = time.perf_counter()await q.put((i, t))print(f"Producer {name} added <{i}> to queue.")async def consume(name: int, q: asyncio.Queue) -> None:"""消费者"""while True:await randsleep(caller=f"Consumer {name}")i, t = await q.get()now = time.perf_counter()print(f"Consumer {name} got element <{i}>"f" in {now - t:0.5f} seconds.")q.task_done()async def main(nprod: int, ncon: int):q = asyncio.Queue()# asyncio.run()会自动运行消费者和生产者producers = [asyncio.create_task(produce(n, q)) for n in range(nprod)]consumers = [asyncio.create_task(consume(n, q)) for n in range(ncon)]await asyncio.gather(*producers) # 等待生产者结束await q.join() # 阻塞直到队列中的所有项目都被接收和处理# 取消消费者for c in consumers:c.cancel()if __name__ == "__main__":random.seed(444)start = time.perf_counter()asyncio.run(main(2, 3))elapsed = time.perf_counter() - startprint(f"Program completed in {elapsed:0.5f} seconds.")上面的代码逻辑流程如下:
- 1、将向队列put任务的操作单独编写为一个生产者协程。
- 2、启动生产者和消费者
- 3、等待生产者结束,通过
await producer()
或者await gather(*producers)
,或者其他方式。 - 4、一旦生产者结束,通过
await q.join()
等待队列中所有的项目被接受和处理完 - 5、取消消费者任务,否则消费者会一直等待不可能出现的下一个任务。
3、结合线程和进程
多线程
- 默认情况下,事件循环在主线程中运行,并在其线程中执行所有回调和任务,同一时刻只有一个任务在执行。需要注意的是:要处理信号和执行子进程,必须在主线程中运行事件循环。
- 如何将异步代码和多线程结合在一起使用,有下面几种方法:
方法一:启动一个子线程,在子线程中运行异步代码
12345678910111213141516171819202122import asynciofrom threading import Threadasync def hello(i):print("hello", i)await asyncio.sleep(i)return iasync def main():tasks = [asyncio.create_task(hello(i)) for i in range(5)]await asyncio.gather(*tasks)def async_main():asyncio.run(main())# 在子线程中运行异步任务t = Thread(target=async_main)t.start()# 不会干扰主线程for i in range(3):print(i)方法二:loop.call_soon_threadsafe()函数
loop.call_soon()用于注册回调,当异步任务执行完成时会在当前线程按顺序执行注册的普通函数。loop.call_soon_threadsafe()用于在一个线程中注册回调函数,在另一个线程中执行注册的普通函数。
12345678910111213141516171819202122232425262728293031323334from threading import Threadimport asyncioimport timeasync def hello(i):print("hello", i)await asyncio.sleep(i)return iasync def main():tasks = [asyncio.create_task(hello(i)) for i in range(5)]await asyncio.gather(*tasks)def start_loop(loop):asyncio.set_event_loop(loop)loop.run_until_complete(main())def more_work(x):print('More work {}'.format(x))time.sleep(x)print('Finished more work {}'.format(x))# 在主线程创建事件循环,并在另一个线程中启动new_loop = asyncio.new_event_loop()t = Thread(target=start_loop, args=(new_loop,))t.start()# 在主线程中注册回调函数,在子线程中按顺序执行回调函数new_loop.call_soon_threadsafe(more_work, 1)new_loop.call_soon_threadsafe(more_work, 3)# 不会阻塞主线程for i in range(10):print(i)方法三:asyncio.run_coroutine_threadsafe()函数
loop.call_soon_threadsafe()函数是同步执行回调函数,asyncio.run_coroutine_threadsafe()函数则是异步执行回调函数,传入写成函数。
1234567891011121314151617181920212223242526272829from threading import Threadimport asyncioimport timeasync def hello(i):print("hello", i)await asyncio.sleep(i)return iasync def main():tasks = [asyncio.create_task(hello(i)) for i in range(5)]await asyncio.gather(*tasks)def start_loop(loop):asyncio.set_event_loop(loop)loop.run_until_complete(main())# 在主线程创建事件循环,并在另一个线程中启动new_loop = asyncio.new_event_loop()t = Thread(target=start_loop, args=(new_loop,))t.start()# 在主线程中注册回调协程函数,在子线程中按异步执行回调函数asyncio.run_coroutine_threadsafe(hello(3.5), new_loop)asyncio.run_coroutine_threadsafe(hello(1.5), new_loop)# 不会阻塞主线程for i in range(10):print(i)方法四:loop.run_in_executor(executor, func, *args)
- asyncio的事件循环在背后维护着一个ThreadPoolExecutor对象,我们可以调用run_in_executor方法,把可调用对象发给它执行。
- loop.run_in_executor()函数用于在特定的executor中执行函数。executor参数必须是 concurrent.futures.Executor实例对象,传入None表示在默认的executor中执行。返回一个可等待的协程对象。123456789101112131415161718192021222324252627import asyncioimport concurrent.futuresimport timedef blocks(n):"""阻塞任务"""time.sleep(0.1)return n ** 2async def run_blocking_tasks(executor):loop = asyncio.get_event_loop()# 在线程池中执行阻塞任务blocking_tasks = [loop.run_in_executor(executor, blocks, i)for i in range(6)]completed, pending = await asyncio.wait(blocking_tasks)results = [t.result() for t in completed]print(results)# 创建线程池executor = concurrent.futures.ThreadPoolExecutor(max_workers=3)event_loop = asyncio.get_event_loop()try:event_loop.run_until_complete(run_blocking_tasks(executor))finally:event_loop.close()
多进程
- 结合多进程运行异步代码和多线程类似,有下面几种方法:
方法一:启动一个子进程,在子进程中运行异步代码
12345678910111213141516171819import asyncioimport multiprocessingasync def hello(i):print("hello", i)await asyncio.sleep(1)def strap(tx, rx):loop = asyncio.new_event_loop()asyncio.set_event_loop(loop)loop.run_until_complete(hello(3))# 启动一个子线程,在子线程中运行异步代码p = multiprocessing.Process(target=strap, args=(1, 3))p.start()# 子进程和主进程不会相互干扰for i in range(10):print(i)方法二:loop.run_in_executor(executor, func, *args)
和多线程一样,只不过是把线程池换成进程池。
123456789101112131415161718192021222324252627import asyncioimport concurrent.futuresimport timedef blocks(n):"""阻塞任务"""time.sleep(0.1)return n ** 2async def run_blocking_tasks(executor):loop = asyncio.get_event_loop()# 在进程池中执行阻塞任务blocking_tasks = [loop.run_in_executor(executor, blocks, i)for i in range(6)]completed, pending = await asyncio.wait(blocking_tasks)results = [t.result() for t in completed]print(results)# 创建进程池executor = concurrent.futures.ProcessPoolExecutor(max_workers=3)event_loop = asyncio.get_event_loop()try:event_loop.run_until_complete(run_blocking_tasks(executor))finally:event_loop.close()方法三:第三方库aiomultiprocess
第三方库aiomultiprocess可以方面的将异步代码和多进程结合使用,下面是官方demo:
12345678910111213import asynciofrom aiohttp import requestfrom aiomultiprocess import Workerasync def get(url):async with request("GET", url) as response:return await response.text("utf-8")async def main():p = Worker(target=get, args=("https://jreese.sh", ))response = await pasyncio.run(main())更多的使用案例,请参考:asyncio — Asynchronous I/O, event loop, and concurrency tools(该系列文章本博客已翻译)
五、资源
可以和async/await一起使用的库
- 来自aio-libs:
- 来自magicstack:
- 来自其他地方:
- trio:类似于asyncio的异步框架,旨在更加简单易用
- curio:类似于asyncio的异步框架,Python大牛dabeaz开发的实验性质的库
- aiofiles:异步文件IO
- asks:异步HTTP客户端,跟requests类似
- asyncio-redis:异步Redis客户端
- aioprocessing:将多进程和asyncio结合在一起
- umongo:异步MongoDB客户端
- unsync:通过在单独的线程中使用环境事件循环来取消同步asyncio
- aiostream:异步版的itertools
- awesome-asyncio
参考资料
- Python 中的进程、线程、协程、同步、异步、回调
- 深入理解 Python 异步编程(上)
- 简明网络I/O模型—同步异步阻塞非阻塞之惑
- Linux IO模式及 select、poll、epoll详解
- I/O 的五大模型:阻塞、非阻塞、复用、信号驱动、异步
- 网络IO之阻塞、非阻塞、同步、异步总结
- 进程、线程和协程的概念
- 进程和线程、协程的区别
- 进程,线程,协程与并行,并发
- [译] Python 3.5 协程究竟是个啥
- HOW THE HECK DOES ASYNC/AWAIT WORK IN PYTHON 3.5?
- 《流畅的Python》
- [PEP 0255]Simple Generators
- [PEP 0342]Coroutines via Enhanced Generators
- [PEP 0380]Syntax for Delegating to a Subgenerator
- In practice, what are the main uses for the new “yield from” syntax in Python 3.3?
- 深入理解Python的yield from语法
- Python yield from 用法详解
- PEP 0492 Coroutines with async and await syntax 中文翻译
- 雾里看花之 Python Asyncio
- Python 中的异步编程:Asyncio
- Async programming in Python with asyncio
- How does asyncio actually work?
- Python “黑魔法” 之 Generator Coroutines
- Python黑魔法 — 异步IO( asyncio) 协程
- asyncio — Asynchronous I/O
- Python asyncio详解
- A Web Crawler With asyncio Coroutines
- 手把手教你如何使用Python的异步IO框架:asyncio(上)
- 手把手教你如何使用Python的异步IO框架:asyncio(中)
- 手把手教你如何使用Python的异步IO框架:asyncio(下)
- Python之asyncio
- Using asyncio.Queue for producer-consumer flow
- John Reese - Thinking Outside the GIL with AsyncIO and Multiprocessing - PyCon 2018
- Threaded Asynchronous Magic and How to Wield It
- asyncio中使用阻塞函数
- aiomultiprocess
- Some thoughts on asynchronous API design in a post-async/await world