Stackful Coroutine Made Fast

Stackful 协程并非天生慢——慢的是现有实现。借助上下文感知的上下文切换(CACS),在调用点只保存最少必要的寄存器,stackful 协程在多数场景下反超 stackless,在其他场景持平。已在 Photon 中实现。

摘要

Stackful 协程(又称用户态协作式线程)有望带来更直观、更易用的并发编程。随着高并发程序需求的增长,stackful 协程近年受到越来越多关注。然而,由于严重依赖上下文切换,它一直因性能逊于 stackless 协程而饱受诟病。本文对多种先进的 stackful 与 stackless 协程实现进行了深入的测量与分析。分析表明:尽管当前 stackful 协程的实现确实明显慢于 stackless,但 stackful 协程并非天生就慢。其性能不佳的主要原因在于,现有实现没有充分利用"控制流是在协程之间协作式传递的"这一事实。基于此,我们提出面向 stackful 协程的上下文感知上下文切换(CACS)。 CACS 不保存完整的寄存器集合,而是根据调用方(被调方)的上下文,只保存(恢复)最少必要的寄存器集合。它还使分支跳转能够内联在调用点,从而让分支预测更加准确。我们已在 Photon——一个基于协程的高度优化的 libOS——中实现了 CACS。性能测量表明,借助本文提出的优化, stackful 协程在多数场景下超越 stackless 协程,并在对 stackful 尤其具有挑战性的 generator 范式中打成平手。我们还提出了计算机体系结构、编程语言、编译器和操作系统方面的若干配套改进建议,可进一步提升 stackful 协程的性能。我们的工作已在 GitHub 上开源。

引言

现代服务器的网络带宽已与其内存或 CPU 互连的带宽处于同一量级,并可挂载数十块 SSD,每块吞吐量超过 10GB/s。随着硬件 I/O 能力的进步,软件栈必须同时具备高并发与高效率,才能释放现代服务器不断增长的性能潜力。近期的相关软件改进包括 DPDK、SPDK 等高性能 I/O 框架——它们以 CPU 周期为单位来预算单个数据包或请求的处理开销。

协程近年也受到越来越多的关注,用于高效地处理并发编程。已经或正在拥抱协程的编程语言包括:C++20、2019 年的 Rust 1.39、2012 年的 C# 5.0、2015 年的 Python 3.5、JavaScript ES2017、2021 年的 Swift 5.5、2023 年的 Java 21、2019 年的 Dragonwell 8 等。Golang 自最初设计起就将协程(goroutine)作为一等公民。

协程分为两类——stackless 与 stackful。前者让所有协程共享一个默认栈,后者为每个协程分配独立的栈。使用 stackless 协程时,代码在编译期被转换为事件处理器,并在运行期由事件引擎(即 stackless 协程的调度器)驱动。将 CPU 控制权转交给一个 stackless 协程,本质上只是一次函数调用,参数指向其上下文。相反,将 CPU 控制权转交给 stackful 协程则需要一次上下文切换。与函数调用相比,这种上下文切换被广泛认为是重量级操作。但实际上,它远比内核任务切换高效,因为它不会产生用户态到内核态往返切换的开销;而且利用同一程序内协程的协作特性,还可以对其进行优化。

尽管如此,主要出于性能方面的顾虑(以及其他问题),越来越多的系统正在放弃 stackful 协程、转向 stackless 协程,尤其是 C++20、Rust、C#、 Swift 等强调性能的系统。C++ 标准委员会曾就 C++20 应采用 stackless 还是 stackful 协程展开过激烈争论。尽管 stackful 协程通常更易用、更兼容现有代码库、在许多场景下也更高效,但 stackless 派构造了一个微基准测试,证明"fiber(stackful 协程)的上下文切换开销比 stackless 协程大 20 倍"。stackful 派未能正面回应这一挑战,委员会最终为 C++20 选择了 stackless 协程。我们认为,这一悬殊的开销差异在该决定中起到了重要作用,也影响了其他系统的类似决策。

本文主张:stackful 协程并非天生就慢,只是其实现尚未充分利用 stackful 协程的协作本质,上下文切换的效率仍有提升的机会。我们对多种现有协程实现进行了深入测量与分析,并在此基础上提出上下文感知上下文切换(CACS),以提高寄存器保存的效率、分支预测的准确率和 CPU 缓存的命中率。CACS 利用了这样一个事实:stackful 协程之间的上下文切换只发生在特定的调用点。借助 CACS,每次上下文切换只保存切换回来之后仍需要的寄存器,而这些寄存器由编译器在调用点确定。CACS 还使分支跳转能够内联在调用点,从而让分支预测更加准确。我们通过设计 in-stack generator,将 CACS 应用于优化非对称 stackful 协程,使其性能与相应的 stackless 协程实现相当。我们还引入了名为 preserve_none 的新函数调用约定,作为 CACS 对所有切换函数的扩展,以进一步提升性能。有了我们的工作,yield 操作(调度并切换到下一个协程)的开销大幅降低。CACS 在 Photon(一个基于协程的成熟 libOS)中实现,该调用约定则在 Clang 中实现。

另一方面,我们证明 stackless 协程在处理多级调用(真实程序中不可避免的模式)时效率低下,处理递归时甚至会产生与调用链长度成正比的开销。尽管某些优化在部分场景下能把该开销降为常数,它仍远高于 stackful 协程的相应开销。我们的结果表明,stackful 协程是实现高效并发的更好选择。

本文的贡献如下:

  1. 我们对最先进的 stackless 与 stackful 协程实现进行了深入的性能刻画,分析了各种测量差异的根本原因。
  2. 我们观察到,利用"协程是协作式调度的用户态线程"这一事实, stackful 协程的性能尚有未开发的提升机会。
  3. 我们提出并实现了 CACS 来优化 stackful 协程的性能,证明它能有效消除上下文切换的性能顾虑,从而抬高 stackful 协程的性能上限。 CACS 使 stackful 协程甚至适用于 generator 范式等具有挑战性的场景。总体结果令人鼓舞:单个 Xeon CPU 核心执行一次 yield 操作仅需约 1.52 纳秒(约 3.34 个周期),与函数调用的开销相当,比最先进的结果快数倍。
  4. 尽管 CACS 的结果令人鼓舞,当前实现仍受限于现有的体系结构、编程语言、编译器和操作系统。我们提出了这些领域的若干配套改进建议,可进一步提升 stackful 协程的性能。

阅读全文

前往 GitHub 评论本文 →

使用 Hugo 构建
主题 StackJimmy 设计