协程是“轻量线程”吗?从 `co_yield` 看懂 C++20 的挂起与生命周期
时间:2026/04/09
下面的函数看起来会像普通函数一样从头执行到尾:
1 | Generator numbers() { |
但调用 auto values = numbers(); 后,函数体甚至可能一行都没有执行。每次外部恢复协程,它才运行到下一个 co_yield,保存现场并再次挂起。
这不是操作系统替我们创建了一个“轻量线程”。C++20 协程是语言级的可挂起函数:编译器把函数改写成状态机,把需要跨越挂起点存活的状态放进协程帧,再通过句柄恢复或销毁。至于何时恢复、在哪个线程恢复、怎样等待 I/O、怎样取消,标准协程机制都不会自动决定。
本文用一个最小生成器讲清协程帧、promise_type、coroutine_handle、挂起点和销毁责任,再把这些概念连接到真实异步 I/O 的工程边界。
1. 协程解决的是哪一种复杂度?
传统异步流程经常被回调切碎:
1 | 连接完成回调 |
每个回调都要携带状态、检查错误并保证捕获对象仍然存在。协程允许把相同控制流写成接近顺序代码的形式:
1 | Task<Response> request() { |
“看起来顺序”不代表线程被阻塞。一个真正异步的 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 | 协程函数调用 |
函数返回的 Task 或 Generator 通常不是最终业务结果,而是拥有或引用协程帧的控制对象。
4. 编译器把协程改写成了什么?
源码:
1 | Task work() { |
可以粗略理解为状态机:
1 | 协程帧:promise、value、当前位置、其他状态 |
value 在挂起后仍要存在,因此可能成为协程帧的一部分。协程帧常由动态存储承载,但标准允许编译器在条件满足时消除或嵌入这次分配。不能把“协程一定分配一次堆内存”当成跨实现结论,也不能假设它一定被优化掉。
无栈(stackless)表示协程只能在编译器识别的挂起点暂停,并不保存一整条任意调用栈。普通子函数不能替调用它的协程随意挂起,除非异步边界也通过协程/awaiter 显式连接。
5. promise_type 和返回对象分别负责什么?
手写协程返回类型时,promise 常见成员包括:
1 | struct promise_type { |
它们的职责可以这样理解:
| 成员 | 决定什么 |
|---|---|
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 |
|
编译运行:
1 | clang++ -std=c++20 -O2 -Wall -Wextra -pedantic \ |
预期输出:
1 | generator created |
输出顺序证明了惰性执行:创建生成器时函数体没有运行。前三次 next() 分别恢复到下一个 co_yield;第四次恢复后循环结束,执行最后一行并到达 final_suspend,next() 看到 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 | bool await_ready(); |
执行 result = co_await awaitable 时,可粗略理解为:
await_ready()为真:资源已经就绪,不挂起,直接进入await_resume();- 否则调用
await_suspend(current),把当前句柄交给 awaiter 安排恢复; - 协程之后被恢复时,调用
await_resume()取得结果或重新抛出操作异常。
await_suspend 的返回类型还能影响是否真正挂起或执行对称转移:
void:当前协程挂起,由其他代码未来恢复;bool:false表示不保持挂起,true表示挂起;- 另一个 coroutine handle:把执行权转移给该协程。
这些规则容易产生重复恢复、恢复已完成协程和生命周期竞态。手写 awaiter 时,必须明确“注册完成事件”和“协程开始挂起”之间的同步,尤其要处理操作可能同步完成的情况。
9. 协程怎样连接到异步 I/O?
co_await socket.read() 并不会让普通阻塞 read 自动非阻塞。背后仍需要事件循环:
1 | 协程调用 co_await async_read |
恢复可能发生在 I/O 线程、原调用线程或线程池,完全由库契约决定。跨 co_await 之后,代码不能默认仍在原线程;线程局部状态、UI 线程要求和锁的使用都要按执行器语义检查。
绝不要持有普通互斥锁跨越可能挂起的 co_await。协程挂起时锁不会自动释放,恢复又可能在另一线程发生,容易阻塞整个系统或违反互斥量的线程所有权要求。
10. 生命周期为什么是协程最危险的部分?
协程帧会保存参数和跨挂起点局部变量,但引用仍然只是引用。下面的调用可能在恢复时访问已经销毁的对象:
1 | Task use_later(const std::string& text) { |
函数调用时,临时字符串的生命周期不会因为协程帧保存了引用而自动延长。异步边界上通常应按值取得必要数据,或使用拥有关系明确的智能指针;但 shared_ptr 也要警惕循环引用和不必要的长生命周期。
成员协程还有 this 悬空风险:对象销毁后,帧中保存的 this 指针仍可能被恢复。调用方必须保证对象活过任务,或让协程取得独立状态。把协程“启动后不保存返回对象”尤其危险,因为返回对象的析构可能立即销毁尚未完成的帧,或某些 fire-and-forget 设计又可能失去异常和关闭管理。
11. 异常和取消会自动传播吗?
未捕获异常会进入 promise_type::unhandled_exception()。生成器示例保存 exception_ptr 并在 next() 重抛;一个 Task<T> 通常在 co_await 它时传播异常。若 promise 直接调用 std::terminate(),调用方就没有恢复机会。
取消不是销毁句柄的同义词。一个异步操作可能已经把句柄注册到事件循环;直接销毁帧后,完成回调若继续 resume() 就会使用悬空句柄。可靠取消需要协议:
1 | 请求停止 |
C++20 标准协程没有统一取消模型。项目可以组合 stop_token、库自己的 cancellation slot 或显式取消对象,但具体保证必须结合所用运行时文档确认。
12. Task、future、线程池和协程是什么关系?
| 工具 | 主要职责 |
|---|---|
| 协程语言机制 | 保存可挂起控制流并允许恢复 |
Task<T> |
用库定义的语义包装协程结果、等待和生命周期 |
std::future<T> |
表达一次异步结果的共享状态和阻塞等待接口 |
| 线程池 | 在受控数量线程上执行工作 |
| I/O 事件循环 | 等待大量非阻塞 I/O 事件并调度完成逻辑 |
它们可以组合。协程可能在 I/O 事件上挂起,随后切换到线程池执行 CPU 工作,再回到事件循环发送响应。协程减少的是手写状态机,不是替代执行资源。
将阻塞函数简单放进协程仍会阻塞当前线程:
1 | Task bad() { |
需要把阻塞操作提交到合适线程池,或使用真正的异步 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。
- C++20 协程是可挂起函数和编译器生成的状态机,不是自动调度的线程;
- promise 决定启动、完成、结果和异常语义,返回对象负责把这些能力安全暴露给调用方;
coroutine_handle能恢复和销毁帧,必须有唯一、明确的生命周期协议;co_await只有连接到 awaiter、事件源和执行器后,才形成真正异步流程;- 引用、恢复线程、异常和取消是生产协程最需要明确的边界。
学习协程时,先手写并运行一个像本文这样的惰性 generator,观察每次 resume 前后发生什么;进入工程后,优先使用成熟运行时提供的 Task 和 I/O awaitable,把精力放在生命周期与调度契约,而不是重复实现协程基础设施。