概念
C++ Coroutines 是 C++20 引入的一种语言级协程机制,用来把"会中途暂停、以后再继续"的函数写成顺序代码。它适合表达异步 I/O、生成器、事件驱动状态机等场景。
它不是线程,也不是运行时调度器。C++ 标准只定义了语法和编译器变换规则,不提供像 Go 那样的内置调度。你通常还需要配套库或自己实现 awaitable、task、scheduler。
最核心的三个关键字是:
-
co_await
表示"如果结果还没准备好,就先挂起;准备好后再继续"。
-
co_yield
表示"产出一个值,并暂停自己",常用于生成器。
-
co_return
表示"协程结束,并返回结果"。
只要函数体里用了这三个关键字之一,它就会被当成协程来编译。
它和普通函数的区别
普通函数调用时,要么一路执行到结束,要么抛异常退出。协程则可以:
- 执行到某个挂起点暂停
- 保存当前状态
- 稍后从暂停点恢复
- 最终完成并销毁状态
编译器会把协程拆成一个状态机。局部变量、当前位置、异常状态等会被放进一块协程帧里。恢复时,本质上是在重新进入这台状态机。
它和线程的区别
线程是操作系统调度的执行流,切换成本相对高,通常有独立栈。
协程是语言级、用户态的可暂停计算,C++20 协程是无栈协程,切换通常更轻量。
简单说:
- 线程解决"并行执行"
- 协程更擅长解决"等待期间不要阻塞、代码仍然像同步一样写"
一个最小协程长什么样
协程的返回类型不能只是随便一个类型,它需要配套 promise_type。下面是一个极简示意:
cpp
#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() noexcept {}
void unhandled_exception() { std::terminate(); }
};
};
Task foo() {
co_return;
}
这段代码虽然没什么实际用途,但足以说明一件事:协程返回类型背后必须有一个 promise_type,编译器靠它决定如何构造、挂起、恢复和结束协程。
协程的底层结构
理解 C++ 协程,抓住这四个对象就够了:
-
协程函数
你写的那个含有 co_await 或 co_yield 的函数。
-
promise_type
协程的"控制中心"。定义返回对象、异常处理、初始挂起、最终挂起、返回值如何保存等。
-
coroutine_handle
一个轻量句柄,指向协程状态。可以用它来 resume、destroy、done。
-
协程帧
编译器生成的状态存储区域,里面放局部变量、挂起点编号、promise 对象等。
执行生命周期
一个协程大致经历这几个阶段:
- 创建协程帧
- 构造 promise_type
- 调用 get_return_object 生成返回对象
- 执行 initial_suspend
- 运行协程主体
- 遇到 co_await 或 co_yield 可能挂起
- 恢复后继续执行
- 执行 co_return 或异常退出
- 执行 final_suspend
- destroy 释放协程帧
其中 initial_suspend 和 final_suspend 非常关键。
initial_suspend 决定协程创建后是"立刻执行"还是"先挂起,等别人手动启动"。
final_suspend 决定协程结束后是否还保留挂起状态,方便外部拿结果、做 continuation,再决定何时销毁。
co_await 到底做了什么
表达式:co_await expr
编译器会把它变成一套 await 协议,大致分三步:
- 先把 expr 转成一个 awaiter
- 调用 await_ready()
如果返回 true,说明不用挂起,直接继续 - 如果返回 false
调用 await_suspend(handle)
决定如何挂起,以及谁来恢复这个协程 - 恢复后调用 await_resume()
拿到 co_await 的结果
也就是说,co_await 不是"等待某个固定类型",而是等待"任何实现了 await 协议的对象"。
一个最小 awaiter 可以长这样:
cpp
#include <coroutine>
#include <iostream>
struct MyAwaiter {
bool await_ready() const noexcept {
return false;
}
void await_suspend(std::coroutine_handle<>) const noexcept {
std::cout << "suspend\n";
}
int await_resume() const noexcept {
return 42;
}
};
如果协程里写:int x = co_await MyAwaiter{};
那么恢复后 x 就是 42。
await_suspend 的意义
await_suspend 是协程和调度器衔接的关键点。它拿到当前协程句柄后,可以:
- 什么都不做,让别人以后恢复
- 立刻恢复当前协程
- 把当前协程挂到某个事件源上
- 把 continuation 链起来
- 返回另一个协程句柄,让执行权转移给别的协程
这就是为什么 C++ 协程本身不负责调度,但能很好地接入异步框架。
co_yield 是什么
co_yield 常用于生成器。它的语义大致等价于:
- 把值交给 promise_type 的 yield_value
- 通常挂起当前协程
- 下次恢复时再继续往下执行
例如一个生成器可以这样用:
cpp
auto g = counter();
while (g.next()) {
std::cout << g.value() << "\n";
}
协程函数内部则可能是:
cpp
co_yield 1;
co_yield 2;
co_yield 3;
每次 co_yield 都把一个值暴露给外部,然后暂停。
co_return 是什么
co_return 表示协程完成。
如果返回 void,通常会走 promise_type.return_void()。
如果返回值类型 T,通常会走 promise_type.return_value(value)。
例如:
cpp
struct promise_type {
void return_value(int v) noexcept {
result = v;
}
};
这样协程结束时,返回值会被存进 promise 中,外部再从返回对象里取走。
promise_type 的职责
promise_type 是整个协程设计的核心。常见成员包括:
-
get_return_object
构造并返回协程的返回对象。
-
initial_suspend
决定创建后是否立即执行。
-
final_suspend
决定结束后是否挂起等待收尾。
-
return_void 或 return_value
处理 co_return。
-
unhandled_exception
处理未捕获异常。
-
yield_value
处理 co_yield。
-
await_transform
可选。拦截 co_await expr,把 expr 转换成别的 awaitable。
其中 await_transform 很强大。它允许你定制"在这个协程上下文里,co_await 某个对象是什么意思"。
coroutine_handle 是什么
coroutine_handle 可以理解成"协程实例的句柄"。你可以通过它:
-
resume()
恢复执行
-
destroy()
释放协程帧
-
done()
判断是否已经到最终挂起点
它本身很轻,但也很危险,因为它像裸指针一样,不做资源管理。通常要配合 RAII 封装,避免泄漏或重复销毁。
一个简单生成器思路
标准库直到 C++23 才有 std::generator,而且并非所有编译器/标准库都完整可用。自己写生成器时,常见模式是:
- 返回对象内部保存 coroutine_handle<promise_type>
- promise_type 有一个 current_value
- yield_value 把值写入 current_value 并挂起
- 外部调用 next() 时 resume()
- 用 value() 读取当前值
- 析构时 destroy()
这类代码能很好展示协程"按需产出数据"的能力,比一次性构造整个容器更省内存。
为什么说它是无栈协程
C++20 协程不是给每个协程分配一个独立调用栈,而是把暂停点之间需要保存的状态放入协程帧。它不能像某些有栈协程那样在任意深层函数处直接切出去,只有在显式的 co_await、co_yield、co_return 这些语义点才能挂起。
这也是它高效的原因之一,但也意味着它的表达力更依赖编译器变换和 awaitable 设计。
异常处理
协程中的异常如果没被函数体内部捕获,会进入 promise_type.unhandled_exception()。
常见做法是:
- 把异常保存到 promise 里
- 在 await_resume 或结果读取阶段重新抛出
这样可以把"异步失败"延迟到调用方真正取结果时再感知。
内存分配
协程帧通常会动态分配,但并不一定总在堆上。编译器在某些场景下可能优化掉分配。你也可以在 promise_type 中重载 operator new 和 operator delete,自定义分配策略。
这在高性能系统里很重要,因为频繁创建短生命周期协程时,分配成本可能成为瓶颈。
为什么很多人第一次学会觉得难
难点不在语法,而在"协议"和"控制反转":
- 你写的是顺序代码,但真正控制恢复时机的是 awaiter 或调度器
- 返回对象、promise_type、handle 三者职责分散
- final_suspend 和销毁时机很容易搞错
- 生命周期和所有权问题非常容易踩坑
所以学协程,最好分两层理解:
-
使用层
会写 co_await task,知道它让异步代码看起来像同步
-
实现层
知道 promise_type、awaiter、handle 怎么协作
只掌握第一层够日常使用,第二层更适合框架作者或底层库开发。
典型使用场景
-
异步 I/O
网络请求、定时器、文件读写,避免回调地狱。
-
生成器
惰性遍历大数据流、树遍历、解析器。
-
状态机
把复杂状态迁移写成更自然的顺序逻辑。
-
协作式任务系统
游戏引擎、事件循环、任务图调度。
一个直观类比
把普通函数想成"一口气演完的戏"。
把协程想成"分幕演出,每到关键节点可以暂停,布景保留,下次接着演"。
协程帧就是舞台现场保存下来的所有状态。
coroutine_handle 是后台工作人员拿着的控制器。
promise_type 是导演规则,规定开场、暂停、收场、异常怎么处理。
与 future/promise 的关系
很多人会把协程和 std::future 混在一起。实际上:
- future/promise 是一种结果传递模型
- coroutine 是一种控制流表达模型
协程可以基于 future 实现,也可以完全不依赖 future。现代异步库通常会提供自己的 task<T>,比标准的 std::future 更适合协程整合。
常见坑
- 忘记 destroy,导致协程帧泄漏
- 句柄悬空后还 resume
- final_suspend 设计错误,导致结果还没取就销毁
- 在 await_suspend 里错误恢复,造成重入问题
- 捕获了引用,但协程比被引用对象活得更久
- 误以为 co_await 会自动切线程
- 把协程当线程,期待它自己并发执行
第 6 点尤其常见。co_await 只表示"可能挂起并恢复",不代表"切到后台线程"。线程切换要由你的 awaitable 或调度器决定。
学习建议
如果你要真正掌握 C++ 协程,建议按这个顺序:
- 先理解 co_await、co_yield、co_return 的表面语义
- 再理解 awaiter 三件套:await_ready、await_suspend、await_resume
- 然后理解 promise_type 生命周期
- 最后再看 task<T>、generator、scheduler 的工程实现
这样比一开始就啃完整底层规范更容易。
一句话总结
C++ Coroutines 本质上是"编译器帮你把可暂停函数变成状态机,再通过 promise_type 和 awaiter 协议把它接入异步世界"。
下面把两个都给出来:一个最小可运行的 Task<T>,一个最小可运行的 Generator<T>。前者用来理解 co_await 和结果传递,后者用来理解 co_yield 和惰性产出。
1. 最小 Task<T>
这个版本刻意只保留最核心机制:
- 协程创建后先挂起
- 外部通过 get() 驱动它跑完
- 也支持在别的协程里 co_await 它
- 能保存返回值和异常
示例代码:
cpp
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
#include <utility>
template <typename T>
class Task {
public:
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
struct promise_type {
std::optional<T> value;
std::exception_ptr exception;
Task get_return_object() {
return Task(handle_type::from_promise(*this));
}
std::suspend_always initial_suspend() noexcept {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
void return_value(T v) noexcept {
value = std::move(v);
}
void unhandled_exception() noexcept {
exception = std::current_exception();
}
};
explicit Task(handle_type h) : coro_(h) {}
Task(Task&& other) noexcept : coro_(std::exchange(other.coro_, {})) {}
Task& operator=(Task&& other) noexcept {
if (this != &other) {
if (coro_) {
coro_.destroy();
}
coro_ = std::exchange(other.coro_, {});
}
return *this;
}
Task(const Task&) = delete;
Task& operator=(const Task&) = delete;
~Task() {
if (coro_) {
coro_.destroy();
}
}
bool await_ready() const noexcept {
return !coro_ || coro_.done();
}
void await_suspend(std::coroutine_handle<>) noexcept {
coro_.resume();
}
T await_resume() {
auto& promise = coro_.promise();
if (promise.exception) {
std::rethrow_exception(promise.exception);
}
return std::move(*promise.value);
}
T get() {
while (!coro_.done()) {
coro_.resume();
}
auto& promise = coro_.promise();
if (promise.exception) {
std::rethrow_exception(promise.exception);
}
return std::move(*promise.value);
}
private:
handle_type coro_;
};
Task<int> compute() {
co_return 21 + 21;
}
Task<int> twice() {
int x = co_await compute();
co_return x * 2;
}
int main() {
auto task = twice();
std::cout << task.get() << '\n';
return 0;
}
运行结果:84
这个例子里最重要的点
-
initial_suspend 返回 suspend_always
协程一创建先不执行,所以你拿到的是一个"尚未启动"的任务对象。
-
get() 里反复 resume()
这是最小驱动方式。真实工程里通常不是手写 while,而是交给事件循环或调度器。
-
await_suspend 里 resume 当前被等待的任务
这里为了最小实现,直接同步推进子任务。真实异步框架通常会在这里注册 continuation,而不是直接 resume。
-
final_suspend 返回 suspend_always
协程结束后先停在最终挂起点,外部还能安全读取 promise 里的结果,之后再 destroy。
它的执行过程
以 twice() 为例:
- 调用 twice(),生成协程帧,先挂起
- main 调用 task.get()
- get() 调用 resume(),进入 twice()
- twice() 遇到 co_await compute()
- compute() 被创建,也先挂起
- await_suspend 里 resume compute()
- compute() 执行到 co_return 42,结束
- await_resume() 取到 42
- twice() 继续执行,co_return 84
- get() 拿到最终结果
所以这个最小 Task<T> 本质上已经把这几件事串起来了:
- 协程状态保存
- promise 保存结果
- handle 控制恢复
- co_await 对另一个 Task 取结果
这个实现故意省略了什么
- 没处理 void 特化
- 没做 continuation 链
- 没接线程池或事件循环
- await_suspend 是同步恢复,不是真异步调度
- 没做更严谨的多次 await / 多次 get 约束
但用来理解底层足够了。
2. 最小 Generator<T>
这个版本用来展示 co_yield。核心思路是:
- promise 里保存当前值
- 每次 co_yield 调用 yield_value
- yield_value 保存值并挂起
- 外部每次调用 next() 推进一步
- 用 value() 读取当前产出
示例代码:
cpp
#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>
template <typename T>
class Generator {
public:
struct promise_type;
using handle_type = std::coroutine_handle<promise_type>;
struct promise_type {
T current_value;
std::exception_ptr exception;
Generator get_return_object() {
return Generator(handle_type::from_promise(*this));
}
std::suspend_always initial_suspend() noexcept {
return {};
}
std::suspend_always final_suspend() noexcept {
return {};
}
std::suspend_always yield_value(T value) noexcept {
current_value = std::move(value);
return {};
}
void return_void() noexcept {}
void unhandled_exception() noexcept {
exception = std::current_exception();
}
};
explicit Generator(handle_type h) : coro_(h) {}
Generator(Generator&& other) noexcept : coro_(std::exchange(other.coro_, {})) {}
Generator& operator=(Generator&& other) noexcept {
if (this != &other) {
if (coro_) {
coro_.destroy();
}
coro_ = std::exchange(other.coro_, {});
}
return *this;
}
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
~Generator() {
if (coro_) {
coro_.destroy();
}
}
bool next() {
if (!coro_ || coro_.done()) {
return false;
}
coro_.resume();
if (coro_.promise().exception) {
std::rethrow_exception(coro_.promise().exception);
}
return !coro_.done();
}
const T& value() const {
return coro_.promise().current_value;
}
private:
handle_type coro_;
};
Generator<int> counter(int n) {
for (int i = 1; i <= n; ++i) {
co_yield i;
}
}
int main() {
auto gen = counter(5);
while (gen.next()) {
std::cout << gen.value() << ' ';
}
std::cout << '\n';
return 0;
}
运行结果:1 2 3 4 5
这个例子里最重要的点
-
co_yield i
会转到 promise_type.yield_value(i)
-
yield_value 返回 suspend_always
表示"把值交出去后暂停自己,等外部下次再恢复"
-
next()
每调用一次,就把协程推进到下一个 co_yield 或结束点
-
value()
读取上一次 yield 出来的值
它的执行过程
以 counter(3) 为例:
- 调用 counter(3),协程先创建并挂起
- 第一次 next(),resume 后跑到 co_yield 1
- yield_value(1) 保存当前值并挂起
- value() 读到 1
- 第二次 next(),从上次暂停点继续,到 co_yield 2
- 重复这个过程
- 最后循环结束,协程到 final_suspend
- next() 返回 false
这就是"惰性生成"的本质:不是一开始把所有值算完,而是外部要一个,协程才往前走一步。
3. Task<T> 和 Generator<T> 的本质区别
Task<T> 更像"最终会完成一次的异步结果"。
Generator<T> 更像"可以逐个吐出多个值的数据流"。
你可以把它们对照着记:
- Task<T> 主要看 co_await / return_value
- Generator<T> 主要看 co_yield / yield_value
- Task<T> 通常产出一个最终结果
- Generator<T> 可以产出很多次中间结果
4. 为什么这两个例子能帮助理解协程
因为它们分别覆盖了两条最核心的协程路径:
-
Task<T>
让你看到 promise 怎么保存结果,handle 怎么 resume,co_await 怎么取值。
-
Generator<T>
让你看到协程怎么在多个挂起点之间反复暂停和恢复。
如果把这两个吃透,C++ 协程底层已经掌握了大半。
5. 实战里还会再补什么
如果你继续往工程实现走,下一步通常会加这些能力:
- Task<void> 和 Task<T> 的完整特化
- continuation,避免 await_suspend 里直接同步 resume
- 线程池、事件循环、timer、socket 的 awaitable
- 更安全的结果存储和生命周期管理
- generator 的迭代器接口,做成 range 风格
一句话记忆
Task<T> 是"等最终结果",Generator<T> 是"按次产出值"。