第23.1章:协程机器

English

1. 问题的形状

普通函数只有一个入口和一个出口。它一直运行到返回。如果它需要的数据还没准备好,它有三个选择: 阻塞线程;返回一个句柄让调用者轮询;或者把自己拆成回调。这三种选择都会出现在源码里, 也都让程序更难读。

协程(coroutine)是一个可以在中间停下、把控制权交还给调用者、之后再从 停下的地方继续的函数。协程的局部变量、当前语句和调用栈在暂停期间都保留下来。调用者不会阻塞。 协程等待时,操作系统线程也不会被占用。

定义。协程是一个执行可以被暂停和恢复的函数。在 C++20 及以后,只要函数体里 含有 co_await、co_yield 或 co_return,它就是协程。 编译器把这个函数改写成状态机,把它的状态放进协程帧(coroutine frame), 并把它与用户定义的 promise 类型连接起来。

关键词是函数。协程不是线程,不是任务队列,也不是回调。它是一个带有保存状态的函数。 总得有东西来恢复它:调度器、事件循环、另一个协程,或者调用者自己。

2. 三个关键字,一台机器

关键字 含义 编译器生成什么
co_await expr 在这里暂停,直到 expr 就绪。 调用 awaiter 协议:await_ready,然后 await_suspend, 然后 await_resume。
co_yield expr 产出一个值并暂停,用于生成器。 等价于 co_await promise.yield_value(expr)。promise 保存值或把它暴露给消费者。
co_return expr 结束协程并交付结果。 调用 promise.return_value(expr);对于 co_return; 则调用 promise.return_void()。

这三个关键字不是三个独立特性。它们是到达同一个 promise 对象的三种方式。promise 是协程的控制块: 它决定协程返回什么、第一次暂停时做什么、最后一次暂停时做什么,以及异常如何处理。

#include <generator>
#include <print>

std::generator<int> iota(int n)
{
    for (int i{}; i != n; ++i)
    {
        co_yield i;
    }
}

int main()
{
    for (int value : iota(5))
    {
        std::print("{} ", value);
    }
    std::print("\n");
}

输出 0 1 2 3 4。函数 iota 不是被调用五次。它只被调用一次,创建一个 协程帧,然后恢复五次。每次 co_yield 都在同一点暂停;循环和变量 i 活在帧里,而不是调用者的栈上。

3. 编译器构建了什么

对每个协程,编译器创建一个帧。帧是一个隐藏对象,至少包含:

帧通常用 operator new 分配,并在协程帧销毁时释放。当编译器能看到完整生命周期时, 它可以省略这次分配,但在库 API 里你不能依赖这一点。这就是为什么 fast_io 的 io_async_task 这样的协程返回类型把分配器作为模板参数:帧通过与库其余部分相同的 分配器纪律分配,而不是走全局默认。

promise 类型是定制点。协程返回类型 task<T> 必须提供嵌套的 promise_type。编译器在固定时刻调用 promise:

Promise 钩子 何时运行 控制什么
get_return_object() 函数体开始前 构造调用者收到的对象。
initial_suspend() 函数体开始前 suspend_always 让任务惰性启动; suspend_never 立即启动。
final_suspend() 函数体结束后 决定帧是保留给调用者读结果,还是自行销毁。
return_value / return_void co_return 时 把结果存进 promise。
yield_value co_yield 时 向消费者发布一个值。
unhandled_exception 普通 C++ 异常逃出函数体时 保存或终止;它不能正常返回。
unhandled_herbception fast_io 的赫布异常逃出函数体时 把原始两字错误存进 promise,供等待的调用者或 rethrow_if_error() 之后交付。

没有 promise_type 的协程是编译错误。promise 不是可以忽略的细节:它把语言的 暂停/恢复原语变成生成器、任务或异步操作。

4. Awaiter 协议

co_await expr 不直接调用 expr。它向结果索取一个 awaiter,然后最多调用三个成员函数:

  1. await_ready() —— 如果返回 true,操作已经完成,协程不暂停。
  2. await_suspend(handle) —— 协程即将暂停时调用。它可以返回 void(暂停)、bool(为 true 则暂停),或另一个协程句柄 (把控制权转交给它)。
  3. await_resume() —— 恢复后产生整个 co_await 表达式的值。
#include <coroutine>

struct immediate
{
    bool await_ready() const noexcept { return true; }
    void await_suspend(std::coroutine_handle<>) const noexcept {}
    int await_resume() const noexcept { return 42; }
};

some_task<int> use()
{
    int value = co_await immediate{};
    co_return value;
}

some_task 是协程返回类型的占位符,它必须有 promise_type; fast_io 的对应类型是 io_async_task。这个例子从不暂停,因为 await_ready 返回 true。有意思的是返回 false 的情况:协程保存恢复点,调用 await_suspend,然后把控制权 交还给恢复它的人。之后,操作的完成回调调用 handle.resume(),执行从 await_resume 继续。

异步 I/O 的全部技巧就在这里。协程不在 socket 上等待。它提交一个操作,保存自己的句柄,然后暂停。 事件循环等待内核通知操作完成,再恢复保存的句柄。在协程内部,代码读起来就像阻塞调用: 读、打印、再读。

5. 有栈与无栈

C++ 协程是无栈(stackless)的。暂停的协程不保留独立的调用栈。它的状态就是帧, 并且只保留协程自己的局部变量。你不能在协程调用的普通函数里暂停,然后指望普通函数的栈存活; 只有协程体有暂停点。

这不同于有栈协程或绿色线程,后者保存并恢复整个栈。有栈设计允许在任何地方暂停,但每个协程需要 一个栈和一次上下文切换。无栈设计更便宜、更显式:编译器确切知道哪些状态跨越暂停点, 程序员在允许暂停的地方写 co_await。

属性 无栈 C++ 协程 有栈协程 / 线程
暂停时保留的状态 一个帧,含参数、局部变量和 promise 整个调用栈
能在任何地方暂停吗? 只能在 co_await / co_yield 通常可以
每个实例的成本 帧大小,通常一次分配 栈大小,通常大得多
谁来恢复它? 显式句柄、调度器或事件循环 调度器或上下文切换
本身提供并发吗? 不提供 不提供

6. 协程不是线程

这是最容易混淆的一点。协程提供暂停,不提供并行。两个协程可以同时活在一个线程上, 但任意时刻只有一个在运行。它们在暂停点协作式地交错。没有抢占,也不会产生数据竞争, 除非你引入另一个线程。

caller:      resume A ──► A runs ──► A suspends ──► caller continues
event loop:  wait ──► completion ──► resume A ──► A runs ──► A finishes

线程是操作系统拥有的调度单位。协程是你的程序拥有的函数状态。一个服务器可以用少量线程处理一万个 连接,因为大多数连接都在等待 I/O 而暂停,而不是因为它创建了一万个线程。并发来自事件循环和内核, 不是来自协程关键字。

推论同样重要:在协程里执行阻塞操作会阻塞整个事件循环。在异步协程里调用 read、sleep 或同步的 fast_io 操作,会停住那个调度器上的所有其他协程。 异步代码是一种纪律,不是装饰。

7. 生命周期、成本与失败

协程帧一直活到被销毁。对于惰性任务,任务对象通常拥有帧,并在析构时销毁它。对于 detached 任务, 帧在 final_suspend 处自行销毁。如果异步操作仍然持有指向帧的指针,而帧被销毁, 就是 use-after-free;操作的回调会恢复一个已经不存在的帧。这就是异步层要区分拥有任务和 detached 任务的原因,也是你必须让拥有任务活到 done() 的原因。

成本有三部分:帧分配、帧大小和恢复。语法不会隐藏它们。每个请求分配一个帧的协程,不会因为它是 协程就免费。fast_io 允许调度器提供分配器,让帧来自与其余 I/O 状态相同的池;异步任务类型也可以 按那个分配器参数化。

失败在本教程中有两个通道。普通 C++ 异常可以逃出协程,交给 promise.unhandled_exception()。fast_io 的赫布异常使用 throws 标记和 throw throws 形式;逃出 io_async_task 函数体的错误被存进 promise, 并在 co_await 处重新抛出,或由非协程调用者的 rethrow_if_error() 交付。钩子收到的是原始两字错误值,当前实现拼作 ::std::cxx_std_error (第19.1章); catch throws (::std::error e) 里的公开句柄在 第3.16章介绍。它们是同一对 域指针加错误码在不同层上的表示。这里只需要记住规则:异步操作通过与同步操作相同的通道报告失败。

要点

下一页把这套语言机制变成 fast_io 的异步模型:任务、调度器、观察者、操作和事件循环。