协程coroutine原理
# 47.协程coroutine原理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. promise_type——协程的调度中心
- 4. awaiter 与 co_await——暂停和恢复的原子操作
- 5. coroutine_handle——协程帧的物理操作杆
- 6. co_yield 与 co_return——两种不同的结束方式
- 7. 协程栈帧的完整布局——编译器生成的隐藏结构
- 8. 异步协程模式——不阻塞等待而是 co_await
- 9. 常见陷阱与反模式
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 Generator 忘记 co_yield——空生成器的静默崩
某数据处理框架用 C++20 协程写了一个惰性生成器,用于流式读取文件并逐行处理。在模板化阶段,某次重构后生成器突然不产出任何数据:
// ====== 事故代码 V1:没有 co_yield 的生成器——编译器不报错 ======
generator<int> range(int start, int end) {
for (int i = start; i < end; ++i) {
// ❌ 忘记写 co_yield——协程体里没有任何协程关键字
// 编译器把这段代码当成普通函数编译——没有协程状态机
// → 调用方迭代——generator 的 begin() 返回 end()——永远不产出数据
}
co_return; // 这个 co_return 是多余的——编译器在前面的 for 里没看到协程操作
}
2
3
4
5
6
7
8
9
编译通过 + 运行不报错 + 不产出数据——三元最恐怖的 bug。
根因:C++20 的协程检测是基于函数体中是否存在 co_await / co_yield / co_return 三者之一。如果协程体里没有任何协程关键字——编译器直接走普通函数路径——不生成协程帧。然而写代码的人以为自己在写协程——类型签名的 generator<int> 也暗示了这一点。
# 1.2 promise_type 提前析构——协程栈帧的悬挂地狱
同一个框架的异步 RPC 模块用协程做请求-响应调度。在协程挂起后、恢复前,框架持有的 coroutine_handle 变成了悬空指针:
// ====== 事故代码 V2:promise_type 析构 → coroutine_handle 悬垂 ======
struct Task {
struct promise_type {
Task get_return_object() {
return Task{coroutine_handle<promise_type>::from_promise(*this)};
// ⚠️ *this 是 promise_type——在协程帧内部
// 协程帧什么时候析构?如果 ~Task 没有调 handle.destroy()——
// 协程帧在 promise_type 所在的内存被回收后──被重新分配!
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() { return {}; } // ⚠️ suspend_never!
void return_void() {}
void unhandled_exception() {}
};
coroutine_handle<promise_type> handle_;
~Task() {
// ❌ 忘记 handle_.destroy()!
}
};
// 在调用方:
Task t = async_operation(); // ① 协程启动(initial_suspend = never → 立即执行)
// ② 协程遇到 co_await → 暂停
// ③ 协程帧在堆上——等待被恢复
// ④ ... 某个时刻 t 析构(离开作用域)→ ~Task 不调 destroy
// ⑤ 协程帧变成「活着的但没人引用的幽灵」
// 恢复协程的代码仍然持有旧的 handle_ → 指向已被回收(复用)的堆内存
// → resume() 读到垃圾值 → SIGSEGV
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
根因链条:
final_suspend = suspend_never→ 协程在最后一个co_await之后自动析构协程帧- 但如果协程在
co_await上暂停了(未到 final_suspend)→ 协程帧仍在堆上 ~Task不调destroy→ 协程帧泄漏或变成悬垂指针- 后续恢复请求 → 操作已释放的内存
# 1.3 八个待解疑问
① 协程的三个关键字 co_await/co_yield/co_return 分别对应什么样的暂停/恢复语义? → 第 4 / 第 6 章
② promise_type 的 5-7 个接口函数各在什么时机被谁调用?编译器怎么插桩? → 第 3 章
③ awaiter 的 await_ready/await_suspend/await_resume 三个函数的完整调用顺序? → 第 4 章
④ coroutine_handle 为什么只是一个 void*?它是怎么 resume/destroy 协程帧的? → 第 5 章
⑤ 栈less协程是"函数停在哪里,栈帧就分配到哪里"——局部变量都去哪了? → 第 7 章
⑥ 生成器怎么写?co_yield 背后是什么语法变换? → 第 6.3 章
⑦ 协程怎么替代 hand-rolled 异步状态机?比 future/thread 哪里强? → 第 8 章
⑧ final_suspend 的 suspend_always vs suspend_never 怎么选? → 第 3.3 / 第 10 章
2
3
4
5
6
7
8
# 2. 架构概览
# 2.1 协程三角——promise_type / awaiter / handle 的职责划分
┌──────────────────────┐
│ compiler-generated │
│ 协程帧 (堆上) │
│ │
│ ┌─────────────────┐ │
│ │ promise_type │ │ ← 协程的"寄存器"—保存状态/值/异常
│ │ (调度控制中心) │ │
│ └────────▲────────┘ │
│ │ │
│ │ get_return│
│ │ _object() │
│ │ │
│ ┌────────┴────────┐ │
│ │ coroutine_ │ │ ← 帧的"操作杆"
│ │ handle (void*) │ │ resume/destroy/done
│ └────────▲────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ awaiter │ │ ← 暂停/恢复的"开关"
│ │ await_ready │ │ co_await 表达式展开处
│ │ await_suspend │ │
│ │ await_resume │ │
│ └─────────────────┘ │
└──────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
三角的分工:
- promise_type:控制「协程生什么」——生成器产出的值、协程的返回值、异常、启动行为
- awaiter:控制「协程停在哪、怎么恢复」——暂停条件、恢复调度
- coroutine_handle:协程帧的物理指针——resume/destroy 是唯二的操作
# 2.2 为何这么切
疑惑:普通函数不能暂停吗?为什么需要协程?
论证——普通函数和协程的本质差异:
普通函数:
调用 → 压栈 → 执行 → 返回 → 弹栈 → 函数结束
中间不能暂停——一旦返回,栈帧消失、局部变量消失
协程函数(C++20 栈less):
调用 → 分配协程帧(堆) → 执行 → co_await → 暂停(帧保留)
→ 恢复 → 继续执行 → co_return → 帧销毁
中间可暂停任意多次——每次暂停时帧仍在堆上,下次恢复时继续
为什么叫「栈less」——和「有栈协程」的对比:
有栈协程(如 Go goroutine、Lua coroutine):
每个协程有自己独立的栈——切换时切整个栈指针
栈less协程(C++20、Rust):
协程帧在堆上——暂停时行帧保留在堆中
不需要独立的栈——比有栈协程更轻量(不需要 1-8MB 栈分配)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.3 栈less协程的内存秘密——协程帧在堆上、局部变量在帧里
generator<int> sequence() {
int a = 10; // ① 局部变量
co_yield a; // ② 暂停点
int b = a + 20; // ③ 跨暂停点的局部变量
co_yield b;
co_return;
}
2
3
4
5
6
7
编译器变换后的协程帧布局:
协程帧 (堆上,编译器生成的结构):
┌─────────────────────────────────────────────┐
│ promise_type (promise) │ ← 内嵌的 promise_type
│ - 状态: 当前在第几个暂停点 (resume point) │
│ - 保存的 value (yield 的值) │
├─────────────────────────────────────────────┤
│ int a; // 局部变量——从栈搬进帧 │ ← 不需要跨暂停点的变量也会在帧中
│ int b; // 局部变量——跨暂停点 │
├─────────────────────────────────────────────┤
│ 其他编译器生成的状态: │
│ - resume point index (0/1/2) │
│ - exception_ptr (如有未处理的异常) │
└─────────────────────────────────────────────┘
sizeof(协程帧) ≈ sizeof(promise_type) + sizeof(所有局部变量) + overhead(~40B)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键理解:协程函数中的所有局部变量都被搬进协程帧——不管它们是否需要跨暂停点。这是编译器的一律处理策略——因为编译器必须保证在 co_await 暂停时帧中能恢复全部状态。
# 3. promise_type——协程的调度中心
# 3.1 编译器要求的五(或七)个接口函数
struct promise_type {
// ===== 必须实现的 5 个 =====
// ① 创建返回值对象——调用方拿到的不是协程帧,是包装对象
Task get_return_object();
// ② 第一个暂停点之前——suspends at beginning or runs immediately
std::suspend_always initial_suspend(); // 或 suspend_never
// ③ 最后一个暂停点之后——协程结束时
std::suspend_always final_suspend() noexcept; // 或 suspend_never
// ④ co_yield a → 编译器调用 yield_value(a)
std::suspend_always yield_value(int val);
// ⑤ co_return; (无值返回)
void return_void();
// ===== 可选——如果 co_return v (有值) =====
// ⑥ co_return v;
void return_value(int val);
// ===== 必须 =====
// ⑦ 协程体内未捕获的异常
void unhandled_exception();
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
编译器的插桩时机——协程函数被编译器展开后的调用序列:
编译器变换后:
generator<int> sequence() {
// === 编译器插入——阶段 1:创建协程帧 ===
frame = operator new(sizeof(coroutine_frame));
frame->promise = promise_type{};
frame->a = 10; // 搬移局部变量到帧
// === 编译器插入——阶段 2:get_return_object ===
auto return_obj = frame->promise.get_return_object();
// === 编译器插入——阶段 3:initial_suspend ===
co_await frame->promise.initial_suspend();
// 如果 suspend_always → 暂停,返回 return_obj 给调用方
// 如果 suspend_never → 继续执行协程体
// === 协程体(原代码) ===
// int a = 10; ← 已在阶段 1 搬进帧
co_yield frame->a; ← 展开为 co_await promise.yield_value(a)
int b = a + 20; ← b 也在帧中
co_yield b;
co_return; ← 展开为 promise.return_void(); goto final_suspend;
final_suspend:
// === 编译器插入——final_suspend ===
co_await frame->promise.final_suspend();
// 如果 suspend_always → 暂停(由调用方 destroy)
// 如果 suspend_never → 自动析构帧
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 3.2 initial_suspend——协程第一行前停在哪
suspend_always 协程:
调用方拿到 return_obj 后——协程还未执行
需要显式 resume → 协程从头开始执行
适用场景:惰性求值、需要回调注册之后才启动
suspend_never 协程:
调用方拿到 return_obj 时——协程已执行到第一个 co_await
不需要显式 resume——"热启动"
适用场景:异步任务——启动后立即执行直到遇到 IO
2
3
4
5
6
7
8
9
Generator 必须 suspend_always、异步 Task 通常 suspend_never。
# 3.3 final_suspend——最后一个 co_await 之后去哪
这是协程生命周期管理的最关键设计点——直接决定了谁负责析构协程帧:
final_suspend = suspend_always:
协程在结束时「停住」——不自己析构帧
调用方需要通过 handle.destroy() 手动析构帧
好处:可以在 final_suspend 后安全地读取 promise 中的返回值/异常
(因为帧还活着、promise 还活着)
这是推荐模式——和 RAII 兼容
final_suspend = suspend_never:
协程在结束时「不停」——自动析构帧
调用方不需要手动 destroy
坏处:在 co_return 之后无法访问 promise 的数据
(帧已析构——promise 也随之析构)
2
3
4
5
6
7
8
9
10
11
12
案例 1.2 的根因:final_suspend = suspend_never——协程暂停在中间的 co_await 时帧还活着,但一旦协程结束(不管是被 resume 完成还是异常)——帧自动析构。调用方持有的 coroutine_handle 变成悬垂指针。
# 3.4 yield_value 与 return_value 的语义差异
// co_yield val → 编译器展开:
// promise.yield_value(val);
// // 暂停——控制权回到调用方
// co_return val → 编译器展开:
// promise.return_value(val);
// // 跳到 final_suspend
// 关键差异:co_yield 之后协程还活着——co_return 之后协程走向结束
2
3
4
5
6
7
8
9
# 3.5 get_return_object——协程和调用方的唯一接口
struct Task::promise_type {
Task get_return_object() {
return Task{coroutine_handle<promise_type>::from_promise(*this)};
// from_promise:把 promise 的地址转成协程帧的地址
// → 返回 coroutine_handle——调用方通过它来控制协程
}
};
2
3
4
5
6
7
为什么返回值类型不叫 coroutine_handle 而是 Task/Generator? 因为返回对象可以做 RAII——在析构时自动 handle.destroy()。这就是为什么 generator<T> 是一个范例——它把协程帧的生命周期用 RAII 包裹起来。
# 3.6 unhandled_exception——协程异常的最后一站
void unhandled_exception() {
// std::current_exception() 捕获当前未处理的异常
// 存储到 promise 的某个成员——让 get_return_object 返回的对象可以传播它
// 或者在 rethrow 之前存储到 promise 中供调用方检查
// 典型实现:把异常存进 promise,在 final_suspend 后抛回给调用方
exception_ = std::current_exception();
}
2
3
4
5
6
7
8
# 4. awaiter 与 co_await——暂停和恢复的原子操作
# 4.1 awaiter 三函数:await_ready / await_suspend / await_resume
struct Awaiter {
// ① 检查是否「已有结果」「不需要暂停」
bool await_ready() const {
return data_ready_; // true → 跳过暂停(和同步调用一样)
}
// ② 暂停——协程在这里挂起
// 可以返回 void/bool/coroutine_handle
void await_suspend(coroutine_handle<> h) {
// h 是当前协程的 handle——保存它,供恢复时使用
scheduler_->schedule(h); // 注册到调度器
}
// ③ 恢复——协程被 resume 后,这里拿到结果
Data await_resume() const {
return data_; // 返回给 co_await 表达式的值
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
关键:co_await awaiter 的结果是 awaiter.await_resume() 的返回值——不是 awaiter 本身。
# 4.2 co_await 的完整编译器变换——从一行代码到状态机
// 原始代码:
Data d = co_await async_read(sock);
// ↓ 编译器展开为:
auto&& awaiter = async_read(sock); // ① 获取 awaiter
if (!awaiter.await_ready()) { // ② 是否需要暂停?
// === 暂停协程 ===
// 保存当前执行点(resume_point = N)
// 保存所有寄存器
awaiter.await_suspend(handle); // ③ 通知 awaiter——协程暂停了
// await_suspend 返回后——协程已暂停
// 控制权回到调用方/resume 者
// === 恢复后——从 await_resume 开始 ===
}
Data d = awaiter.await_resume(); // ④ 取结果(或在暂停前已取——看实现)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
await_suspend 的三种返回类型——决定控制流转给谁:
| 返回类型 | 语义 | 控制流转给 |
|---|---|---|
void | 暂停——控制权返回到调用方(或上次 resume 者) | 调用方 |
bool | true: 暂停(同 void);false: 不暂停(立即 resume) | 调用方 / 当前协程 |
coroutine_handle<> | 暂停——控制权转给另一个协程(对称转移) | 另一个协程 |
对称转移是协程性能的关键优化——直接从一个协程跳转到另一个——不经过调用方的中间栈帧。
# 4.3 suspend_always vs suspend_never——初始暂停与最终暂停的设计意图
struct suspend_always {
bool await_ready() const noexcept { return false; } // 永远暂停
void await_suspend(coroutine_handle<>) const noexcept {} // 不做调度
void await_resume() const noexcept {} // 无返回值
};
struct suspend_never {
bool await_ready() const noexcept { return true; } // 永远不暂停
void await_suspend(coroutine_handle<>) const noexcept {}
void await_resume() const noexcept {}
};
2
3
4
5
6
7
8
9
10
11
initial_suspend 用 suspend_always vs suspend_never 的语义已在 3.2 展开。核心:懒启动 vs 立即启动。
# 4.4 自定义 awaiter——把一个 IO 操作变成可暂停的协程点
struct IoAwaiter {
int sock_;
Data buffer_;
bool await_ready() {
// 非阻塞检查——如果数据已在 kernel buffer 中
int n = recv(sock_, &buffer_, sizeof(buffer_), MSG_DONTWAIT);
if (n > 0) { already_ = true; return true; } // 已有数据——不暂停
if (n == 0) { eof_ = true; return true; } // EOF——不暂停
return false; // 暂无数据——暂停
}
void await_suspend(coroutine_handle<> h) {
// 注册到 epoll——数据可读时 → 恢复 h
epoll_ctl(epfd_, EPOLL_CTL_ADD, sock_,
&(epoll_event){EPOLLIN, {.ptr = h.address()}});
}
Data await_resume() {
if (eof_) throw eof_error{};
if (already_) return buffer_; // await_ready 时已读了
recv(sock_, &buffer_, sizeof(buffer_), 0); // 恢复时再读
return buffer_;
}
};
// 使用——像同步代码一样写异步 IO:
Data d = co_await IoAwaiter{sock};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 5. coroutine_handle——协程帧的物理操作杆
# 5.1 源码——只是一个 void* 的薄包装
template <typename Promise = void>
struct coroutine_handle {
void* ptr_; // 指向协程帧的第一个字节
// 从 promise 反向获取 handle
static coroutine_handle from_promise(Promise& p) {
// 编译器知道 promise 在帧中的偏移量
// → ptr = &p - offset_of(promise)
coroutine_handle h;
h.ptr_ = static_cast<char*>(static_cast<void*>(&p)) - some_offset;
return h;
}
// 从 void* 构造(from address() 的逆向)
static coroutine_handle from_address(void* addr) {
coroutine_handle h;
h.ptr_ = addr;
return h;
}
void* address() const { return ptr_; }
explicit operator bool() const { return ptr_ != nullptr; }
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
sizeof(coroutine_handle<>) = sizeof(void) = 8 字节。* 它不拥有协程帧——只是指向它。谁负责析构框架——这是 final_suspend 和 promise_type 配合的结果。
# 5.2 resume——从暂停点继续执行的完整路径
void resume() {
// ① 检查协程是否已结束
if (done()) return; // 或抛异常
// ② 恢复执行——从上次暂停的下一条指令开始
// 内部:设置指令寄存器到 resume_point + 恢复寄存器 + 跳转
resume_impl(ptr_); // 编译器内建——对于特定平台
}
2
3
4
5
6
7
8
resume 做了什么——完全由编译器生成:每个协程帧的头部有一个 resume 函数指针——指向编译器生成的 __coroutine_resume 函数。这个函数做:
- 从帧中恢复寄存器
- 跳转到正确的
resume_point(协程状态机内的标签) - 继续执行
# 5.3 destroy——析构协程帧、释放堆内存
void destroy() {
// ① 析构帧中的所有局部变量(按构造的反序)
// ② 析构 promise_type
// ③ operator delete(ptr_)——释放帧内存
destroy_impl(ptr_);
}
2
3
4
5
6
# 5.4 done——检查协程是否已结束
bool done() const {
// 读取帧中的状态标志——是否到达 final_suspend 且 final_suspend 不会暂停
return frame_is_done(ptr_);
}
2
3
4
done == true 之后——resume 和 destroy 都是 UB。
# 5.5 address——获取帧地址用于调试和自定义调度
void* addr = handle.address();
// 可以将帧地址传给 epoll 的 data.ptr、自定义调度器的事件等
epoll_event ev;
ev.data.ptr = handle.address(); // 当 IO 就绪时——从此地址恢复 coroutine_handle
2
3
4
# 6. co_yield 与 co_return——两种不同的结束方式
# 6.1 co_yield 的本质——co_await promise_type.yield_value() 的语法糖
// 原始协程:
generator<int> sequence() {
co_yield 42; // ①
co_yield 84; // ②
}
// ====== 编译器展开 ======
generator<int> sequence() {
// ① co_yield 42 →
co_await promise.yield_value(42);
// yield_value 返回 suspend_always → 暂停
// 调用方从 generator 的迭代器中获取 42
// ② co_yield 84 →
co_await promise.yield_value(84);
// 同上
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
yield_value 的实现范例:
struct promise_type {
int current_value_;
std::suspend_always yield_value(int val) {
current_value_ = val; // 存值——供迭代器读取
return {}; // 暂停——控制权回到调用方
}
};
2
3
4
5
6
7
8
# 6.2 co_return 的 void 与有值版本——return_value vs return_void
// co_return; → promise.return_void(); → final_suspend
// co_return val; → promise.return_value(val); → final_suspend
// 一个 promise_type 只能实现 return_value 或 return_void 之一
// 不能同时实现——编译器在解析协程体时决定用哪个
2
3
4
5
# 6.3 生成器模式——co_yield + for 循环的完整协程版本
template <typename T>
struct generator {
struct promise_type {
T current_val_;
generator get_return_object() {
return generator{coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T val) { current_val_ = val; return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
struct iterator {
coroutine_handle<promise_type> h_;
bool operator!=(std::default_sentinel_t) const { return !h_.done(); }
iterator& operator++() { h_.resume(); return *this; }
T operator*() const { return h_.promise().current_val_; }
};
coroutine_handle<promise_type> h_;
iterator begin() { h_.resume(); return {h_}; }
std::default_sentinel_t end() { return {}; }
~generator() { if (h_) h_.destroy(); }
};
// 使用——像 Python generator 一样简洁:
generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
for (int v : fibonacci()) { // 惰性生成——只在需要时计算下一个值
if (v > 1000) break;
std::cout << v << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 7. 协程栈帧的完整布局——编译器生成的隐藏结构
# 7.1 函数签名到帧结构变换
// 原始函数:
generator<int> range(int start, int end) {
for (int i = start; i < end; ++i)
co_yield i;
co_return;
}
2
3
4
5
6
编译器变换后的帧——简化版:
struct __range_frame {
// === 编译器生成的状态 ===
void (*resume_fn)(__range_frame*); // 恢复函数指针
void (*destroy_fn)(__range_frame*); // 析构函数指针
int resume_point; // 0=initial, 1=after co_yield, -1=done
// === promise_type ===
generator<int>::promise_type __promise;
// === 形参(搬到帧上) ===
int start;
int end;
// === 局部变量(搬到帧上) ===
int i;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
resume 函数——编译器生成的 dispatch 代码:
void __range_resume(__range_frame* frame) {
switch (frame->resume_point) {
case 0: goto initial;
case 1: goto after_yield;
case -1: return; // done
}
initial:
for (frame->i = frame->start; frame->i < frame->end; ++frame->i) {
frame->__promise.current_val_ = frame->i;
frame->resume_point = 1;
return; // 暂停——等待下一次 resume
after_yield:
; // 继续 for 循环
}
frame->__promise.return_void();
frame->resume_point = -1;
// final_suspend
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键:每个暂停点对应 resume_point 的一个整数值。co_yield 在设置 resume_point 后 return——下一次 resume 从 after_yield 标签继续。
# 7.2 局部变量、参数、暂停点的跨帧保存
协程帧中所有需要在暂停之间存活的变量:
✓ 所有形参——通过值传递、或 move 到帧上
✓ 所有跨暂停点的局部变量——存放在帧中
✓ 所有临时对象——如果在 co_await 前构造、co_await 后仍需要——搬进帧
编译器优化——如果编译器能证明「变量不需要跨暂停点」:
→ 变量仍然放在帧上(一律策略)
因为暂停点可能在循环/分支内部——编译器难做精细的活性分析
实例——20 行协程的帧大小 ~200-400 字节(包括编译器状态)
2
3
4
5
6
7
8
9
10
# 7.3 与内核/绿色线程的有栈协程对比——栈less 有栈的本质区别
| 维度 | 栈less协程 (C++20) | 有栈协程 (Go goroutine, Lua) |
|---|---|---|
| 栈分配 | 协程帧在堆上 ~200B | 独立栈 1-8 MB |
| 跨暂停点变量 | 编译器分析→搬进帧 | 自然在栈上 |
| 切换开销 | resume = 分支跳转 + 恢复寄存器 (~5ns) | 切栈 + 改栈指针 (~50ns) |
| 堆碎片 | 多次协程创建 = 多次小分配 | 一次大分配 |
| 递归 | 不支持直接递归(帧需提前确定大小) | 支持 |
| 嵌套协程 | 必须显式 co_await 子协程 | 原生 call |
| 适用场景 | 异步 IO、生成器——轻量 | 通用用户态线程 |
# 8. 异步协程模式——不阻塞等待而是 co_await
# 8.1 单线程异步调度器——一个最简单的 scheduler 实现
struct Scheduler {
std::queue<coroutine_handle<>> ready_queue_;
void schedule(coroutine_handle<> h) {
ready_queue_.push(h);
}
void run() {
while (!ready_queue_.empty()) {
auto h = ready_queue_.front();
ready_queue_.pop();
h.resume(); // 恢复协程——会再次 co_await → 暂停 → schedule
}
}
};
// 使用:
Scheduler sched;
auto task = [&]() -> Task {
co_await async_read(sched, sock); // 暂停——注册到 scheduler
co_await async_write(sched, sock);
co_return;
}();
sched.run(); // 单线程驱动所有协程——不需要锁
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
和传统的线程/回调对比:
- 线程每次 IO 等待 = 一个线程阻塞 = 1MB 栈 + 上下文切换
- 协程每次 IO 等待 = 一个协程帧暂停 = ~200B + resume(函数调用开销)
# 8.2 与 std::future/std::thread/std::async 的对比——为什么协程是更好的异步抽象
future 异步:
auto f = std::async(work);
int r = f.get(); // 阻塞等待——浪费一个线程
协程异步:
int r = co_await async_work(); // 暂停——线程可以干别的
// 协程帧在堆上保留状态——不需要整个线程
2
3
4
5
6
7
| 维度 | thread+future | 协程 |
|---|---|---|
| IO 等待时的资源 | 1 个完整的线程(~1MB 栈) | 协程帧 ~200B |
| 上下文切换 | 内核态切(~3μs) | 函数调用(~5ns) |
| 逻辑流 | 回调 + split,难读 | 顺序代码——和同步一样 |
| 并发 | 真正的并行 | 单线程多路复用(或可多线程) |
| 百万连接 | 不可能(百万线程) | 可能(百万协程帧) |
# 8.3 异步 generator——流式处理的协程范式
async_generator<Packet> read_packets(Socket sock) {
while (true) {
auto data = co_await async_read(sock);
if (data.empty()) co_return;
co_yield parse_packet(data); // 惰性生成——流式处理
}
}
// 消费方——也是协程:
Task process() {
auto packets = read_packets(sock);
while (auto pkt = co_await packets.next()) {
process(pkt);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 9. 常见陷阱与反模式
# 9.1 coroutine_handle::destroy 忘记调用——内存泄露
Task t = async_work();
// ... t 离开作用域 → ~Task()
// 如果 ~Task 不调 handle_.destroy() → 协程帧泄露
// 这是案例 1.2 的一个变种
// 正确——RAII 包裹
~Task() { if (handle_) handle_.destroy(); }
2
3
4
5
6
7
# 9.2 promise_type 中取引用的悬挂——get_return_object 返回引用指向已析构的帧
struct promise_type {
Task get_return_object() {
// ❌ 返回 Task{*this, handle} → this 是 promise_type 的引用
// 如果 final_suspend = suspend_never → 协程结束 → 帧析构
// → Task 里存的是悬垂引用
return Task{*this, coroutine_handle<promise_type>::from_promise(*this)};
}
};
2
3
4
5
6
7
8
正确:在 Task 中存储 coroutine_handle(8 字节的指针)——需要 promise 时通过 handle.promise() 动态获取。
# 9.3 在 final_suspend 中 resume 自己——无限递归
struct promise_type {
std::suspend_always final_suspend() noexcept {
// ❌ 不要在这里 resume 自己
return {}; // suspend_always 是安全的——等调用方 destroy
}
};
2
3
4
5
6
# 9.4 co_await 一个临时对象——await_suspend 返回时临时已析构
// ❌ awaiter 临时对象——在 co_await 表达式结束时析构
co_await IoAwaiter{sock}; // IoAwaiter 在这行结束时析构
// 但如果 await_suspend 中存了 coroutine_handle——恢复时的 await_resume 访问的是已析构的对象!
// ✅ awaiter 通过值传到帧上——或者保证生命周期跨越暂停
2
3
4
5
# 9.5 生成器的迭代器失效——在迭代期间修改协程帧
auto gen = fibonacci();
auto it = gen.begin();
auto val = *it; // 读取第一个值
// 如果不 ++it——generator 的帧停留在第一个 co_yield 之后
gen = fibonacci(); // ❌ 旧的协程帧被 destroy——it 持有的 handle 悬空
auto val2 = *it; // UB!
2
3
4
5
6
7
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | 三个关键字的语义? | 第 4/6 章:co_await → awaiter 暂停恢复;co_yield → 暂停+传值;co_return → 结束 |
| ② | promise_type 的接口时机? | 第 3.1:编译器在协程帧构造→协程体执行→返回值→异常这条线上插入调用 |
| ③ | awaiter 三函数顺序? | 第 4.2:await_ready → await_suspend(暂停)→ await_resume(恢复后) |
| ④ | coroutine_handle 是怎么指针? | 第 5.1:只是 void* → 8 字节——编译器知道如何从地址找回帧结构 |
| ⑤ | 局部变量去哪了? | 第 7 章:全搬进协程帧——堆上。暂停时帧不消失 |
| ⑥ | 生成器的 compiler 魔法? | 第 6.3:co_yield → promise.yield_value + 暂停——迭代器 resume + 读 current_val |
| ⑦ | 协程 vs future/thread? | 第 8.2:协程帧 ~200B vs 线程栈 ~1MB——适合百万级连接 |
| ⑧ | final_suspend 的选择? | 第 3.3:suspend_always → 由调用方 destroy ——RAII 兼容 |
案例①修复——忘记 co_yield:确保协程体中有 co_yield / co_await / co_return 之一。使用静态分析工具检查 generator<T> 函数的返回体是否包含协程关键字。
案例②修复——promise_type 提前析构:
~Task() {
if (handle_ && !handle_.done()) {
handle_.destroy(); // ✅ 只要协程没结束——手动析构帧
}
}
// 并确保 promise_type 中:
std::suspend_always final_suspend() noexcept { return {}; }
// → 协程结束时保持暂停——由调用方通过 destroy 安全析构帧
2
3
4
5
6
7
8
# 10.2 一次 co_await 的完整生命周期——从暂停到恢复
========================================
场景:协程通过 co_await 异步读 socket
========================================
① 协程帧已分配——在堆上(~240B)
② 协程执行到 co_await IoAwaiter{sock}
→ 编译器展开为:
auto&& awaiter = IoAwaiter{sock};
if (!awaiter.await_ready()) { ← recv(MSG_DONTWAIT) = EAGAIN
// 没有数据——需要暂停
③ awaiter.await_suspend(my_handle)
→ epoll_ctl(ADD, sock, data.ptr = my_handle.address())
→ my_handle 被保存在 epoll 的就绪列表中
// === 协程现在暂停了 ===
// 控制权返回到调度器
}
④ 调度器运行事件循环:
epoll_wait → sock 可读 → data.ptr = my_handle.address()
→ coroutine_handle<>::from_address(data.ptr).resume()
⑤ resume → 恢复协程帧
→ resume_point = after_suspend
→ 进入 awaiter.await_resume()
→ recv(sock) 读数据
⑥ 协程继续执行——和同步代码完全一样的外观
========================================
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 10.3 设计哲学回扣
哲学 1:协程把「暂停后恢复」从操作系统级抽象降维到函数调用级
线程的暂停(阻塞)需要内核参与——保存整个线程上下文、调度出去、等 IO 就绪后再调度回来。协程的暂停不需要内核——只是函数 return(到调度器),下次 resume 从暂停点继续。这层抽象降维的意义:把「需要内核的上下文切换」(~3μs)变成「函数调用」(~5ns)——600× 的更轻量暂停。
哲学 2:栈less 不是缺陷——是把「什么需要跨暂停点保存」交给编译器分析
有栈协程的问题是:独立栈需要固定 1-8MB——不管协程只用了几 KB。栈less 协程让编译器精确分析需要保存的变量——只分配刚好够用的帧。这也是 C++「零开销抽象」哲学的延伸——不为不需要的存储付代价。 对于大量协程的场景(百万连接),每个协程省下 1MB 栈 = 省下 1TB 内存。
哲学 3:promise_type / awaiter / handle 的三角分离——编译时协变、运行时协作
三个概念各司其职:promise_type 控制协程的值语义(产什么、返什么、异常怎么传)、awaiter 控制暂停语义(何时停、何时恢复、恢复后取什么值)、handle 提供物理控制(resume / destroy / done)。这个三角把协程的三个维度——数据流、控制流、生命周期——在类型层彻底分离。 这是 C++ 设计语言的典型手法——用类型系统把正交关注点独立编码。
哲学 4:co_await 是显式的暂停点——不是隐式的 yield
C++ 的协程要求每个暂停点都显式标记(co_await / co_yield)。这和 Go 的 goroutine(隐式 yield)正好相反。显式的暂停点让你精确控制「哪里可能发生上下文切换」——这对多线程安全和性能分析至关重要。 这也是 C++ 的设计偏好:让代价在代码中可见。
哲学 5:对称转移是从协程到协程的直接跳转——消灭中间人
await_suspend 返回 coroutine_handle 实现对称转移——一个协程暂停时直接跳转到另一个协程——不经过调度器的中间栈帧。这和对称协程(如 Lua 的 coroutine.transfer)一致——把「调用链」压到最短。在 C++ 中这个优化利用了编译器对协程帧布局的精确了解——从帧 A 跳到帧 B 只是改了指令寄存器——不需要额外的栈操作。
# 10.4 速查表合集
协程三角速查:
| 组件 | 职责 | 关键操作 | sizeof |
|---|---|---|---|
promise_type | 值/异常/生命周期控制 | get_return_object, yield_value, initial_suspend, final_suspend | ~40B (内嵌在帧中) |
awaiter | 暂停/恢复开关 | await_ready, await_suspend, await_resume | 取决于实现 |
coroutine_handle | 帧的物理指针 | resume, destroy, done, from_promise | 8B (void*) |
三个关键字编译器变换:
| 关键字 | 编译器展开 | 协程状态变化 |
|---|---|---|
co_await expr | awaiter.await_ready() → await_suspend → await_resume | 暂停→恢复 |
co_yield val | co_await promise.yield_value(val) | 暂停(值传给迭代器) |
co_return | promise.return_void() | → final_suspend → 结束 |
suspend_always vs suspend_never:
| 策略 | initial_suspend | final_suspend |
|---|---|---|
| Generator | suspend_always(惰性启动) | suspend_always(由调用方 destroy) |
| Task(异步) | suspend_never(立即启动) | suspend_always(RAII 兼容) |
promise_type 接口与协程生命期:
| 阶段 | promise_type 接口 | 调用者 |
|---|---|---|
| 帧创建后 | 构造函数 | 编译器 |
| 调用方取结果 | get_return_object() | 编译器 |
| 第一行前 | initial_suspend() | 编译器(co_await) |
| co_yield 时 | yield_value(val) | 编译器(co_await) |
| co_return 时 | return_void() / return_value(val) | 编译器 |
| 异常时 | unhandled_exception() | 编译器 |
| 最后一刻 | final_suspend() | 编译器(co_await) |
本篇小结:C++20 协程把「可暂停的函数」变成了一等公民。协程三角(promise_type / awaiter / coroutine_handle)把控制流、数据流和物理帧管理在类型层分离。栈less设计让每个协程只占 ~200B——比线程的 1MB 栈轻量 5000 倍。
co_await是显式的暂停点——把同步风格的代码自动展开为异步状态机。生成器、异步 IO、流式处理——三个场景覆盖了协程的核心价值:用同步代码写异步逻辑。
下一篇:六卷并发全部完成。下一篇进入卷七「编译链接与 ABI」——48.编译流程全景,从预处理到可执行文件的完整旅程。