C++20 协程实战:手写 Task、单线程调度器与 awaiter 的三个钩子
协程不是"更轻量的线程",而是一个能被编译器改写成状态机的函数 。
它不解决并行,只解决"在某个点挂起、之后从这个点继续 "。理解了这一点,
co_await就不再像魔法:它只是「检查能不能立刻完成 → 不能就把自己挂到某处 → 有人 resume 我时从这里返回」。本文用 130 行代码实现一个可
co_await的Task和一个单线程调度器,跑出可观测的交错执行,然后把最容易踩的坑一次说完。
1. 先分清:协程、线程、回调
| 谁在跑 | 切换成本 | 典型用途 | |
|---|---|---|---|
| 线程 | 操作系统调度 | 微秒级(栈 + 内核) | 真并行(多核) |
| 回调 | 调用者的栈 | 零切换,但代码被打散 | 事件驱动 I/O |
| 协程 | 调用者的栈(无独立栈) | 纳秒级(一次函数调用) | 用同步写法表达异步流程 |
协程的关键收益是把「状态」从人脑搬到编译器 :if (step == 2) ... 这种手写状态机,编译器会替你在协程帧里生成局部变量和 resume 点。代价是协程帧要自己管理生命周期------这正是本文后面一半在讲的事。
已有线程池/无锁队列的讨论见 thread_pool.md 与 lockfree_queue.md,它们解决的是并行 ;协程解决的是挂起,两者可以叠加(协程跑在线程池上),但不互相替代。
2. 五个钩子:编译器到底调了什么
一个能被 co_await 的对象,必须有三个钩子:
cpp
bool await_ready(); // true → 不必挂起,直接 await_resume
std::coroutine_handle<> await_suspend(handle); // 返回 void/bool/handle,决定交给谁
T await_resume(); // 恢复后拿结果
一个协程函数(函数体里出现 co_await / co_yield / co_return),编译器会生成一个协程帧,并在两个时刻调用 promise 的钩子:
cpp
initial_suspend(); // 刚创建时:立刻跑(suspend_never)还是等被调度(suspend_always)
final_suspend(); // 函数体结束前:挂起(suspend_always)还是直接销毁
外加两个必备成员:
cpp
get_return_object(); // 把 promise 包装成返回给调用者的对象
return_void() / return_value(v); // co_return 的落点
unhandled_exception(); // 协程体内抛出的异常在哪儿被接住
记住一句话:
co_await expr的语义是「调用expr的 awaiter 的三个钩子」 ,和expr是不是协程没有关系。
3. 最小可用的 Task
先看骨架。Task 只做三件事:持有协程句柄、暴露 co_await、在析构时销毁帧。
cpp
struct Task {
struct promise_type {
Scheduler* sched = nullptr;
std::coroutine_handle<> continuation{}; // 谁在等我
struct Initial { // 自定义 initial_suspend
bool await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle h) noexcept {
h.promise().sched = g_sched; // 第一次被调度时绑定调度器
}
void await_resume() noexcept {}
};
Task get_return_object() {
return Task{std::coroutine_handle::from_promise(*this)};
}
Initial initial_suspend() noexcept { return {}; }
struct FinalAwaiter {
bool await_ready() noexcept { return false; }
std::coroutine_handle<> await_suspend(std::coroutine_handle h) noexcept {
auto cont = h.promise().continuation;
return cont ? cont : std::noop_coroutine(); // 对称转移
}
void await_resume() noexcept {}
};
FinalAwaiter final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
explicit Task(std::coroutine_handle h) : h_(h) {}
Task(Task&& o) noexcept : h_(std::exchange(o.h_, {})) {}
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;
~Task() { if (h_) h_.destroy(); }
std::coroutine_handle handle() const { return h_; }
struct Awaiter {
std::coroutine_handle h;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(std::coroutine_handle<> caller) const noexcept {
h.promise().continuation = caller; // 子协程结束后回到父协程
h.promise().sched->spawn(h); // 子协程入队,先让别的任务跑
return std::noop_coroutine(); // 父协程让出,控制权回到事件循环
}
void await_resume() const noexcept {}
};
Awaiter operator co_await() const noexcept { return Awaiter{h_}; }
std::coroutine_handle h_;
};
三个设计决定,每个都能解释:
| 决定 | 原因 |
|---|---|
initial_suspend 用 suspend_always(自定义 Initial) |
让 Task 创建即挂起,调度器决定何时启动;否则 co_await 之前就已经跑完了,失去"可调度"的意义 |
final_suspend 用 suspend_always(FinalAwaiter) |
帧不能在校程函数体结束时立刻销毁------continuation 还要用它;销毁交给 Task 析构 |
await_suspend 返回 std::noop_coroutine() |
这叫对称转移 (symmetric transfer):把控制权还给事件循环,而不是递归调用 caller.resume()。后者在链式等待时会不断加深调用栈,长流水线直接爆栈 |
4. 调度器:一个队列 + 两次 resume
cpp
thread_local Scheduler* g_sched = nullptr;
struct Scheduler {
std::queue> ready;
void spawn(std::coroutine_handle<> h) { ready.push(h); }
void run() {
while (!ready.empty()) {
auto h = ready.front(); ready.pop();
g_sched = this; // 记录"当前调度器",供 Initial 钩子读取
h.resume(); // 整个程序只有这一处恢复协程
}
}
struct Yield { // co_await s.yield() 让出执行权
Scheduler* s;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<> h) const { s->spawn(h); }
void await_resume() const noexcept {}
};
Yield yield() { return Yield{this}; }
};
所有
resume()都发生在run()里,所以协程之间天然没有数据竞争 ------这也是单线程事件循环(如 asio_strand_vs_multi_iocontext.md 里的 strand)能用"不加锁"换性能的原因。
5. 跑起来:交错执行 + 串行组合
cpp
Task worker(Scheduler& s, const char* name, int steps) {
for (int i = 0; i < steps; ++i) {
std::printf("[%s] step %d\n", name, i);
co_await s.yield();
}
std::printf("[%s] done\n", name);
}
Task pipeline(Scheduler& s) {
std::printf("[P] start\n");
co_await worker(s, "child-A", 2); // 串行等待子协程
std::printf("[P] after child-A\n");
co_await worker(s, "child-B", 1);
std::printf("[P] end\n");
}
int main() {
Scheduler s;
auto w1 = worker(s, "A", 3);
auto w2 = worker(s, "B", 2);
auto p = pipeline(s);
s.spawn(w1.handle());
s.spawn(w2.handle());
s.spawn(p.handle());
s.run();
}
实测输出(g++ 12、-std=c++20、-Wall -Wextra 无警告;-fsanitize=address 亦无报错):
text
[A] step 0
[B] step 0
[P] start
[A] step 1
[B] step 1
[child-A] step 0
[A] step 2
[B] done
[child-A] step 1
[A] done
[child-A] done
[P] after child-A
[child-B] step 0
[child-B] done
[P] end
两个现象值得注意:
A/B/P是交错的 :因为每次co_await yield()都把自己塞回队尾,谁都没有"霸占"循环。P对child-A是串行的 :[P] after child-A出现在[child-A] done之后,说明continuation生效------子协程final_suspend时直接把控制权交给父协程,中间不需要经过run()的队列。
6. 常见坑
sched绑定错时机。 很多示例在get_return_object里就把sched设好,但那时协程还没被任何调度器接管。本文放在自定义initial_suspend里、由g_sched赋值,含义是「第一次被 resume 时才知道自己在哪个循环里 」。⚠️thread_local的代价是:从别的线程 resume 同一个协程,g_sched指错------单线程循环里没问题,跨线程就必须显式传调度器。Task必须 move-only,且析构要destroy()。 协程帧是手动管理的堆内存(编译器帮你operator new,没有人帮你delete)。Task拷贝会导致双重销毁;忘记销毁就是泄漏。这也解释了为什么std::generator/std::task都设计成 move-only。co_await的临时Task会在整条表达式结束时析构。co_await worker(...)中的临时对象,在await_resume之后立刻destroy()子帧------此时子协程停在final_suspend,是安全的;但如果你在await_suspend里保存了子句柄,稍后再用它,就是悬垂引用。final_suspend绝不能返回suspend_never。 函数体一结束帧就被销毁,continuation指向的内存已经回收,对称转移过去直接崩。await_suspend里不要同步resume()。 那等于把"挂起"变成"递归调用",深度随任务链增长。要"立刻让别人跑"就用noop_coroutine()返回给事件循环。- 异常必须处理。
unhandled_exception()里默认std::terminate();想把异常传给co_await的调用方,要在 promise 里存std::exception_ptr,并在await_resume里std::rethrow_exception。 - 协程不是免费的。 帧是一次内存分配(可被 HALO 优化掉一部分),
co_await是一次间接调用。在热点路径上,一个普通函数调用仍然比协程便宜------别把协程当语法糖到处撒。
7. 什么时候值得用
- 有明确的"等待"语义:I/O、定时器、信号量、另一个任务的完成;
- 想把回调地狱改写成同步写法(
co_await read(); co_await write();); - 单线程事件循环里需要并发发起多个逻辑任务(协程互相让出,而不是抢占)。
反过来,纯 CPU 计算、没有挂起点、或者只需要"并行"时,线程池就够了------参考 thread_pool.md。
一句话:协程把状态机交给编译器,代价是帧的生命周期交还给你;
co_await只是三个钩子,写对它只需要记住"挂起前登记、恢复后清理"。
参考
- memory_model.md ------ 跨线程 resume 时,
continuation的可见性同样受happens-before约束 - thread_pool.md ------ 协程常见的落地方式:跑在线程池的某个 loop 上
- asio_strand_vs_multi_iocontext.md ------ 单线程事件循环与 strand 的调度模型