跳转至

异步编程入门教程

样例代码

本样例将获取并打印吧主题帖列表

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-意为notsyn意为togetherchrono源自古希腊语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),后面我们都会使用这个术语来替代“暂停”等口语化的词汇。suspendpause意思相近,但他们之间有着微妙的区别——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__进行异步清理工作(比如关闭所有连接)。