协程是“轻量线程”吗?从 `co_yield` 看懂 C++20 的挂起与生命周期

时间:2026/04/09

下面的函数看起来会像普通函数一样从头执行到尾:

1
2
3
4
5
Generator numbers() {
co_yield 1;
co_yield 2;
co_yield 3;
}

但调用 auto values = numbers(); 后,函数体甚至可能一行都没有执行。每次外部恢复协程,它才运行到下一个 co_yield,保存现场并再次挂起。

这不是操作系统替我们创建了一个“轻量线程”。C++20 协程是语言级的可挂起函数:编译器把函数改写成状态机,把需要跨越挂起点存活的状态放进协程帧,再通过句柄恢复或销毁。至于何时恢复、在哪个线程恢复、怎样等待 I/O、怎样取消,标准协程机制都不会自动决定。

本文用一个最小生成器讲清协程帧、promise_typecoroutine_handle、挂起点和销毁责任,再把这些概念连接到真实异步 I/O 的工程边界。


1. 协程解决的是哪一种复杂度?

传统异步流程经常被回调切碎:

1
2
3
4
连接完成回调
└── 写入完成回调
└── 读取完成回调
└── 保存结果回调

每个回调都要携带状态、检查错误并保证捕获对象仍然存在。协程允许把相同控制流写成接近顺序代码的形式:

1
2
3
4
5
6
Task<Response> request() {
co_await async_connect();
co_await async_write();
Response response = co_await async_read();
co_return response;
}

“看起来顺序”不代表线程被阻塞。一个真正异步的 awaiter 会注册操作并挂起协程,当前线程返回事件循环;I/O 完成后,调度器再恢复协程。

协程主要改善异步控制流的表达和状态保存。它不会自动提供:

  • 并行执行;
  • I/O 事件循环;
  • 工作线程池;
  • 线程安全;
  • 超时、取消和背压;
  • 可直接用于生产的 Task 类型。

这些能力要由库或应用运行时围绕协程机制提供。

2. 协程和线程到底有什么区别?

维度 C++20 无栈协程 操作系统线程
核心能力 在明确挂起点暂停和恢复函数 独立执行并由 OS 抢占调度
是否自动并行 多核上可以
调度者 程序、库或运行时 操作系统
保存状态 协程帧和状态机 线程栈、寄存器等上下文
切换位置 co_await/co_yield 等挂起点 OS 可在执行期间调度
常见用途 异步流程、生成器、惰性计算 并行计算、运行阻塞任务

协程可以始终在同一个线程恢复,也可以由调度器投递到另一个线程。线程切换是 awaiter/调度器的策略,不是 co_await 自带的语义。

因此,“协程是轻量线程”只能作为非常粗糙的类比,容易掩盖最重要的事实:协程本身不运行,它必须被某个执行者恢复。

3. 哪些关键字会让函数成为协程?

函数体出现下面任一关键字时,它会成为协程:

  • co_await:等待 awaitable,可能挂起并在恢复后产生结果;
  • co_yield:产出一个值并挂起,通常用于生成器;
  • co_return:完成协程并向 promise 交付返回值或完成信号。

返回类型不能随便选择。编译器需要从返回类型关联到 promise_type,由 promise 定义协程怎样创建返回对象、开始、结束、保存结果以及处理未捕获异常。

1
2
3
4
5
6
7
协程函数调用

创建协程帧,其中包含 promise 和跨挂起点状态

promise.get_return_object() 生成外部返回对象

promise.initial_suspend() 决定立即执行还是先挂起

函数返回的 TaskGenerator 通常不是最终业务结果,而是拥有或引用协程帧的控制对象。

4. 编译器把协程改写成了什么?

源码:

1
2
3
4
5
Task work() {
int value = 1;
co_await wait_for_event();
use(value);
}

可以粗略理解为状态机:

1
2
3
4
5
6
7
8
协程帧:promise、value、当前位置、其他状态

state 0: 初始化 value
注册等待并挂起,记录 state 1

state 1: 从挂起点恢复
使用 value
进入最终挂起点

value 在挂起后仍要存在,因此可能成为协程帧的一部分。协程帧常由动态存储承载,但标准允许编译器在条件满足时消除或嵌入这次分配。不能把“协程一定分配一次堆内存”当成跨实现结论,也不能假设它一定被优化掉。

无栈(stackless)表示协程只能在编译器识别的挂起点暂停,并不保存一整条任意调用栈。普通子函数不能替调用它的协程随意挂起,除非异步边界也通过协程/awaiter 显式连接。

5. promise_type 和返回对象分别负责什么?

手写协程返回类型时,promise 常见成员包括:

1
2
3
4
5
6
7
8
9
struct promise_type {
ReturnObject get_return_object();
auto initial_suspend() noexcept;
auto final_suspend() noexcept;
void unhandled_exception();
void return_void(); // 对应无值 co_return
// 或 return_value(T) // 对应 co_return value
// 或 yield_value(T) // 对应 co_yield value
};

它们的职责可以这样理解:

成员 决定什么
get_return_object 外部拿到什么对象来控制/等待协程
initial_suspend 创建后立即执行,还是惰性等待首次恢复
final_suspend 函数体完成后是否保留帧供外部读取和销毁
return_value/void co_return 怎样保存结果
yield_value co_yield 怎样发布当前值并挂起
unhandled_exception 未捕获异常怎样保存或终止

返回对象通常拥有一个 std::coroutine_handle<promise_type>。句柄可以 resume()、查询 done()、访问 promise,并最终 destroy() 帧。它类似一个非拥有的底层控制句柄,本身不会自动管理生命周期;必须由更高层 RAII 类型明确负责销毁。

6. 一个最小可运行的惰性生成器

下面的 C++20 示例只生成 int,避免把教学代码扩展成完整泛型库。它使用 initial_suspend = suspend_always,所以调用 count_to_three() 时先创建并返回控制对象,函数体直到第一次 next() 才执行。

1
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
#include <utility>

class IntGenerator {
public:
struct promise_type;
using Handle = std::coroutine_handle<promise_type>;

struct promise_type {
int current = 0;
std::exception_ptr error;

IntGenerator get_return_object() noexcept {
return IntGenerator{Handle::from_promise(*this)};
}

std::suspend_always initial_suspend() const noexcept { return {}; }
std::suspend_always final_suspend() const noexcept { return {}; }

std::suspend_always yield_value(int value) noexcept {
current = value;
return {};
}

void return_void() const noexcept {}

void unhandled_exception() noexcept {
error = std::current_exception();
}
};

explicit IntGenerator(Handle handle) noexcept : handle_(handle) {}

~IntGenerator() {
if (handle_) {
handle_.destroy();
}
}

IntGenerator(const IntGenerator&) = delete;
IntGenerator& operator=(const IntGenerator&) = delete;

IntGenerator(IntGenerator&& other) noexcept
: handle_(std::exchange(other.handle_, {})) {}

IntGenerator& operator=(IntGenerator&& other) noexcept {
if (this != &other) {
if (handle_) {
handle_.destroy();
}
handle_ = std::exchange(other.handle_, {});
}
return *this;
}

std::optional<int> next() {
if (!handle_ || handle_.done()) {
return std::nullopt;
}

handle_.resume();
if (handle_.promise().error) {
std::rethrow_exception(handle_.promise().error);
}
if (handle_.done()) {
return std::nullopt;
}
return handle_.promise().current;
}

private:
Handle handle_{};
};

IntGenerator count_to_three() {
std::cout << "coroutine started\n";
for (int value = 1; value <= 3; ++value) {
co_yield value;
}
std::cout << "coroutine finished\n";
}

int main() {
IntGenerator numbers = count_to_three();
std::cout << "generator created\n";

while (auto value = numbers.next()) {
std::cout << "value = " << *value << '\n';
}
}

编译运行:

1
2
3
clang++ -std=c++20 -O2 -Wall -Wextra -pedantic \
generator.cpp -o generator
./generator

预期输出:

1
2
3
4
5
6
generator created
coroutine started
value = 1
value = 2
value = 3
coroutine finished

输出顺序证明了惰性执行:创建生成器时函数体没有运行。前三次 next() 分别恢复到下一个 co_yield;第四次恢复后循环结束,执行最后一行并到达 final_suspendnext() 看到 done() 后返回空。

7. 为什么 final_suspend 不能随便改成 suspend_never

示例的返回对象持有句柄,并在析构时调用 destroy()。因此协程完成后必须在最终挂起点保留帧:

1
std::suspend_always final_suspend() const noexcept { return {}; }

如果改为 suspend_never,协程完成时可能自行销毁帧,而 IntGenerator 仍保存悬空句柄,稍后查询或销毁就会产生未定义行为。

最终挂起策略必须与所有权协议配套:

  • 谁拥有协程帧?
  • 完成后谁读取结果?
  • 谁、在何时调用 destroy()
  • 是否还有 continuation 等待恢复?

生产级 Task 的最终挂起经常需要把执行权转交给等待它的协程,远比简单返回 suspend_always 复杂。不要把教学 generator 直接当通用任务类型。

8. co_await 的三个步骤是什么?

一个 awaiter 需要参与三个阶段:

1
2
3
bool await_ready();
auto await_suspend(std::coroutine_handle<> current);
Result await_resume();

执行 result = co_await awaitable 时,可粗略理解为:

  1. await_ready() 为真:资源已经就绪,不挂起,直接进入 await_resume()
  2. 否则调用 await_suspend(current),把当前句柄交给 awaiter 安排恢复;
  3. 协程之后被恢复时,调用 await_resume() 取得结果或重新抛出操作异常。

await_suspend 的返回类型还能影响是否真正挂起或执行对称转移:

  • void:当前协程挂起,由其他代码未来恢复;
  • boolfalse 表示不保持挂起,true 表示挂起;
  • 另一个 coroutine handle:把执行权转移给该协程。

这些规则容易产生重复恢复、恢复已完成协程和生命周期竞态。手写 awaiter 时,必须明确“注册完成事件”和“协程开始挂起”之间的同步,尤其要处理操作可能同步完成的情况。

9. 协程怎样连接到异步 I/O?

co_await socket.read() 并不会让普通阻塞 read 自动非阻塞。背后仍需要事件循环:

1
2
3
4
5
6
7
8
9
协程调用 co_await async_read

awaiter 向 epoll/kqueue/IOCP/库注册 I/O

await_suspend 保存协程句柄,协程挂起

事件完成,运行时把句柄投递到执行器

执行器 resume,await_resume 返回数据或抛异常

恢复可能发生在 I/O 线程、原调用线程或线程池,完全由库契约决定。跨 co_await 之后,代码不能默认仍在原线程;线程局部状态、UI 线程要求和锁的使用都要按执行器语义检查。

绝不要持有普通互斥锁跨越可能挂起的 co_await。协程挂起时锁不会自动释放,恢复又可能在另一线程发生,容易阻塞整个系统或违反互斥量的线程所有权要求。

10. 生命周期为什么是协程最危险的部分?

协程帧会保存参数和跨挂起点局部变量,但引用仍然只是引用。下面的调用可能在恢复时访问已经销毁的对象:

1
2
3
4
5
6
Task use_later(const std::string& text) {
co_await schedule_later();
consume(text); // text 的所有者可能已经销毁
}

auto task = use_later(std::string("temporary"));

函数调用时,临时字符串的生命周期不会因为协程帧保存了引用而自动延长。异步边界上通常应按值取得必要数据,或使用拥有关系明确的智能指针;但 shared_ptr 也要警惕循环引用和不必要的长生命周期。

成员协程还有 this 悬空风险:对象销毁后,帧中保存的 this 指针仍可能被恢复。调用方必须保证对象活过任务,或让协程取得独立状态。把协程“启动后不保存返回对象”尤其危险,因为返回对象的析构可能立即销毁尚未完成的帧,或某些 fire-and-forget 设计又可能失去异常和关闭管理。

11. 异常和取消会自动传播吗?

未捕获异常会进入 promise_type::unhandled_exception()。生成器示例保存 exception_ptr 并在 next() 重抛;一个 Task<T> 通常在 co_await 它时传播异常。若 promise 直接调用 std::terminate(),调用方就没有恢复机会。

取消不是销毁句柄的同义词。一个异步操作可能已经把句柄注册到事件循环;直接销毁帧后,完成回调若继续 resume() 就会使用悬空句柄。可靠取消需要协议:

1
2
3
4
5
6
7
请求停止

通知底层 I/O/定时器取消或忽略结果

确保完成回调不会再访问已销毁帧

在安全执行上下文恢复并交付“已取消”,或完成受控清理

C++20 标准协程没有统一取消模型。项目可以组合 stop_token、库自己的 cancellation slot 或显式取消对象,但具体保证必须结合所用运行时文档确认。

12. Taskfuture、线程池和协程是什么关系?

工具 主要职责
协程语言机制 保存可挂起控制流并允许恢复
Task<T> 用库定义的语义包装协程结果、等待和生命周期
std::future<T> 表达一次异步结果的共享状态和阻塞等待接口
线程池 在受控数量线程上执行工作
I/O 事件循环 等待大量非阻塞 I/O 事件并调度完成逻辑

它们可以组合。协程可能在 I/O 事件上挂起,随后切换到线程池执行 CPU 工作,再回到事件循环发送响应。协程减少的是手写状态机,不是替代执行资源。

将阻塞函数简单放进协程仍会阻塞当前线程:

1
2
3
4
Task bad() {
blocking_database_query(); // 没有 co_await,不会神奇地异步化
co_return;
}

需要把阻塞操作提交到合适线程池,或使用真正的异步 API。

13. 性能应该怎样判断?

协程避免了深层回调和每个操作一个线程,但仍有成本:

  • 协程帧的存储和可能的动态分配;
  • 恢复、间接调用和调度队列;
  • awaiter 与完成事件的同步;
  • 大量跨挂起点局部变量增大帧;
  • 不当调度造成线程切换和缓存损失。

优化时应在 Release 构建下测量真实异步链路,观察吞吐、尾延迟、分配次数和调度次数。不要只比较一个不做 I/O 的微型 resume() 循环,也不要假设“协程比线程轻”就能自动提高服务性能。

14. 工程中最容易踩哪些坑?

误区一:出现 co_await 就不会阻塞线程

是否阻塞取决于 awaitable 的实现。await_ready、注册方式以及底层 API 才决定它是不是异步等待。

误区二:恢复后一定还在原线程

恢复线程由执行器决定。跨挂起点访问线程绑定对象前,必须确认库契约或显式切回正确执行器。

误区三:协程完成后帧会自动按预期销毁

是否在最终点挂起以及谁调用 destroy() 由返回类型设计决定。句柄所有权不清会导致泄漏、双重销毁或悬空恢复。

误区四:引用捕获和普通同步代码一样安全

挂起会延长控制流时间,却不会延长外部被引用对象的生命周期。异步参数和 this 必须重新审视所有权。

误区五:直接手写一个通用 Task 很简单

可等待 Task 还涉及 continuation、对称转移、并发完成、异常、取消、多次等待策略和析构时未完成任务。生产项目优先使用成熟协程库或框架提供的任务类型。

15. 什么时候适合使用协程?

协程适合包含多个异步步骤、需要保存局部状态并在完成事件后继续的流程,例如网络请求链、异步文件操作、定时器组合和生成器。

它不一定适合:

  • 单纯的短同步函数;
  • 直接并行化 CPU 循环;
  • 没有异步运行时支持、只有阻塞 API 的模块;
  • 极简单的一次回调,抽象成本超过收益的地方。

如果目标是利用多核执行计算,应先考虑线程池、并行算法或任务调度器;如果目标是把大量等待流程写清楚,协程才是主要候选。

16. 总结

开头的 Generator 调用后没有立即运行,是因为它的 promise 选择了惰性启动。函数体、局部变量和执行位置被编译器组织进协程帧,外部通过句柄一次次恢复到下一个 co_yield

  1. C++20 协程是可挂起函数和编译器生成的状态机,不是自动调度的线程;
  2. promise 决定启动、完成、结果和异常语义,返回对象负责把这些能力安全暴露给调用方;
  3. coroutine_handle 能恢复和销毁帧,必须有唯一、明确的生命周期协议;
  4. co_await 只有连接到 awaiter、事件源和执行器后,才形成真正异步流程;
  5. 引用、恢复线程、异常和取消是生产协程最需要明确的边界。

学习协程时,先手写并运行一个像本文这样的惰性 generator,观察每次 resume 前后发生什么;进入工程后,优先使用成熟运行时提供的 Task 和 I/O awaitable,把精力放在生命周期与调度契约,而不是重复实现协程基础设施。