Await:从 Swift 到 C++

上周三(6/9)早上醒来,发现全世界都在赞叹 WWDC 里宣布的新版 Swift async/await 特性。看了几条信息隐约说是基于 coroutine。尽管从去年起因为对 Apple Silicon Mac 迁移的失望已经把个人项目向 Windows 平台转移,但看在当年研究 Lua coroutine 和 Lisp continuation 下了很大功夫的份上,决定探索一下这个 Swift 语言新特性的细节。特别是在没有 VM 的语言上采用 coroutine 会有什么样的挑战。

这一看发现 Swift 并非仅仅给普通的 coroutine 库加上一点 syntax sugar 而已。这里要提一下,我最早看到的一条相关信息就是抱怨它只在最新的 iOS 15 上才支持。如果这个新特性真的只是普通 coroutine 库的语法糖,那这个抱怨是合理的。但其实并非如此。要解释这点,先回想一下最开始学 coroutine 的时候,一般的库或者 Lua 这样的语言文档如何给开发者展示第一个例子 [1]。在这个例子中会有 coroutine caller 和 coroutine 本身。前者是普通的 code flow,用 resume 来跳转到 coroutine。后者则用 yield 回到 caller。但 WWDC 的介绍视频里显然没有明确或者隐含提到这两者。所以第一步是找出 Swift 的实现和一般 coroutine 概念之间的联系。

一般概念与 Swift 实现

Swift 的 await 操作中止当前的 event-handler,而这个 handler 的 context 并没有丢失,可以在以后被恢复。所以 await 相当于 Lua 的 yield 操作。这说明了几点:

  1. 在 WWDC 视频中的例子里,coroutine caller 是 event-loop。视频并没有明确指出这一点,但是从 await 挂起当前 handler 之后可以处理其它 event 这点可以推断出来。
  2. 在 iOS 15 中,event-loop 调用 event-handler 不再是普通的函数调用。而是创建运行 event-handler 的 coroutine 并执行它。
  3. 视频的例子中,没有明确的 resume 操作。实际的 resume 操作隐藏在 event-loop 代码中。
  4. 在视频最后的例子中,continuation.resume() 并不是真的 resume 操作。它只是给对应的 coroutine 做上标记。而 event-loop 在随后的运行中会通过这个标记决定 resume coroutine。

要做到这几点,event-loop 的代码必须改写并用新版的 Swift compiler 重新编译。这就是只有 iOS 15 支持 async/await 的原因。以上是这个 Swift 语言特性和一般 coroutine 概念的对应关系。

Coroutine 的普及度

从上节可以看到,Swift 的 coroutine 的实现,至少在 WWDC 视频中呈现的场景,和 event-loop 紧密集成。我认为这个设计才是能真正发挥 coroutine 价值的关键,而且是以普通水平的程序员能接受的方式。在没有和 event-loop 紧密集成时,coroutine 虽然看起来炫酷,但在实际中处于「叫好不叫座 」 的局面。下面分析一下为什么缺乏 event-loop 帮助的 coroutine 难以体现价值,至少是难以让大多数程序员受益。

从模型能力来说,coroutine 的价值在于提供了独立的 call stack,并且切换开销比操作系统线程低。要体现它的价值就要充分利用其独立 call stack。在《Programming in Lua》中列举了 coroutine 的三种典型用例:iterator,consumer-producer,协作式多任务。 前两类需求用传统的没有独立 call stack 的方法解决并没有很大问题,并不值得引入 coroutine。

那么协作式多任务是 coroutine 最重要的应用场景吗?在多线程如此普及的时代,为什么需要协作式多任务呢?《Programming in Lua》里给出的解释是,协作式多任务可以把 non-blocking I/O 封装成为普通的 blocking 形式。在 2010 年左右,Lua 作者以 socket select 作为例子。虽然也勉强称为「non-blocking」,但这种传统风格的 I/O 并没有显示 coroutine 的价值。从 2013 年后,non-blocking I/O 的形式发生了很大变化,也脱离了 I/O 这个狭义范畴,变成了带有 completion closure 的 async function [2]。所以我们下面要讨论的 coroutine 应用是如何用它封装 async function。

在继续讨论 coroutine 之前,先说说 async function 本身的问题。它用 completion closure 来处理异步返回的结果。这样它的 continuation 就不能通过普通的 code flow 体现。如果要先后使用多个 async function 的话,就会导致 callback hell。现在设想一下:如果能把 async function 封装成某种形式的函数,在 coroutine 中调用这个新函数,它会调用原来的 async function。这个 async function 会立即返回 [3] ,然后新函数会 yield 当前 coroutine。这之后 coroutine caller 定期用某种方式查看 async function 的 completion closure 有没有触发,如果已经触发过就 resume coroutine;此时 coroutine 里接下来的代码就可以自然的处理 async function 的结果。我们可以把这种新的封装形式称为 coroutine-aware async,它可以让 coroutine 的正常 code flow 而不是 completion closure 来处理 continuation。

这种 coroutine-aware async 允许程序代码用普通的 code flow 调用 async function。看起来一切很美好。但我们还没有考虑一个关键问题,实现 coroutine caller 的复杂度。当某个 coroutine 因为等待 completion closure 被 yield 挂起的时候,我们希望 coroutine caller 能利用这个时间去执行其它有意义的任务。这就要求 caller 不能是普通的应用逻辑,而必须是比较通用的 scheduler。而编写 scheduler 并不是大多数程序员想要做,甚至不是有能力做的事情。所以这种 scheduler 一般都会封装在某种库或者框架中。如果这个库或者框架并不是全局掌管整个 app 运行模式的框架,那这个 scheduler 就只能在局部的特定领域使用。所以,对于大多数程序员来说,coroutine-aware async 很少能和他们接触的领域产生交汇。那么问题另一面是,为何不写一个掌管整个 app 运行模式的 coroutine scheduler?那么答案是,在绝大多数 app 中有一个天然的位置适合做这个 scheduler,就是 event-loop。但是 event-loop 被极少数代码垄断,典型的如 OS vender 自己的 UI framework,或者像 Qt 这样接管全局的框架才能做到。而这些垄断者之前并没有在 coroutine-aware 方面迈出步伐。

直到 WWDC 展示的下一版 Swift 提供了这个 scheduler。它的 async 就是 coroutine-aware async。这个新的语言特性是真正能让 coroutine 出现在更多应用代码中的关键。

尝试跨平台实现

赞叹了 Swift async/await 的设计之后,我面临一个挑战。Mac 作为个人计算平台的角色已经被全面向 Apple Silicon 迁移的战略断送。我自己的个人项目完全转向了 Windows 和 C++。最好能在 C++ 上实现 async/await 技术。

首先面临的问题是,Windows 并不像 macOS/iOS 那样大量使用带有 completion closure 的 async function 作为 APIs。好在我实现了一个简单的类似 libdispatch 的 NuoDispatch。只要能在非 main thread 上安全运行的代码都可以通过 NuoDispatch 改成 async function 形式。接下来就是 coroutine 本身的实现。

最先研究的技术是 C++ 20 引入的 co_await。看了之后大失所望。这个特性实现的是 stackless coroutine,就是说 coroutine 的范围只能是直接使用 co_await 的 function。我不明白 C++ committee 或者任何人怎么会在 2020 年还采用 stackless coroutine。编程语言中的 function 今天是一种封装和重用的机制,而不是控制 code flow 的机制。不能想象,本来应该放到第二层 function 中的代码为了用 co_await 必须放到上一层中。我想 C++ committee 设计 co_await 的时候脑子里一定在想 iterator/consumer-producer 这类的应用,设计出一个根本没法用于 event-loop scheduling 的废物特性。

接下来看了一些 stackful coroutine 的 C++ 实现。很多实现基于 POSIX context,不能支持 Windows。而且我个人对 「集成污染」比较敏感。 一番折腾下来,我开始想放弃这方面尝试。

退一步想想 await 配合 coroutine 的意义在于把 code flow 交还给 event-loop。如果不能用 context switch 的方式回到原来的 event-loop,还有没有其它的方式?Windows 的 event-loop 其实只有三到四行代码,而且本身在 call stack 上几乎没有 contextual data。所以答案是 Win16 时代的老技巧:再开一个新的 event-loop。我给 NuoBackgroundTask 添了两个新方法:

Resume() 在第一次执行时会把 NuoBackgroundTask::_work 交付给 background thread 运行。随后它每次被调用时会分发一次 event-queue 里的消息,让其它 event-handler 有机会运行,然后检查 _work 是否已经结束,并返回其状态。 Await() 的实现是简单的:

通过 Resume()ModelViewerWindow::OpenFile() 从定义两个 completion closure 的方式变成了自然的 code flow。达到和 Swift async/await 在普通 event-handler 中使用同样的效果。几乎任何语言任何 OS 都可以受益于这种设计模式,而不再局限于最新的 beta iOS/macOS 和 Swift runtime。

再审视 Swift Async/Await

回头看 Swift 语言本身的 async/await,把通用的 stackful coroutine 引入 Swift,并与 event-loop 紧密集成。 这是非常聪明的实现 。但是再想一下,目前它也有很大的局限性。对 event-loop 的侵入式修改使老版本的 iOS/macOS 无法支持,对 runtime coroutine 实现的依赖使得 Swift 以外的语言无法使用。

既然 coroutine 和 event-loop 的结合才能降低 coroutine 的门槛让普通程序员接受,其实第二消息循环也可以做到,而且后者不需要依赖汇编代码,POSIX 特有的 API,或者 LLVM 特有功能。这种方式摆脱了 Swift,让 C++ 也能采用同样的自然 code flow 的设计。而第二消息循环对 root event-loop 的非侵入性让 iOS 15 和 macOS 12 以外的 OS 也能支持这个技术。

脚注:

  1. 后面关于 coroutine 的术语如果不加说明,都采用 Lua 的术语。
  2. 注意这里的 async function 指类似 iOS 14 和 macOS 11 之前的采用 completion closure 的 API 形式。和 async/await 中的 async 不是一回事。后文会把 async/await 中的 async 方式称为 coroutine-aware async。
  3. 这里指普通的 function return,而不是触发 completion closure。

留下评论