【C++ | 协程】协程控制流切换的流程梳理

本文将以笔者仿照 IceBearC++ coroutine generator 实现笔记写的简易 generator 为例,对协程控制流切换的流程进行梳理。

在这个例子中,有 2 个栈,一个是协程调用方的调用栈,另一个是协程的调用栈。

int main() { // 调用方
    for (auto && i: range<int>(5)) {
        std::cout << i << std::endl;
    }
}
template <typename T>
generator<T> range(T n) { // 协程
    for (T i{}; i < n; ++i) {
        co_yield i;
    }
}

显然,控制流的切换有 2 种:从调用方切到协程,从协程切回调用方。


下列 2 种情况会从调用方切到协程:创建协程,和调用 handle.resume()

创建协程时会依次做以下事情:

  1. operator new 申请协程状态 [1] 对象的内存;

  2. 初始化协程函数体形参(指例子中的 T n,形参是协程状态的一部分)和其他对象;

  3. 初始化 promise 对象;

  4. 调用 promise.get_return_object() [2],该函数的返回值将用来初始化协程对象(本文例子中用来初始化匿名变量,另外写一个 range<int> r(6) 的话会比较容易发现是用来初始化 r);

  5. 在协程函数体内执行 co_await promise.initial_suspend();(此时控制流已经切到了协程函数体,相当于在协程函数体开头插入了这个语句,co_await 的具体效果见下文)。

调用方调用 handle.resume() 同样会将控制流切到协程。由于这里的流程涉及还未提到的 awaiter,这部分流程在下文提到 awaiter 时一并描述。


下列 3 种情况会从协程切到调用方:抛异常谁闲着没事抛异常(本文不描述抛异常的流程,可前往 cppreference [1] 查看)、co_awaitco_return

co_await 是一元运算符,co_await expr 求值时的流程如下:

  1. 按照一套规则 [1] 获得 awaiter,获得 awaiter 的过程包含 expr 的求值;

    • co_yield xxx 相当于 co_await promise.yield_value(xxx)

    • 本文例子中的 awaiter 的类型为 std::suspend_always

  2. 调用 awaiter.await_ready(),该函数表示是否继续协程函数体的控制流。(本文例子中 std::suspend_always::await_ready() 总是返回 false)。如果该函数返回 false,那么调用 awaiter.await_suspend(handle)

至此,协程暂停。然后根据上述 2 个函数决定控制流切去什么地方。

  • 如果 await_ready() 返回 true,那么恢复当前协程(相当于切回当前协程);

  • 如果 await_suspend() 的返回值类型为 void 或者返回值类型为 bool 且返回 true,控制流切回到调用方(就是创建协程对象或者调用 handle.resume() 的地方);

  • 如果 await_suspend() 返回 false,那么恢复当前协程;

  • 如果 await_suspend() 返回一个 handle,控制流切回调用方然后在调用方调用 handle.resume()(效果是从一个协程切到另一个协程)。

到这里控制流就(可能)切到其他地方了。(通过调用 handle.resume() 或者上文的“恢复当前协程”)切回到这个协程时,会调用 awaiter.await_resume(),该函数的返回值会作为 co_await expr 的值(到这里 awaiter 的任务才完成),然后继续协程函数体的控制流。

注意该流程为了方便理解,使用了不准确的描述。实际上 await_ready() 返回 true 时不会暂停协程而是直接调用 await_resume()(暂停协程需要记录暂停点等信息,有开销);暂停不是在调用 await_suspend() 之后而是在 await_suspend() 之前,这样该函数可以较为简单地在线程间转移 handle,方便调度 [3];get_return_object() 的返回值初始化协程对象的时机是在第一次暂停时。

co_return 语句的语义应该十分清晰:要切出这个函数,而且不再切回。显然,和 return 语句相比,co_return 语句要做的事情更多,流程如下:

  1. 根据 co_return expr;expr 的类型执行 expr; promise.return_void();co_return; 就只执行 promise.return_void())或者 promise.return_value(expr)

  2. 销毁协程函数体内的局部变量(这里跟 return 语句的一样);

  3. 在协程函数体内执行 co_await promise.final_suspend();


现在对本文例子中控制流的切换流程进行描述,方便读者加深印象,同时给出在编写库时常用的可自定义的点。

为了方便描述,先给出例子中的 for 循环的等价代码 [4]:

{
    auto && __range = range<int>(5);
    auto __begin = __range.begin();
    auto __end = __range.end();
    for (; __begin != __end; ++__begin) {
        auto && i = *__begin;
        std::cout << i << std::endl;
    }
}
  1. 首先初始化协程函数体形参和其他对象,控制流进入协程的调用栈,然后执行 co_await promise.initial_suspend();。本文例子中 initial_suspend() 的返回值类型是 std::suspend_always,执行的这个语句相当于 co_await std::suspend_always{};。得到的 awaiter 的类型也是 std::suspend_alwaysawaiter.await_ready() 返回 falseawaiter.await_suspend(handle) 的返回值类型为 void,控制流切回调用方。

这里可以自定义的地方是 promise.initial_suspend() 的返回值类型。通常我们不需要立刻开始执行协程函数体的内容,需要控制流切回到调用方,此时返回值类型可以用 std::suspend_alwaysstd::suspend_always 是一个标准库提供的类类型,该类型对象作为 awaiter 的效果是暂停协程,控制流切回调用方。如果需要立刻开始执行协程函数体的内容,那么返回值类型可以用 std::suspend_never

  1. promise.get_return_object() 的返回值初始化匿名对象,该匿名对象将用来初始化 __range

这是协程的第一次暂停,会初始化协程对象。

  1. 调用方执行到调用 __range.begin(),该函数函数体内调用 handle.resume(),控制流切到协程,开始执行协程函数体的内容。执行到 co_yield i;(相当于 co_await promise.yield_value(i);),yield_value()i 存在 promise.opt 然后返回 std::suspend_always{},控制流切回调用方。然后用 handle 初始化 begin() 的返回值。

需要从协程往调用方传递信息时,信息通常放在 promise 里面(需要额外加数据成员)作为中转,往 promise 存放信息的时机通常是 yield_value()return_value(),可能还有 await_suspend() [3]。

  1. 调用方执行到调用 *__begin,这里 operator* 直接返回 promise.opt 中存的值。

这里是把 promise 中存的信息取出来给调用方。

  1. 调用方执行到调用 ++__begin,这里 operator++ 里面调用 handle.resume(),控制流切到协程函数体。控制流切回函数体的第一件事是调用 awaiter.await_resume()(显然本文例子中这个函数啥事都没干),然后继续协程函数体的控制流。

需要从调用方往协程传递信息时,信息通常也放在 promise 里面作为中转,往 promise 存放信息的时机是调用 handle.resume() 前,promise 的信息取出来给协程是通过 await_resume() [5]。

  1. 协程函数体执行到 co_yield i;,回到了 3.,循环往复,直至控制流到达协程函数体末尾,(控制流到达协程函数体末尾自动)执行 co_return;,控制流切回到调用方。

显然,这里 promise.final_suspend() 的返回值类型不能是 std::suspend_never,控制流不能切回来。

需要注意的是,考虑到协程函数体内可能是死循环(例如实现无界的 std::views::iota),协程函数体内没有 co_return 语句时不要求提供 return_void() 或者 return_value()。控制流到达协程函数体末尾但没有提供 return_void() 是未定义行为。

  1. 调用方执行到 __begin != __end,这里 operator== 里面调用 handle.done(),协程函数体内已经 co_return 了(严谨说是在 co_await promise.final_suspend() 的暂停点暂停了),所以返回 true。调用方的 for 循环结束。

本文的例子中没有涉及 await_resume() 返回 handle 的情况。已经完全熟悉本文例子中的流程的同学可以尝试阅读 cppreference 中的 std::noop_coroutine 页面中的示例代码。


[1] 见 cppreference 中对相关词语的描述。

[2] 在编写库的时候,获取 promise 的方法是调用 handle.promise()

[3] 别问,笔者也不懂这玩意 qwq。

[4] 规则见 cppreference 中的范围 for 页面

[5] 可以参考文章开头 generator 实现笔记中 bigenerator 的实现。