引言:C++20 协程的革命性意义
C++20 引入的协程(Coroutines)被誉为自 C++11 移动语义以来最重要的语言特性。不同于操作系统线程的抢占式调度,C++ 协程是用户态协作式的执行单元——程序员显式控制挂起与恢复,无需内核介入,无需锁保护共享状态。一次协程切换的开销仅为几次指针交换(纳秒级),而线程切换需要保存/恢复完整的 CPU 上下文(微秒级)。
但 C++20 协程的设计哲学与众不同:标准库不提供现成的"协程类型",而是提供了一套编译器原语(low-level building blocks),让开发者自行构建适合场景的协程抽象。这种"给予鱼竿而非鱼"的设计赋予了极致的灵活性,但也带来了陡峭的学习曲线。
C++ 协程的核心思想:将函数的执行状态(局部变量、指令指针、挂起点)打包到一个可恢复的"Continuation"对象中——这就是协程帧(Coroutine Frame)。
第一节:无栈协程 vs 有栈协程
1.1 两种协程模型对比
| 特性 | 有栈协程 (Stackful) | 无栈协程 (Stackful) |
|---|---|---|
| 典型实现 | Boost.Coroutine2、goroutine、Lua Coroutine | C++20 coroutines、C# async/await、JS async |
| 独立栈空间 | ✅ 拥有独立栈(通常 8KB-8MB) | ❌ 共享调用者栈,挂起时数据存入堆 |
| 嵌套调用 | ✅ 可调用普通函数并挂起其内部 | ⚠️ 只能从协程内部挂起,不可跨非协程函数挂起 |
| 内存开销 | 固定栈空间 + 上下文 | 仅存活变量的精确大小(编译器计算) |
| 上下文切换 | swapcontext / 汇编保存寄存器 | 协程帧指针交换 |
| 执行效率 | 中(需切换栈指针) | 高(通常内联为状态机) |
1.2 为什么 C++ 选择无栈模型?
有栈协程虽然直观(看起来像线程),但存在栈空间浪费问题:你无法预知一个协程运行时的实际栈需求。设太大浪费内存,设太小则栈溢出。无栈协程通过编译器精确分析,将仅跨越挂起点的变量存入堆上的协程帧,其余临时变量保留在调用者栈上——后者在协程挂起时自动释放,内存利用率极高。
第二节:C++20 协程三大关键字解析
2.1 co_await — 挂起的核心
co_await expr 是协程挂起的唯一方式。编译器将其转化为一系列 Awaiter 接口调用:
// 伪代码:co_await awaiter 的编译展开
bool await_ready = awaiter.await_ready();
if (!await_ready) {
// 将当前协程注册到 awaiter 的恢复句柄
awaiter.await_suspend(coroutine_handle);
return; // 挂起!控制权返回给调用者/恢复者
}
// 如果 await_ready() == true,不挂起,直接取值
auto result = awaiter.await_resume();
Awaiter 可以是任意满足这三方法接口的类型。这意味着你可以控制:
- await_ready():是否需要挂起?如果已知结果,返回 true 避免挂起,零开销。
- await_suspend(handle):挂起时做什么?通常将 handle 交给调度器或注册到 I/O 事件循环。
- await_resume():恢复时返回的值——就是
co_await表达式的最终结果。
2.2 co_yield — 序列生成器
// co_yield expr 等价于:
co_await promise.yield_value(expr);
co_yield 非常适合实现 Generator 模式:每次调用者拉取一个值时,协程恢复 → 执行到下一个 co_yield → 保存新值并挂起。典型应用是无限序列生成(斐波那契、数据流分块读取)。
2.3 co_return — 协程终结
// co_return expr 等价于:
promise.return_value(expr);
// 跳转到最终挂起点并销毁
co_await promise.final_suspend();
不带值的 co_return; 调用 promise.return_void()。如果忘却 co_return,协程会在函数体末尾隐式调用 return_void()——但这是未定义行为(UB),C++20 要求所有返回非 void 的协程必须有 co_return。
第三节:Promise 机制 — 协程的灵魂
Promise 是协程的"控制中枢"——它决定了协程的行为模式。编译器在发现函数体内使用 co_await/co_yield/co_return 时,会为该函数特化 std::coroutine_traits<RetType, Args...>,从中提取 Promise 类型。
// 完整的 Task 类型定义
template<typename T = void>
struct Task {
struct promise_type {
T value;
std::exception_ptr eptr;
// 1. 返回协程类型本身(决定函数签名 → 返回类型的映射)
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
// 2. 初始挂起策略:suspend_always(惰性启动)或 suspend_never(立即执行)
std::suspend_always initial_suspend() { return {}; }
// 3. 最终挂起策略:必须 suspend_always,否则协程帧自动销毁
std::suspend_always final_suspend() noexcept { return {}; }
// 4. 返回值处理
void return_value(T v) { value = std::move(v); }
// 5. 异常处理
void unhandled_exception() { eptr = std::current_exception(); }
// 6. 自定义 co_await 变换(C++20 返回值推导)
// auto await_transform(CustomType) → 允许对特定类型启用 co_await
};
// 句柄是协程帧的"遥控指针"
std::coroutine_handle<promise_type> handle;
// RAII 管理协程帧生命周期
~Task() { if (handle) handle.destroy(); }
};
3.1 initial_suspend 策略选择
- suspend_always(惰性启动):协程创建后立即挂起,等待调度器驱动。适合 I/O 密集型任务。
- suspend_never(立即启动):协程创建时就开始执行,直到第一个挂起点。适合需要尽快执行的"热"任务。
3.2 协程帧内存布局
编译器将协程帧精确布局为:
// 协程帧布局(概念上)
struct Frame {
Promise promise; // Promise 对象(含 return_value 存储)
// --- register save area --- (仅在需要时保存)
int local_var1; // 跨越挂起点的局部变量
string local_var2; // 同上
Awaiter awaiter; // 当前挂起点的 Awaiter 对象
void* resume_addr; // 恢复时的指令地址(类似程序计数器)
};
对比有栈协程固定预留数百 KB 栈空间,无栈协程帧大小可能仅有几十到几百字节——这使得创建大量协程成为可能(百万级协程并发是可行的)。
第四节:Task 类型完整实现 — 构建可组合的协程
4.1 最小的 Recoverable Task
template<typename T = void>
struct Task {
struct promise_type {
std::optional<T> result;
std::exception_ptr error;
Task get_return_object() {
return {Handle::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
void unhandled_exception() { error = std::current_exception(); }
auto final_suspend() noexcept struct FinalAwaiter {
bool await_ready() noexcept { return false; }
// 关键技巧:恢复调用者链
std::coroutine_handle<> await_suspend(Handle h) noexcept {
auto& prom = h.promise();
if (prom.continuation) return prom.continuation;
return std::noop_coroutine();
}
void await_resume() noexcept {}
};
void return_value(T val) { result = std::move(val); }
};
using Handle = std::coroutine_handle<promise_type>;
// 链式延续:允许 task1 完成后恢复 task2
void continuations(Handle next) {
handle.promise().continuation = next;
}
void resume() { handle.resume(); }
Handle handle;
~Task() { if (handle) handle.destroy(); }
};
4.2 when_all — 并发等待多个协程
// N 个协程全部完成时恢复主协程
template<typename... Ts>
Task<std::tuple<Ts...>> when_all(Task<Ts>... tasks) {
// 预分配结果元组
std::tuple<Ts...> results;
size_t remaining = sizeof...(tasks);
// 为每个 task 设置 continuation 链
co_await std::suspend_always{}; // 让出控制权,让子任务独立执行
// ... 当所有子任务均调用你的 continuation 句柄时,再 co_await 一个汇总 awaiter
co_return results;
}
第五节:协程调度器设计
5.1 多线程 Work-Stealing 调度器
单线程事件循环无法满足多核 CPU 需求。Work-Stealing 是每个线程维护一个本地 LIFO 队列,窃取其他线程的 FIFO 任务:本地 pop hottest(cache-friendly),窃取 FIFO 减少竞争。
class Scheduler {
// 本地队列:LIFO,线程自己 push/pop 无需加锁
std::vector<ch> local_queue[MAX_THREADS];
// 窃取队列:FIFO,其他线程 steal 时加锁/chase-lock
StealableQueue<ch> global_queue;
public:
void schedule(ch h) {
int tid = current_thread_id();
local_queue[tid].push_back(h);
}
void run(int tid) {
while (running) {
ch h = local_pop(tid);
if (!h) h = steal_from_other(tid);
if (!h) { sleep_briefly(); continue; }
h.resume(); // 驱动协程执行
if (h.done()) h.destroy();
}
}
private:
ch steal_from_other(int tid) {
for (int i = 0; i < MAX_THREADS; i++) {
if (i == tid) continue;
ch h = global_queue.steal(i);
if (h) return h;
}
return nullptr;
}
};
5.2 整合 io_uring — 异步 I/O 协程恢复
真正的协程运行时必须与 I/O 事件循环配合。Linux 5.1+ 的 io_uring 提供真正的异步 I/O(无 AIO/vsyscall 限制),是构建高性能协程运行时的理想选择:
// 将 io_uring 绑定到协程调度器
class UringLoop {
struct io_uring ring;
Scheduler& sched;
public:
UringLoop(Scheduler& s) : sched(s) {
io_uring_queue_init(QUEUE_DEPTH, &ring, IORING_SETUP_SQPOLL);
}
// 提交一个协程的 I/O 请求,完成后自动恢复
Task<ssize_t> read_async(int fd, void* buf, size_t count) {
// 获取 SQE 并填充 read 请求
auto* sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, count, 0);
// 将协程 handle 存入 user_data
sqe->user_data = (uint64_t)co_await std::noop_coroutine();
io_uring_submit(&ring);
// 挂起当前协程
co_await suspend_until_io_done();
// io_uring 完成时被事件循环恢复
co_return get_cqe_result();
}
void event_loop() {
while (running) {
// 批量收割完成事件
io_uring_cqe* cqes[BATCH];
int n = io_uring_peek_batch_cqe(&ring, cqes, BATCH);
for (int i = 0; i < n; i++) {
ch h = (ch)io_uring_cqe_get_data(cqes[i]);
sched.schedule(h); // 重新进入调度队列
io_uring_cqe_seen(&ring, cqes[i]);
}
// io_uring 的 shared ring kernel 主动 polling,无需系统调用
}
}
};
第六节:协程陷阱与最佳实践
6.1 最常见的 UB 场景
| 陷阱 | 后果 | 规避方法 |
|---|---|---|
| 协程帧销毁后访问 Promise | Heap-use-after-free(通常是崩溃或数据损坏) | final_suspend 中必须 disconnect,handle.destroy() 前确保无状态依赖 |
| 忘记 co_return | C++20 UB → 编译器警告 + 可能内存泄漏 | 开启 -Werror=return-type,使用 static_assert |
| 在协程内使用 catch(...) 吞掉异常 | 协程帧泄漏,promise.unhandled_exception() 未调用 | 要么 rethrow,要么显式调用 current_exception() 存储 |
| 跨线程 resume 时未同步 | 数据竞争(UB) | 使用 sched.schedule(handle) 将 resume 投递到原始线程 |
| lambda 协程中引用捕获局部变量 | lambda 销毁后引用失效(经典悬空) | 值捕获或确保 lambda 生命周期长于协程执行时间 |
6.2 性能关键原则
- 避免协程帧 heap 分配爆炸:重载 promise_type::operator new 使用内存池(Pool Allocator / Arena),将百万级协程帧的分配开销从 O(N) malloc 降为 O(1) 预分配。
- 使用 symmetric transfer(对称转移):
await_suspend返回下一个协程 handle 时,编译器生成尾调用(tail call),避免递归 resume 导致的栈溢出。 - 让 Awaiter 小且 trivially movable:Awaiter 嵌入在协程帧中,如果 Awaiter 很大或有非平凡析构函数,协程帧大小膨胀。
- 热路径避免虚函数:协程调度器的核心路径(schedule/resume)应使用 CRTP 或 concepts 而非 virtual 实现,避免间接调用开销。
第七节:生产级协程运行时架构参考
构建一个类似 libcppcoro(已废弃)或 Mara(现代 C++20)的高性能协程运行时,需要整合以下模块:
┌───────────────────────────────────────────────────────┐
│ Application Layer │
│ co_await http_get(...); co_await db_query(...); │
├───────────────────────────────────────────────────────┤
│ Task / Generator API │
│ Task<T>, Generator<T>, when_all(), when_any() │
├───────────────────────────────────────────────────────┤
│ Scheduler (Work-Stealing) │
│ N 线程 × 本地 LIFO + 全局 Work-Stealing Queue │
├───────────────────────────────────────────────────────┤
│ I/O Backend (io_uring) │
│ Fixed Buffers + SQPOLL + CQ batch reap │
├───────────────────────────────────────────────────────┤
│ Timer / Signal / AIO │
│ TimerFD → uring submit or epoll + eventfd │
└───────────────────────────────────────────────────────┘
- 协程层:提供 Task/Generator 类型,通过 co_await 组合异步操作。
- 调度层:多线程 Work-Stealing,自动均衡负载。
- I/O 层:io_uring + Fixed Buffers 零拷贝 I/O;若不支持 io_uring 则回退到 epoll + eventfd。
- 基础设施层:定时器(timerfd_create + IORING_OP_TIMEOUT)、信号处理(signalfd → uring)、AIO(IORING_OP_READ/WRITE)。
总结与展望
C++20 协程是"零成本抽象"哲学的极致体现:你只为你使用的功能付出代价,未使用的语言机制不产生运行时开销。从零构建协程运行时是理解现代异步编程本质的最佳路径——它让你看清 Go 的 goroutine调度器、Rust 的 tokio 运行时、JavaScript 的 event loop 在底层是如何通过的同一组机制实现看似不同的API。
随着 C++23 的 std::generator 和 std::stackless_coroutine 进入标准,以及 C++26 的 std::execution(基于 Sender/Receiver 模型)的逐步落地,C++ 的协程与异步编程生态正在迎来真正的收获期。
推荐学习资源
- 《C++20 Coroutines — Complete Guide》:lewissbaker.github.io,作者为 C++ 委员会协程提案作者,权威深度解析
- cppcoro 库(已停更但设计精妙):github.com/lewissbaker/cppcoro,展示了完整的协程原语集合
- Folly 的 coroutine 实现:facebook/folly 的 CPUExecutor + Task 类型,生产级大规模验证
- Mara(Modern Async Runtime Abstraction):C++20 + io_uring 的协程网络库,最贴近本文架构的参考实现
- C++Now / CppCon 协程主题演讲:David Sankel 和 Gor Nishanov 历年演讲深度与实用性兼备

发表评论 取消回复