什么是异步?为什么多线程比 async/await 更优秀?

本文受 What Color Is Your Function? 启发,延续 再谈:线程和进程 而作成。

What Color Is Your Function? 这篇文章详细阐述了 async/await 语法的弊端,主要介绍了红蓝函数互不兼容的问题。文末指出了线程作为替代方案的优越性。然而这部分较简洁地一笔带过了,因此本文将这部分展开讲讲。

什么是 callback chain?

从朴素的角度出发,异步任务是什么呢?

python
def func():
    task = create_task(send_http_request)
    while True:
        if task.done():
            break
    return task.result()

这是一个笨办法。我把异步任务启动之后,就不停地盯着它看看返回结果了没有。这就是 busy-wait,或者叫 spin-wait。如果这个任务不那么着急,我可以缓一缓,每隔 1 秒检查一次,这就是 long polling。

这种做法当然是浪费资源的,有没有更好的方法呢?

python
def my_task(some_arg, callback):
    # do my job
    callback(my_result)

def func():
    def after_task(some_arg):
        print(some_arg)

    task = create_task(my_task(some_arg, after_task))

在这个方案里,my_task 多了一个 callback 参数,它在执行结束后将自己的 my_result 传给 callback。而 func 只要将 after_task 作为回调函数传给 my_task 即可。

但这个方案意味着每个 my_task 都需要有最后这句 callback(my_result) 调用。这就是红蓝之别的其一:如果一个处理任务的函数没有 callback,你就不能用这个方案进行异步调用。

python
async def func():
    result = await my_task(some_arg)
    print(result)

async/await 的语法可以说是对这种方案的语法糖包装。每个 async 函数都自动在末尾加上 callback 调用。每个 await 都为调用提供了 callback 参数。但如果 await 语法不希望把后面的代码重新包装成一个 after_task 函数该怎么办?它希望直接恢复到 func 的执行过程中。

答案是 coroutine,或称 continuation。这指的是一个函数是可中断可恢复的,通过 yield 命令将 cpu 的执行权让渡给它的上层调用者,后者又可以通过 next/send (in Python) 命令恢复它的执行。

因为此时的 coroutine 函数是可中断可恢复的,所以只要将“恢复”这个指令作为 callback 参数传给 my_task,就可以在 my_task 执行完毕后恢复 func 的执行,而不需要单独包装一个 after_task 函数。

所以 async/await 语法不仅是 callback 的语法糖,同时也是 yield/send 的语法糖。一个 async 函数自动就被声明为 coroutine 函数,而 await 语法则自动进行了 yield/send 的调用。

小结一下,红蓝有别一是因为 callback 的存在与否,二是因为 coroutine 函数是可中断可恢复的,而普通函数是不可中断的,因此同步函数不能调用异步函数。

另外,为了确保最底层的异步任务在等待 IO (比如网络请求、文件读写) 时,cpu 能拿来干其它工作,而不是焦急地原地跺脚 while Ture 等待,所以最顶层有一个 event loop 来调度这些任务。当一个任务在等待时,它就将 cpu 控制权 yield 给 event loop,进而转而去执行其它任务,可能就是它刚刚通过 await 提交的那个任务。

Publish/Subscribe 模式

Callback chain 并不是唯一的解决方案,对于操作系统来说它更偏好 Publish/Subscribe 模式。一个线程可以订阅一个事件/锁后进入睡眠,而调度器在事件发生/锁释放时唤醒它。

Publish/Subscribe 模式在 event loop 中也同样可以使用。一个 coroutine 可以订阅一个事件后 yield,而 event loop 在事件发生时重入它。

Callback chain 不依赖于 event loop 存在,但既然 event loop 已经存在,依赖它用 publish/subscribe 模式也就更优雅一些。调用不需要在栈上一层层 callback 展开,而是通过 event loop 来调度。但这是有代价的,后文将解释这一点。

async func/event loop 和线程/调度器是同构的

async function, 或称 coroutine, 或称 continuation,其本质上都是讲一件事:一个函数在执行过程中是可以被打断的,然后再恢复执行。举个例子:

python
async def func():
    await asyncio.sleep(1)

func 被调用时,它会在 await asyncio.sleep(1) 处调用另一个 async 函数 asyncio.sleep(1),后者会开始执行,而 func 本身会暂停执行。当 asyncio.sleep(1) 执行完毕后,也就是 1 秒过后,func 会从暂停处恢复执行。

是谁打断了函数执行?又是谁恢复了它?打断执行的是这个函数自身,它在底层使用了类似 yield 命令,这个命令将 cpu 的执行权让渡给了它的上层调用者,也就是 event loop。event loop 将 cpu 的执行权交给 asyncio.sleep(1)。当 asyncio.sleep(1) 执行完毕后,它又恢复了 func 的执行。

这与操作系统调度器的工作方式不是一模一样吗?正如 再谈:线程和进程 中所提到:

调度机制是通过(简单来说)尽量均匀地分配 CPU,使每个应用程序都能运行,就像它拥有独占的 CPU 一样。等到一个进程的使用时间片到期了,硬件就触发一个定时器中断,先保存寄存器的数据,然后把所有计算用的寄存器切换到调度器上,调度器载入下一个进程的寄存器数据,最后把这些寄存器切换到下一个进程。这个过程是程序员不可见的,一个应用程序被“打晕”然后又被唤醒,它看到的寄存器是一样的,是内核给它维护了“这段时间无事发生”的“幻觉”,因此说这个进程看到的是一颗它独占使用、连续使用的 CPU。

调度器同样有打断线程和恢复线程的能力。不仅通过计时器中断可以强行打断线程,线程本身也可以通过系统调用来触发中断,将 cpu 从用户态转移到内核态,从而主动将 cpu 的执行权让渡给调度器。

因此,async func/event loop 和线程/调度器是同构的。

多线程和 async 函数一样共享内存。async 函数是 coroutine,而线程同样可以中断和恢复。pthread_cond_wait/pthread_cond_signal 不就是系统级的等待和通知机制吗?Mutex lock 不就是为替代 spin lock 而存在的吗?这些都和 async 函数所需要的没什么区别。

全部用异步函数不就好了

上文说到,红蓝有别一是因为 callback 或 pub/sub 通知机制的存在与否,二是因为 coroutine 函数是可中断可恢复的,而普通函数是不可中断的,因此同步函数不能调用异步函数。

面对红蓝染色的问题,一个自然的想法是:我把全部函数都定义为 async 不就好了?所有函数都是 coroutine,可中断可恢复,这就完事了。

但假如所有函数调用都交给 event loop 的话,那么内存的栈结构就不存在了!

这样做的代价是最顶层的调用者需要启动一个 event loop,所有函数调用都发到这里来处理,也就产生了调度开销。但更昂贵的是上下文切换的开销:想象一下我们把每个函数调用都创建成一个新的线程...全部用 async 函数也差不多是这样的。

为什么多线程比 async/await 更优秀?

消除红蓝之别的核心是:让所有函数都是可中断可重入的。不可重入的普通函数或许是源自 C 语言以来的忽视,当时一个函数只是为了表达线性逻辑而已,就像今天的 Python 一样是个业务逻辑的载体。

但所有函数都交给 event loop/调度器的话,栈结构就不存在了。正如上文提到,这会产生巨大的上下文切换开销。

我们能否找到一种折衷的做法?函数的定义和调用本就是分开的。我们在定义时将所有函数都默认定义为 coroutine,但调用时可以选择直接在栈上调用,也可以交给 event loop/调度器去运行,这不就解决问题了?这就是 go 所做的事情。

请注意:这并不意味着重新划分出红蓝,因为函数都是可重入的,只是我们可以选择不同的调用方式:有的调用直接在栈上运行,有的调用交给 event loop/调度器运行。

如此一来我们便不必在定义时为红蓝函数而发愁。当你要把一个同步调用转换成异步调用时,只要在它前面加个 go/await 就可以,不需要找到所有引用它的函数一个个改成 async 了!多线程的异步模型消除了 async 的存在。

而多线程模型又带了另一个好处:别忘了多线程是可以处理计算密集型任务的。假如我们有一个设计得当的线程调度器,那么计算密集型和 IO 密集型的差异就被藏进了调度器中。对于开发者来说,只要把难搞的任务丢出去就好了。

或许没有银弹,这个对开发者友好的方案可能隐藏着其他代价,但 go 的成功至少说明了这个简单直接的方案是可行的。倒不如说从 callback 出发到 async/await 的方案,恰恰是 JavaScript/Python 这样的单线程语言走上了一些弯路。对它们来说,event loop 里的 coroutine 可没法像线程一样有着独立的栈,自然也就没办法带着栈切换运行中的任务了。

很遗憾今天的 Python 并不能在栈上直接调用 async function。但我提供了一个在你想用 await 而不能用时可以派上用场的工具: bwait

(ps. 这不是一个经过验证的解决方案,只是一个实验性质的尝试)