一、协程的编译器视角:无栈的代价与回报
1. 无栈协程 vs 有栈协程
C++20 引入的是无栈协程(stackless)。与 Go goroutine、Java 虚拟线程这类有栈协程不同,C++ 协程不拥有独立的执行栈,而是把一个函数"切"成可挂起/恢复的多个片段。
这带来三个关键后果:
- 极低的切换成本:恢复协程只是函数返回 + 跳转,没有内核调度、没有栈切换,通常数十纳秒量级;
- 不适用任意嵌套:无栈协程挂起时只能让出控制权给直接调用者,不能在嵌套调用链深处挂起(被调用的普通函数内部无法 co_await);
- 状态显式化:局部变量必须存放到堆分配的协程帧中,编译器负责这部分的搬运。
正是这三点,让 C++ 协程特别适合写生成器与异步 I/O 状态机,而非通用调度器。
2. 协程帧(Coroutine Frame)的布局
当函数体出现 co_await / co_yield / co_return 之一时,该函数成为协程。编译器会为它构造一个协程帧:
协程帧(一般堆分配)
├── promise 对象(用户定义的 promise_type 实例)
├── 挂起点的程序计数器(恢复时的跳转地址)
├── 参数拷贝(按值存入帧,保证生命周期)
└── 跨挂起点存活的局部变量
帧的分配默认使用 operator new,但可以通过重载 promise_type 的 operator new 或使用分配器定制——这是性能优化的关键入口(见第五节)。
3. 编译器把协程翻译成什么
理解协程的最佳方式是记住编译器的"契约":调用协程函数时,先构造协程帧,在帧内构造 promise,然后调用 promise.get_return_object() 返回给调用者;调用者拿到的是一个句柄,协程主体尚未执行。
执行按 promise_type 定义的初始挂起点 initial_suspend() 决定:
- 返回
suspend_never{}:协程体立即执行,直到第一个挂起点或结束; - 返回
suspend_always{}:协程体延迟执行,需要手动resume()。
#include <coroutine>
#include <iostream>
struct Task {
struct promise_type {
Task get_return_object() { return Task{}; }
std::suspend_never initial_suspend() { return {}; } // 立即执行
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
Task hello() {
std::cout << "coroutine body\n";
co_return;
}
int main() {
hello(); // 立即打印 "coroutine body"
std::cout << "main\n"; // 随后打印
}
二、co_await 的展开机制
1. 从语法糖到状态机
co_await expr 是协程挂起/恢复的核心。编译器把它展开成一个状态机转换。以 co_await some_awaitable 为例,逻辑等价于:
// 伪代码:编译器对 co_await expr 的展开
auto awaiter = expr.operator co_await(); // ① 获取 awaiter
if (!awaiter.await_ready()) { // ② 是否已就绪?
// ③ 挂起:保存当前状态,调用 awaiter.await_suspend(handle)
awaiter.await_suspend(coroutine_handle);
return; // 挂起,让出控制权
}
// 恢复后:
auto result = awaiter.await_resume(); // ④ 获取结果
四种情况决定行为:
await_ready()返回true:不挂起,直接await_resume(),无任何状态保存开销;await_suspend()返回void:无条件挂起,恢复时从await_resume()继续;await_suspend()返回bool:返回true挂起,false立即继续(此时 awaiter 已被销毁,不允许接触其成员);await_suspend()返回coroutine_handle<>:对称转移,直接切换到另一个协程,见第五节。
2. 手写一个最小 Awaitable
#include <coroutine>
#include <thread>
#include <chrono>
// 一个在后台线程睡眠后恢复的 awaitable
struct SleepAwaitable {
std::chrono::milliseconds ms;
// 是否已就绪:总是 false,必须等待
bool await_ready() const noexcept { return false; }
// 挂起:启动后台任务,保存 handle 供恢复用
void await_suspend(std::coroutine_handle<> h) {
std::thread([h, ms = this->ms] {
std::this_thread::sleep_for(ms);
h.resume(); // 睡眠结束后恢复协程
}).detach();
}
void await_resume() noexcept {}
};
struct Task2 {
struct promise_type {
Task2 get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
};
Task2 demo() {
std::cout << "sleep start\n";
co_await SleepAwaitable{std::chrono::milliseconds(500)};
std::cout << "sleep end\n";
}
int main() {
demo();
std::this_thread::sleep_for(std::chrono::seconds(1));
}
注意 h.resume() 必须线程安全——如果事件循环线程与恢复线程不同,需要保证 resume() 与 run() 的同步关系(通常通过任务队列投递)。
三、promise_type:协程的"人格"
1. promise_type 的方法与生命周期
每个协程帧内都含一个 promise_type 对象。它决定了协程的对外行为,其方法调用时机如下:
| 方法 | 调用时机 | 作用 |
|---|---|---|
get_return_object() | 构造帧后立即 | 生成返回给调用者的对象(句柄) |
initial_suspend() | 帧构造完成后 | 决定协程体是否立即执行 |
return_void() / return_value(v) | co_return 时 | 传递返回值 |
yield_value(v) | co_yield 时 | 产出值并可选挂起 |
final_suspend() | 协程体结束 | 决定帧如何清理 |
unhandled_exception() | 异常逃逸 | 异常处理钩子 |
帧的销毁时机由 final_suspend() 决定:若返回 suspend_always,协程在结束后仍保持"已完成"状态,由调用方通过句柄显式 destroy();若返回 suspend_never,帧在结束瞬间自动销毁——但此时调用方无法安全地获取结果。生成器惯例是 final_suspend 返回 suspend_always,让消费者遍历完再统一清理。
2. 一个能返回值的 Task
#include <coroutine>
#include <optional>
#include <exception>
template<typename T>
struct ValueTask {
struct promise_type {
ValueTask get_return_object() { return {std::coroutine_handle<promise_type>::from_promise(*this)}; }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(T v) { value_.emplace(std::move(v)); }
void unhandled_exception() { exc_ = std::current_exception(); }
std::optional<T> value_;
std::exception_ptr exc_;
};
std::coroutine_handle<promise_type> h_;
explicit ValueTask(std::coroutine_handle<promise_type> h) : h_(h) {}
~ValueTask() { if (h_) h_.destroy(); }
ValueTask(const ValueTask&) = delete;
ValueTask& operator=(const ValueTask&) = delete;
T get() {
h_.resume();
if (h_.promise().exc_) std::rethrow_exception(h_.promise().exc_);
return std::move(*h_.promise().value_);
}
};
ValueTask<int> compute() {
co_return 42;
}
int main() {
ValueTask<int> t = compute();
int result = t.get(); // 42
}
3. 常见实现错误
- 忘记
initial_suspend为suspend_always导致协程在get_return_object()返回前就跑完,值已丢失; - 移动/拷贝语义被隐式保留:
coroutine_handle是普通指针包装,必须 delete 拷贝,否则双重destroy(); final_suspend返回suspend_never时在get()中resume()已销毁的帧 → 未定义行为。
四、生成器与异步流
1. C++23 std::generator
C++23 标准库引入了 std::generator,终结了手写生成器 promise 的痛苦。它基于 co_yield 产出值,支持懒求值:
#include <generator>
#include <iostream>
std::generator<int> fibonacci(int n) {
int a = 0, b = 1;
for (int i = 0; i < n; ++i) {
co_yield a; // 产出当前值并挂起
int t = a + b; a = b; b = t;
}
}
int main() {
for (int v : fibonacci(8)) {
std::cout << v << ' '; // 0 1 1 2 3 5 8 13
}
}
co_yield a 实际上是 co_await promise.yield_value(a) 的语法糖。std::generator 内部已处理好 final_suspend、异常传播与资源清理,是生产代码的首选。
2. 手写 generator 理解原理
为了看清本质,实现一个最小生成器(在 C++23 之前可用):
#include <coroutine>
template<typename T>
struct Gen {
struct promise_type {
T current;
Gen get_return_object() {
return Gen{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T v) {
current = std::move(v); // 存值
return {}; // 挂起
}
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> h_;
explicit Gen(std::coroutine_handle<promise_type> h) : h_(h) {}
~Gen() { if (h_) h_.destroy(); }
Gen(Gen&& o) noexcept : h_(o.h_) { o.h_ = nullptr; }
Gen& operator=(Gen&& o) noexcept { std::swap(h_, o.h_); return *this; }
Gen(const Gen&) = delete;
Gen& operator=(const Gen&) = delete;
bool next() {
if (!h_) return false;
h_.resume(); // 推进到下一个 co_yield
return !h_.done();
}
T value() const { return h_.promise().current; }
};
Gen<int> range(int n) {
for (int i = 0; i < n; ++i) co_yield i;
}
int main() {
Gen<int> g = range(5);
while (g.next()) {
// 使用 g.value()
}
}
注意 co_yield 让 resume() 在每次产出后挂起,所以 next() 每次只推进一个值——这就是懒生成器的全部秘密。
3. 异步流:事件驱动的数据管道
把生成器与异步 I/O 结合,就得到异步流:数据到达时产出,数据未到时挂起协程,不占线程。概念上等价于 cppcoro::async_generator:
// 伪代码:从 socket 读取"行"的异步流
AsyncStream<std::string> read_lines(tcp::socket& sock) {
std::string line;
char c;
while (true) {
// co_await 一次异步读一个字节(仅示意)
auto [ec, n] = co_await sock.async_read_some(net::buffer(&c, 1),
net::as_tuple(net::use_awaitable));
if (ec) co_return;
if (c == '\n') { co_yield std::move(line); line.clear(); }
else line += c;
}
}
异步流的价值在于:管道中每一级都不阻塞线程,数据就像流水线一样从 socket 流过各处理协程。这正是协程在服务端 I/O 中的杀手级应用。
五、协程在服务端 I/O 的应用
1. 与事件循环结合
协程本身不产生并发,它需要一个驱动器——通常是 io_context/事件循环。Asio 提供 net::awaitable<T> 与 net::co_spawn 把两者粘合:
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/use_awaitable.hpp>
net::awaitable<void> echo(tcp::socket sock) {
char buf[4096];
for (;;) {
auto [ec, n] = co_await sock.async_read_some(
net::buffer(buf), net::as_tuple(net::use_awaitable));
if (ec) co_return;
co_await net::async_write(sock, net::buffer(buf, n), net::use_awaitable);
}
}
void run_server(net::io_context& io, tcp::acceptor& acceptor) {
for (;;) {
tcp::socket sock = co_await acceptor.async_accept(net::use_awaitable);
net::co_spawn(io, echo(std::move(sock)), net::detached);
}
}
co_await ... use_awaitable 让 Asio 的异步操作适配协程的 await 协议,回调链被拍平成线性代码。单线程事件循环配合协程,可以支撑数十万连接——每个连接只是一个协程帧(堆上约几百字节),而非一个线程栈。
2. 连接数对比
| 模型 | 每连接资源 | 万级连接成本 | 代码形态 |
|---|---|---|---|
| 每连接一线程 | 约 8MB 栈 + TCB | 数十 GB,不可行 | 同步线性 |
| 异步回调 | 少量堆内存 | 可行 | 回调嵌套 |
| 协程 + 事件循环 | 一个协程帧(几百字节) | 轻松 | 同步线性 |
3. 与其他语言协程的对照
C++ 协程是库导向的:语言只提供机制(promise_type/awaitable 协议),标准库不强制调度策略。相比之下,Python asyncio、Rust tokio 都有配套运行时。C++ 阵营中 Asio、cppcoro、Folly 各成生态,这也是 C++ 协程学习曲线陡峭的原因之一。
六、性能与陷阱
1. 堆分配与帧定制
协程帧默认走 operator new,高频率创建/销毁协程会带来分配开销。优化手段:
- 在 promise_type 中定义
operator new,改用 arena 或线程本地缓存(参考 https://plumephp.com/cpp-memory-pool-allocators/); - 编译器优化:某些情况下帧可省略分配(全内联、无跨挂点存活变量时);
- 避免在热路径上创建"一次性小协程",可用普通函数替代。
2. 悬垂引用与生命周期
协程帧的存活期与调用者的作用域无关。捕获的引用(如 &sock、this)必须在协程帧销毁前保持有效。典型错误:
net::awaitable<void> bad(net::io_context& io) {
int local = 0; // 存入协程帧,OK
co_await SleepAwaitable{...};
// 若在此之后使用外部引用,必须保证引用对象仍存活
}
3. 对称转移(Symmetric Transfer)
当 await_suspend 返回另一个 coroutine_handle 时,编译器直接跳转到那个协程,而不是先返回再 resume。这避免了"恢复-挂起"的交替调用栈深度累积:
// 深链式调度:每个 await_suspend 返回下一个 handle,栈不增长
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
return next_coroutine; // 对称转移,而非 h.resume() 后返回
}
若在对称转移场景误用 resume() 递归,可能导致栈溢出(如深度优先遍历一棵大树的异步版本)。这是协程库作者需要警惕,应用开发者较少直接触及的细节。
4. 性能小结
- 挂起/恢复:纳秒级,优于任何有栈协程切换;
- 帧分配:若未定制,与
malloc同量级,热路径必须优化; - 代码膨胀:协程状态机展开会使二进制体积增加,嵌入式场景需权衡(见 https://plumephp.com/cpp-embedded-game-engine-integration/)。
七、总结
C++20 协程把"挂起点"从语言内置原语(setjmp/longjmp 或汇编栈切换)抽象为可组合的 awaitable 协议,让异步代码回到线性的阅读形态。掌握它需要三层认知:
- 机制层:协程帧 + promise_type + awaiter 三者的生命周期契约;
- 工具层:
std::generator(C++23)、Asioawaitable、cppcoro 等现成轮子,避免重复造 Promise; - 陷阱层:帧生命周期、对称转移、堆分配开销。
正如前面 https://plumephp.com/cpp-modern-17-20-23/ 所提示的,协程 API 偏底层,生产落地建议直接选用成熟的第三方库而非手写 Promise。当异步 I/O 规模上升时,协程 + 事件循环的组合将是 C++ 服务端超越"每连接一线程"限制的正统路径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。