异步编程入门教程
样例代码
本样例将获取并打印吧主题帖列表
import asyncio
import aiotieba as tb
# [2] 异步函数——`async`关键字
async def main():
# [6] `async with`是什么?
async with tb.Client() as client:
# [1] CPU在何时离开?——`await`关键字
threads = await client.get_threads('天堂鸡汤')
print(threads)
# [4] CPU在何时返回?——事件循环
# 官方文档:运行asyncio程序
# https://docs.python.org/zh-cn/3/library/asyncio-task.html#running-an-asyncio-program
asyncio.run(main())
样例解析
什么是异步
在计算机中,CPU的速度普遍要比网络IO的速度快几个数量级。为了不让网络IO成为系统性能的瓶颈,我们会希望CPU等高速设备不要原地等待网卡慢悠悠地传输数据,而是可以把IO缓冲区交给网卡芯片就立刻离开,暂时转去执行一些其他任务,这其中就体现了异步(Asynchronous)的思想。
Asynchronous的前缀a-意为not,syn意为together,chrono源自古希腊语khronos,意为time,-ous为形容词后缀,合起来就是not-together-time,不同时发生的。在计算机领域,Asynchronous常常用于形容“多个事件不在同一时间发生”。这里的“同一时间”所强调的并不是时间长短,而是逻辑上的连贯性。
应用了异步技术后,在网络请求发出后到网络响应到来前的这段时间,CPU可以暂时离开,切换到其他地方去处理另外的工作。这种逻辑切换使得网络IO的请求事件与响应事件,相对于CPU及其他相关的高速设备而言,不在同一个时间发生。Asynchronous的词源与其术语含义高度匹配,值得反复品读。
CPU在何时离开?——await
关键字
那么Python是如何实现异步的?在抛出一大堆错综复杂的概念来回答这个问题之前,你可以先带着一个更小的子问题来阅读下面的文章——CPU在何时离开?让我们先来看样例。
在样例threads = await client.get_threads()
中,client.get_threads
是一个异步函数。它和同步函数类似,都是可调对象(Callable)。client.get_threads()
调用(call)了这个异步函数,调用异步函数不会像调用同步函数那样返回结果,而是会返回一个“施工方案”,也就是可等待对象(Awaitable
)。此时,在可等待对象client.get_threads()
的左边,那个至关重要的关键字await
出现了。
await意为“等待”,往往用于表达“以被动的姿态等待某事的发生”,且暗含期待之意。在threads = await client.get_threads()
中,await
执行client.get_threads()
返回的施工方案,并要求上一级执行过程await main()
必须等待client.get_threads()
给出执行结果threads
后,再继续施工。
可能有初学者会迷惑于一个点,client.get_threads()
会在何时执行?在创建时,还是在受到await
调度时?下面这个简单的例子可以解答你的疑惑。
import asyncio
async def foo():
print("1 - foo_coro is executing")
async def main():
foo_coro = foo()
print("0 - foo_coro has not been executed!!!")
await foo_coro
asyncio.run(main())
输出结果
0 - foo_coro has not been executed!!!
1 - foo_coro is executing
这说明foo_coro
并不会在创建时立即执行,施工流程的创建(foo_coro = foo()
)和执行(await foo_coro
)是可以分开的。
main()
的等待行为对应于一个计算机术语“挂起”(suspend),后面我们都会使用这个术语来替代“暂停”等口语化的词汇。suspend与pause意思相近,但他们之间有着微妙的区别——suspend往往表示较长时间的暂停,譬如在快节奏游戏中的暂停我们通常会说pause而不是suspend。
思考一个问题,在await
关键字的指挥下做出等待行为的是谁?正确答案是main()
的执行过程,而不是main()
或者main
,更不应该是client.get_threads()
或者CPU。这一套概念辨析可不是什么无用的八股,它特别有助于加深我们对Python异步的理解。施工方案main()
只是一个可等待对象(Awaitable
),同样的,main
也只是一个可调对象(Callable
)。对象可没有“等待”、“暂停”的说法——“暂停一个对象”?你应该会觉得这句话十分诡异。只有施工方案的执行过程,也就是main()
的执行过程可以有“暂停施工”、“等待某个任务完成再继续施工”的说法。
在这一小节的最后,我相信各位读者已经能够从具体到抽象,自行总结出await
的含义——Python中await
关键字的作用就是执行右侧的可等待对象,并让其当前所处的执行流程挂起以等待右侧的可等待对象给出结果。在本节开头提出的问题也可以解答了,在利用await
等待一个可等待对象时,如果不能立即获得结果,CPU就会离开。
异步函数——async
关键字
在Python中,async
关键字被用于将函数标记为异步的。不论函数体内是否需要等待(await
),添加了async
标记的函数都会返回一个可等待对象。
什么是协程
协程(Coroutine
)就是可以在中途挂起和恢复执行的函数流程。调用异步函数main
所得到的可等待对象main()
就是一个协程。
CPU在何时返回?——事件循环
回调函数(callback function)是这样一种函数:客户需要到柜台取货,但他又不想一直在柜台干等,就把自己的电话号码(回调函数)交给柜台,让柜台在有货之后打电话(执行回调函数)通知他来取。
事件循环(EventLoop
),其中event意为事件,loop意为循环,就是用来获取事件通知,调度协程执行的循环体。事件循环在每次循环中都会做以下工作:将新增的定时任务添加到优先级队列;将已到执行时间的定时任务的回调函数添加到待执行回调函数列表;从操作系统获取事件通知(比如网卡通过硬件中断通知系统:某个socket有数据到来)并将读/写缓冲区的回调函数添加到待执行列表;最后,执行所有待执行的回调函数。CPU会在“执行回调函数”这一步骤返回先前被挂起的正在等待IO事件的协程,恢复他们的执行。
asyncio.run
将会使用一个全局事件循环(不存在则新建)来执行作为参数的协程。asyncio.run(main())
也就是在全局事件循环中执行协程main()
。从main()
到client.get_threads()
一路往下执行,最终抵达一个底层协程,它将一个用于网络通信的socket注册到操作系统内核,然后立即返回一个Future
对象,表示一个将要到来的结果,由于我们await
了这个Future
,调用链上所有的协程都被挂起,直到事件循环从操作系统获取到一个匹配的可读事件后,事件循环再执行对应读缓冲区的回调函数将协程唤醒并继续执行。
为什么await
只能在异步函数中使用?
协程有多种实现方式,而Python实现的是一种沿用了生成器机制的无栈协程。沿用生成器机制,意味着协程的上下文和生成器一样,被保存在一个对象中;无栈,意味着Python协程的调用链并不是一个栈结构,而是一个酷似链表的结构,最开始被调用的协程为链表头,通过其保存的上下文一路指向最底层的协程。而与无栈协程相对的有栈协程则与线程十分类似,不论同步函数的调用还是异步函数的调用都共享一套调用栈,因此有栈协程可以像线程一样在任何位置挂起,而无栈协程只能在特定位置(如await
关键字标记的地方)挂起。无栈协程的一大“丑陋”之处就是await
只能在异步函数中使用,JavaScript/Rust/C++的无栈协程都是如此,这导致如果你要使用异步特性,就必须将async def
铺满整个调用链。
现在我们来回答标题提出的问题,如果在同步函数的调用过程中使用了await
来等待异步结果,由于调用同步函数的返回值不是协程,不会在其中保存上下文信息,这导致我们无法在同步函数中找到那个应该恢复执行的正确位置。但如果我就是要在同步函数中记录应当恢复执行的正确位置呢?很好,那么你将实现一个有栈协程,go语言正是如此。
async with
是什么?
也就是一个异步版本的上下文生成器,在使用__init__
初始化对象之后使用对象的异步方法__aenter__
进行异步初始化工作(比如创建连接池),使用异步方法__aexit__
进行异步清理工作(比如关闭所有连接)。