本文将以笔者仿照 IceBear 的 C++ 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()
。
创建协程时会依次做以下事情:
-
用
operator new
申请协程状态 [1] 对象的内存; -
初始化协程函数体形参(指例子中的
T n
,形参是协程状态的一部分)和其他对象; -
初始化 promise 对象;
-
调用
promise.get_return_object()
[2],该函数的返回值将用来初始化协程对象(本文例子中用来初始化匿名变量,另外写一个range<int> r(6)
的话会比较容易发现是用来初始化r
); -
在协程函数体内执行
co_await promise.initial_suspend();
(此时控制流已经切到了协程函数体,相当于在协程函数体开头插入了这个语句,co_await
的具体效果见下文)。
调用方调用 handle.resume()
同样会将控制流切到协程。由于这里的流程涉及还未提到的 awaiter
,这部分流程在下文提到 awaiter
时一并描述。
下列 3 种情况会从协程切到调用方:抛异常谁闲着没事抛异常(本文不描述抛异常的流程,可前往 cppreference [1] 查看)、co_await
和 co_return
。
co_await
是一元运算符,co_await expr
求值时的流程如下:
-
按照一套规则 [1] 获得
awaiter
,获得awaiter
的过程包含expr
的求值;-
co_yield xxx
相当于co_await promise.yield_value(xxx)
; -
本文例子中的
awaiter
的类型为std::suspend_always
。
-
-
调用
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
语句要做的事情更多,流程如下:
-
根据
co_return expr;
中expr
的类型执行expr; promise.return_void();
(co_return;
就只执行promise.return_void()
)或者promise.return_value(expr)
; -
销毁协程函数体内的局部变量(这里跟
return
语句的一样); -
在协程函数体内执行
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;
}
}
- 首先初始化协程函数体形参和其他对象,控制流进入协程的调用栈,然后执行
co_await promise.initial_suspend();
。本文例子中initial_suspend()
的返回值类型是std::suspend_always
,执行的这个语句相当于co_await std::suspend_always{};
。得到的awaiter
的类型也是std::suspend_always
。awaiter.await_ready()
返回false
、awaiter.await_suspend(handle)
的返回值类型为void
,控制流切回调用方。
这里可以自定义的地方是 promise.initial_suspend()
的返回值类型。通常我们不需要立刻开始执行协程函数体的内容,需要控制流切回到调用方,此时返回值类型可以用 std::suspend_always
。std::suspend_always
是一个标准库提供的类类型,该类型对象作为 awaiter
的效果是暂停协程,控制流切回调用方。如果需要立刻开始执行协程函数体的内容,那么返回值类型可以用 std::suspend_never
。
- 用
promise.get_return_object()
的返回值初始化匿名对象,该匿名对象将用来初始化__range
。
这是协程的第一次暂停,会初始化协程对象。
- 调用方执行到调用
__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]。
- 调用方执行到调用
*__begin
,这里operator*
直接返回promise.opt
中存的值。
这里是把 promise
中存的信息取出来给调用方。
- 调用方执行到调用
++__begin
,这里operator++
里面调用handle.resume()
,控制流切到协程函数体。控制流切回函数体的第一件事是调用awaiter.await_resume()
(显然本文例子中这个函数啥事都没干),然后继续协程函数体的控制流。
需要从调用方往协程传递信息时,信息通常也放在 promise
里面作为中转,往 promise
存放信息的时机是调用 handle.resume()
前,把 promise
的信息取出来给协程是通过 await_resume()
[5]。
- 协程函数体执行到
co_yield i;
,回到了 3.,循环往复,直至控制流到达协程函数体末尾,(控制流到达协程函数体末尾自动)执行co_return;
,控制流切回到调用方。
显然,这里 promise.final_suspend()
的返回值类型不能是 std::suspend_never
,控制流不能切回来。
需要注意的是,考虑到协程函数体内可能是死循环(例如实现无界的 std::views::iota
),协程函数体内没有 co_return
语句时不要求提供 return_void()
或者 return_value()
。控制流到达协程函数体末尾但没有提供 return_void()
是未定义行为。
- 调用方执行到
__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
的实现。