协程与异步抽象:先理解状态机 / Coroutines and Async Abstractions
C++ 协程最容易被误解成“轻量线程”。
更准确地说,协程让一个函数可以暂停,把状态保存起来,以后再恢复执行。它本身不等于并发,也不自动创建线程。
1. 普通函数的状态在调用期间存在
普通函数:
void f() {
int x = 1;
int y = x + 1;
}调用时通常有栈帧:
call f()
|
v
+------------------+
| frame of f |
| x = 1 |
| y = 2 |
+------------------+函数返回后:
frame of f is gone
x and y lifetime ended普通函数不能执行到一半,把控制权交回去,过一会儿再从原位置继续。
2. 协程需要保存跨暂停点状态
协程看起来像:
task f() {
int x = 1;
co_await something;
use(x);
}如果 co_await 暂停了,f() 已经把控制权交回调用者。
但以后恢复时,还要继续使用 x。
所以 x 不能只依赖普通栈帧。
编译器会把需要跨暂停点保存的状态放进协程帧。
coroutine frame
+------------------------------+
| promise object |
| saved local variables |
| current suspend point |
| awaiter state |
| bookkeeping |
+------------------------------+协程帧通常在动态存储中,也可能被编译器优化掉。
重点是:
协程暂停后,状态必须保存在能活到恢复时的地方。3. 协程不是自动异步
有 co_await 不等于自动开线程。
协程只提供暂停和恢复机制。
真正把恢复动作接到事件循环、线程池、I/O 完成通知上的,是 awaiter 或框架。
coroutine reaches co_await
|
v
awaiter decides whether ready
|
+-- ready
| continue immediately
|
+-- not ready
save coroutine handle
suspend
resume later by scheduler/event loop如果没有调度器或 awaiter 逻辑,协程不会凭空异步运行。
4. 最小协程骨架
一个能让编译器识别的协程返回类型,需要和 promise_type 配合。
#include <coroutine>
#include <exception>
struct task {
struct promise_type {
task get_return_object() {
return {};
}
std::suspend_never initial_suspend() noexcept {
return {};
}
std::suspend_never final_suspend() noexcept {
return {};
}
void return_void() {}
void unhandled_exception() {
std::terminate();
}
};
};
task hello() {
co_return;
}这段代码不实用,但能说明协议。
编译器看到 hello() 里有 co_return,会把它当成协程,并寻找:
task::promise_typepromise_type 定义了协程和返回对象之间的约定。
5. co_await 的三个关键步骤
一个 awaiter 通常涉及三个动作:
await_ready
是否已经完成,不需要暂停?
await_suspend
如果要暂停,拿到协程句柄后怎么安排恢复?
await_resume
恢复后把什么结果交给 co_await 表达式?流程:
co_await expr
|
v
get awaiter
|
v
await_ready()
|
+-- true -> await_resume(), continue
|
+-- false
|
v
await_suspend(handle)
|
v
coroutine suspended
|
v
resume later
|
v
await_resume()这就是为什么协程更像“可暂停函数 + 状态机转换”。
6. 生命周期问题仍然存在
协程能保存自己的局部状态。
但它不会自动延长外部对象生命周期。
危险例子:
task use_name(std::string_view name) {
co_await something;
use(name);
}如果 name 观察的是调用者的临时字符串:
caller creates temporary string
|
v
use_name stores string_view
|
v
coroutine suspends
|
v
temporary string destroyed
|
v
coroutine resumes
|
v
string_view is dangling协程帧保存了 string_view。
但 string_view 本身不拥有字符。
所以协程尤其要关注:
保存的是值,还是观察窗口?
观察对象会不会跨暂停点失效?
协程帧由谁拥有?
何时销毁?7. 协程适合什么
协程适合表达:
异步 I/O
生成器
复杂状态机
需要暂停和恢复的流程例如异步 I/O 的直觉是:
发起读取
|
v
暂停当前协程
|
v
事件循环等待 I/O 完成
|
v
恢复协程
|
v
继续处理数据这能把回调嵌套写成更接近顺序代码的形式。
但协程不适合用来逃避:
对象生命周期设计
错误传播设计
取消机制设计
调度器设计这些仍然需要框架或程序员明确处理。
8. 本篇总结
协程核心模型:
ordinary function
|
v
state lives in stack frame during call
|
v
return destroys frame
coroutine
|
v
state that crosses suspension lives in coroutine frame
|
v
co_await may suspend
|
v
scheduler or awaiter resumes later理解协程时,先别急着背所有关键字。
先问:
哪里会暂停?
暂停时哪些变量必须保存?
协程帧由谁拥有?
什么时候恢复?
什么时候销毁?
跨暂停点保存的是拥有对象,还是非拥有 view/reference?这些问题回答清楚,co_await、promise_type、awaiter 才会逐渐有意义。