1. 阻塞模型为什么会被取代:三代异步实现的特征与阵痛
1.1 第一代:线程加阻塞,直观但扛不住规模
做后台服务的人对下面这个场景应该不陌生:早期版本里一个请求进来,服务端就开一个线程去处理,线程里读数据库、调下游接口、写缓存,每个调用都是同步的。代码读起来很舒服,因为你只需要从上往下写,出错就抛异常,线程栈会把调用关系全部记录下来。
但这个模型有两个绕不开的代价。第一个是线程资源本身不便宜,默认栈大小通常是若干兆字节,哪怕只是创建一个空闲线程,也要先踩一遍栈空间并建立内核对象;当并发量上升到几千甚至上万时,光是线程切换的上下文开销就已经很可观。第二个代价更隐蔽,那就是“阻塞”的时候线程其实是歇着的,它在等待网络包、等待磁盘中断,而在它等待期间,CPU时间片却被白白交出去了。换句话说,你雇了一万个人,但绝大多数时间他们都在站岗等电话,真正干活的时间少得可怜。
一个很典型的反例是连接每线程模型。某个服务早期的实现里,每个客户端连接会占有一个专门的处理线程。上线初期连接数只有几百,系统很稳;后来客户端数量冲到几千,线程数也跟着涨,最后频繁触发线程创建失败,甚至出现内存不足。排查方式也很粗暴:不断调小线程栈,调完发现栈溢出的崩溃更多了。这个阶段的本质问题,是“用线程数量去对应并发数量”,而并发数量往往远大于有效计算数量,资源就这样被空转消耗掉了。
后来大家开始收敛到一个认知:阻塞调用之所以站得住,是因为它把“等待”这件事藏在了系统调用里;但如果一个进程内同时存在大量等待中的调用,那么等待本身的成本就变得不可忽略了。
1.2 第二代:事件循环与回调,控制流向交换但生命周期被撕碎
解决线程空转的经典思路,是把“等待”从线程模型里抽出来:线程不再为一个请求服务到底,而是循环处理事件,哪个 I/O 可读了就去读哪个。Nginx、Redis 这类高并发服务,本质上都是这个路子。
但事件循环一旦落到业务代码里,就会遇到一个很直接的矛盾:业务逻辑是线性的,先做 A 再做 B 再做 C,而事件驱动的代码必须把“做 B”放到 A 完成后的回调里。于是回调里再套回调,回调里再处理异常,很快代码就变成了洋葱结构。到这个阶段,你会发现三个问题开始集中爆发:
- 回调的归属关系模糊:上层调用方发起异步操作之后,回调对象由谁持有、何时释放,往往没有清晰边界。一旦某个中间环节超时或失败,回调可能被反复触发,也可能永远不触发。
- 错误传播断开:同步代码中 try/catch 可以覆盖从函数入口到出口的所有路径,但回调里再抛异常,只能靠回调自己的错误码或者外层封装的特殊回调传递。异常没法沿着“调用链”向上走,因为这条链已经断开。
- 状态机膨胀:为了规避回调嵌套,有人会用状态模式重构,把每一步封装成一个状态节点。本质上就是手工拆状态机。当前状态、下一个状态、输入事件、超时定时器、资源句柄,全都要塞进一个结构体里。写一个复杂握手协议,状态节点能从十几个涨到几十个,维护起来非常痛苦。
我印象最深的是一次实现多步认证流程的经历:每一步要调用外部服务,每个外部服务都是异步回调,还要支持重试和取消。最终代码里维护了一个手工状态机,有七个状态、四个事件、三个定时器,任何一次回调迟到或者重复到达,都可能让状态机进入错误分支。当时的对策是加了一个“世代号”,每次重试加一代,回调回来先比对代数,不是最新一代直接丢弃。现在回头看,这个“世代号”本质上就是在模拟协程的取消和资源过期判断,只是做得更原始。
1.3 协程要拆掉的墙:“写起来像同步”和“运行起来像异步”可以兼得
很多人对协程的第一印象是“它就是让异步代码看起来像同步”。这句话没有错,但只说对了一半。C++20 协程真正解决的问题不是“把异步伪装成同步”,而是让异步逻辑在自己的栈帧里挂起,同时把执行权按需流转出去。这句话可以拆成三点来理解:
第一,协程暂停时,它的局部变量、暂停位置、调用上下文都会保留在协程帧里,而不是被线程栈带走。线程可以立刻去处理别的事情,这正是事件循环追求的高吞吐。
第二,因为局部变量还活着,所以协程可以自然地引用一个 RAII 对象,比如锁、文件句柄、连接池租约。这一点是回调模型蹭破头也想拿回来的能力:回调里你没法顺手创建一个“对本回调生命周期负责”的资源对象,资源该归谁管、该何时释放,经常只能硬编码。
第三,协程恢复时,看起来像是从前一个 co_await 的下一行继续执行,编译器和标准库做了大量工作把这个“继续”转化为状态机的下一次转移。对程序员来说,等待和流转是隐式的;对运行时来说,每一次流转都有确切的控制点。
这套机制的意义不止在“少写几层回调”,而是让异步系统的资源生命周期重新回到结构化掌控中。后面我会详细说确定性销毁,那是整个设计的真正价值所在。
2. 协程的编译期真相:帧、句柄和暂停点重塑了堆栈
2.1 co_await 背后:编译器把函数拆成了状态机
如果你第一次接触 C++20 协程,看到 std::coroutine_handle 和 promise_type 这些名字时可能会被吓住。换个角度想:编译器遇到一个含有 co_await、co_return、co_yield 的函数时,并不会像普通函数那样只是生成一个普通栈帧,而是会做下面几件事:
- 在堆上(或通过重载的 new 分配)分配一个协程帧,帧里存储所有局部变量、当前恢复点、promise 对象、参数副本。
- 把函数体拆分成多个片段,每个 co_await 之间是一个片段;每个片段结束时,要么直接执行下一个片段,要么挂起并保存“该从哪一行继续”的信息。
- 生成一个隐藏的 coroutine_handle 对象,它本质上是指向这个协程帧的指针,外部代码用它来恢复或销毁协程。
协程帧和普通栈帧最大的区别是:普通栈帧随函数调用的深度自动入栈出栈,而协程帧的生命周期完全由程序控制。也正因如此,协程可以挂起后让出控制权,之后由另一个线程的调度器恢复。用一句话概括:协程把“调用栈的连续性”换成了“帧的可流转性”。
2.2 可等待体(awaiter)的三个阶段:准备、转会、恢复
co_await 后面跟的对象被称为 Awaiter,标准要求它至少支持三个接口:await_ready、await_suspend、await_resume。只要理解这三个调用点,就基本理解协程的一半。
假设你在一个协程里写co_await someAwaitable;,编译器生成的状态机会依次做判断:
await_ready()返回 true,说明结果已经就绪,不需要挂起,直接调用await_resume()并继续执行。- 返回 false,说明要等一下。于是协程先挂起,保存恢复点,然后调用
await_suspend(handle),把当前协程的句柄传给外部对象。 - 外部对象(比如一个异步 I/O 请求)可以在
await_suspend里注册回调、提交任务到线程池、或者直接调用handle.resume()。 - 当外部认为结果已经准备好时,它调用
handle.resume()让协程恢复执行。此时await_resume()的返回值会成为co_await表达式的值,整个等待过程完成。
一个容易忽略的事实是:await_suspend的返回值可以是void、bool或另一个coroutine_handle。如果是void或false,协程保持挂起并交还控制权;如果是true,协程会被立即恢复;如果返回另一个句柄,则执行权直接跳到那个句柄对应的协程,这就是后文会讲的对称转移。
不同的异步运行时(调度器)会反复借助这三个接口做策略调整。比如你做线程亲和性控制,就可以在await_suspend中通过判断当前线程 ID 来决定是直接把handle.resume()在同一线程执行,还是投递到目标线程的事件队列。
2.3 coroutine_handle:不要把并发“伪造成连续”,把执行权当成一种可流动的对象
coroutine_handle 是 C++20 协程中对外的操作入口。它类似于指向协程帧的智能指针,但它不做自动释放。你可以调用它的resume()恢复协程,调用destroy()销毁协程帧,也可以通过from_promise()从 promise 对象反向拿到句柄。
从资源流转的角度看,coroutine_handle 才是真正被“传来传去”的东西。网络库收到数据时,IO 线程持有的是一个句柄;定时器超时时,定时器线程持有的也是一个句柄;调度器拿出一批 ready 任务,实际上就是拿出了一批句柄。协程所代表的“业务逻辑流”不是线程本身,而是这些句柄之间的一次次转移。
这里有一个值得反复强调的观点:handle.resume()是同步调用。如果 A 协程执行到 co_await 挂起,B 协程被某个线程 resume,那么 B 会在那个线程的调用栈上一直执行到下一个挂起点才返回。这也解释了为什么不能无脑用一个线程反复 resume 协程——如果协程内部有巨大的计算量,它依然会阻塞当前线程。所以协程解决的是“大量 I/O 等待的场景”,不是把 CPU 密集计算变成非阻塞。
3. 手写 promise_type:协程状态的“四个开关”
3.1 get_return_object 与 initial_suspend:要不要在入口立刻挂起
每一个 C++20 协程的返回类型内部,都必须定义一个promise_type。这个类型定义了协程生命周期内“暴露给外部容器”的部分。第一个需要关注的是get_return_object():它在协程刚开始执行时被调用,返回的对象会作为协程函数本身的返回值。换句话说,外部拿到的是一个 Task 之类的对象,而这个 Task 内部藏着真正可以恢复另一个线程的句柄。
下面是一个最小骨架:
template <typename T> class Task { public: struct promise_type { Task get_return_object() { return Task(std::coroutine_handle<promise_type>::from_promise(*this)); } std::suspend_never initial_suspend() noexcept { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_value(T v) { /* 保存结果 */ } void unhandled_exception() { /* 保存异常 */ } }; private: explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {} std::coroutine_handle<promise_type> handle_; };initial_suspend()决定协程是否在刚启动时就挂起。如果返回std::suspend_never,协程在调用函数返回 Task 对象的同时会立刻执行到第一个 co_await 或 co_return;如果返回std::suspend_always,协程会等外部主动 resume 才真正开始。这个选择直接影响调度策略:
- 对一个“调用后立刻启动”的模型,用
suspend_never最自然,例如发起请求后马上进入等待。 - 对一个“创建后排队”的模型,用
suspend_always更稳妥,例如你需要先注册到调度器、设置优先级,再决定何时执行。 - 对一个“允许外部手动控制执行点”的模型,
suspend_always还是唯一可行的方案,否则你在持有句柄之前协程已经跑完甚至销毁了。
我在实际项目中更倾向于默认suspend_always,把启动权交给调度器,这样可变性更强:线上想改成用户态协作调度、加日志打点、统一埋入执行队列,都不需要改动业务协程函数,只要改初始化策略即可。
3.2 return_void、return_value 与 unhandled_exception:终态也要保持确定性
协程一旦运行到co_return,会调用promise里的return_void()或return_value(),二者只能二选一。之后执行final_suspend(),这是协程执行的最后一步。这里有一个很重要的选择:final_suspend返回suspend_never时,协程帧会在结束时自动销毁;返回suspend_always时,协程会停驻在最终挂起点,帧不释放,由外部句柄调用destroy()来销毁。
为什么很多人坚持要用suspend_always?因为如果返回suspend_never,协程在 co_return 后立刻销毁帧,外部持有的 Task 对象很可能拿到一个已失效的句柄;而且想统一观察“协程是否结束”会更加困难。用suspend_always配合std::coroutine_handle<promise_type>::from_promise(),保证任务对象本身成为唯一的“帧所有者”,析构时再调用destroy(),资源释放点就非常明确。
异常路径同样要处理好。如果协程体内抛出了异常而没有捕获,unhandled_exception()会被调用。一个稳妥的做法是把std::current_exception()保存到 promise 里,之后由恢复方统一提取并重新抛出。这比直接在协程体内四处 catch 优雅得多:协程的调用者只需要关心 Task 的 final state,而不是每个 co_await 之间的异常差异。
void unhandled_exception() { exception_ = std::current_exception(); }如果结果存储也放到 promise 里,那外部通过句柄取返回值时,路径就会非常统一:要么取到值,要么取到异常,协程本身不再返回任何东西。
3.3 final_suspend 与对称转移:把控制权顺着调度链继续流转
单纯的handle.resume()本质上是在调用者的消费栈上执行协程的下一个片段。如果协程 A 被某个线程恢复,A 自己跑到 co_await B,A 挂起,B 被调度,B 又 resume 了协程 C——这些调用会一层层叠在同一个线程栈上。协程挂起容易,但**“挂起时把控制权立刻交还给最初唤醒它的调度器”**却不是那么容易的。这个问题在标准库里的解法是“对称转移”,也就是await_suspend允许返回另一个coroutine_handle,调度器拿到之后直接让那个句柄接管当前执行流。
举一个常见的框架场景:协程 A 等待一个 I/O,I/O 完成后回调发起 resume,A 后续又启动协程 B,B 内部可能继续等待更深的协程 C。如果每层的 resume 都是嵌套调用,调用栈会越来越深。即使协程帧在逻辑上堆起来了,实际线程栈也有被击穿的风险。对称转移的价值就在于:当 A 在final_suspend挂起时,它不是把句柄一路 return 回最初线程,而是直接告诉调度器“下一个该执行的是 B 的句柄”,然后那个线程切换到 B 的上下文,继续执行。这样每次恢复都是一个独立的入口,调用栈不会无限嵌套。
std::coroutine_handle<> await_suspend(std::coroutine_handle<> current) { auto next = scheduler.pick_next_ready(); if (next) return next; // 对称转移:直接执行下一个协程 return std::noop_coroutine(); // 没有任务时回到外层调度循环 }这个细节在实际运行时库中很重要。如果你的线程是从事件循环里调handle.resume()驱动协程的,那么当整个任务链结束前,你会希望控制权始终回到事件循环的调度入口,而不是被一层层回调栈拽到最深处。对称转移让“协程式状态迁移”和“事件循环式驱动”这两种模型可以无缝组合。
4. 确定性销毁不是玄学:析构时机、所有权与验证方法
4.1 “确定性”到底指什么:和 shared_ptr 相比,RAII 的差别在语义层面
异步编程提到资源管理,很多人第一反应是用std::shared_ptr包住连接、包住缓冲区。shared_ptr 确实能防止悬挂,但它不保证释放时机。引用计数可能因为回调、闭包、可观察对象等被拉长,最后一个引用什么时候消失,只有运行时知道。这带来一个问题:资源不泄漏,但资源碎片化和释放时间的不确定性却难以接受。
协程的确定性体现在更严格的逻辑层面:协程帧销毁时,帧内的所有局部对象都会按照作用域规则依次析构,这些析构一定发生在协程的销毁流程内,不会推迟到垃圾回收或引用计数归零的某个未知时刻。这句话不能理解成“协程什么时候销毁一定执行”,而是说“一旦销毁路径被触发,析构过程是严格受控、按顺序发生的”。
举个最直观的例子:你在协程里创建了一个数据库连接池租约对象:
ConnectionGuard guard(connection_pool); co_await db.query(...); // 等待期间 guard 仍然活着 co_await db.commit(...); // 提交完成后继续 // 位置1如果整个协程在第二个 co_await 期间被外部取消并调用handle.destroy(),那么 guard 的析构函数会在协程帧销毁时被执行。连接租约会被自动归还。在回调模型里,这个归还动作要写在回调里,而且一旦回调被跳过了,归还逻辑也跟着失效。协程和回调之间最大的差距,就在这里。
4.2 暂停点被销毁会发生什么:局部对象析构顺序的实际表现
协程帧销毁时,编译器生成的销毁代码会做什么?它会把帧内所有“已构造但尚未析构”的局部对象按逆序析构,然后析构 promise 对象,最后释放帧内存。这里有两个容易被忽略的细节。
第一个细节是:co_await 挂起点前后,局部生命周期是分离的。看下面这段伪代码:
{ std::lock_guard lock(mutex); co_await some_op; // 错误用法:持有锁跨挂起点 }如果你真的这么写,协程会在挂起时保持 lock 活着,直到 later resumed 并被销毁或后续恢复。这不是运行时的 bug,而是逻辑层面的错误。C++ 规范并不会因为“你在 co_await 前加了锁”就自动帮你解锁。这一点在并发的协程中非常危险:一个协程持有锁挂起,会让其他等待同一把锁的协程无限期等待。
第二个细节是:handle.destroy()和正常的函数返回在资源释放语义上并不完全一样。正常返回时,栈上的局部对象从内层到外层依次析构,外层作用域结束后函数才真正退出;而协程帧销毁时,发生在“当前暂停点之后”的局部对象可能尚未构造,自然也不会被析构。例如:
co_await step1; // 挂起点 MyResource res; // 挂起期间未构造 co_await step2;如果协程在co_await step1期间被 destroy,那么res还没来得及构造,也就不存在析构问题。这符合直觉,但也说明了“确定性销毁”不是“把所有东西都析构”,而是“对已构造对象按构造顺序准确析构”。这种精确性是你可以预测资源回收时机的根本原因。
4.3 一套可以上生产的校验方法:构造析构日志驱动验证
既然要谈确定性销毁,总得有办法验证它。我常用的做法是在一个模拟资源类里记录构造与析构时间点,然后用一个专门设计的协程销毁用例来验证顺序。
struct Trace { inline static int alive = 0; inline static int constructed = 0; Trace() { ++constructed; ++alive; } ~Trace() { --alive; } };测试用例可以这样设计:协程启动后先创建一个 Trace 对象,然后在第一个挂起点之前等待一个永远不会完成的事件,之后由外部主动调用handle.destroy()。你期望的结果是:alive在 destroy 后回到 0,而在 destroy 之前,协程帧内部仍然保持 1。这能直接证明析构确实随协程帧销毁发生了。
更复杂的场景是验证协程完成后 final_suspend 的清理路径:用suspend_always保存帧,然后再调用destroy(),观察析构日志是否在 destroy 调用点打出,而不是在已经结束的回调里偷偷释放。这样可以把“生命周期边界”和“执行点”一一对应起来,为后续的压力测试和故障注入提供基准。
5. 架构演进中的工程实践:任务编排、取消与协程句柄的可观测性
5.1 把异步资源包装成可安全销毁的资源管理器
协程本身并不是一个现成的异步框架,它更像是一块没有任何默认行为的“原料”。你仍然需要决定协程何时启动、何时恢复、何时销毁、由谁持有句柄。这也是为什么每个 C++ 协程项目最终都要封装自己的 Task/Awaitable 类型。
我在实际项目中采用的模式是:定义一个AsyncResource,它内部持有协程句柄和清理回调,析构时触发取消,并保证只处理一次。关键点是句柄的所有权必须单一且明确。协程句柄一旦被两个对象同时持有,销毁路径就会变得不可控。我的约定是:
- 创建协程时,
Task对象是句柄的唯一所有者。 - 调度器只暂时借用句柄来 resume,不负责销毁。
- 协程完成后,
Task的析构函数负责调用handle.destroy()释放帧。 - 协程中途被取消时,由取消器(CancellationToken 等)先通知协程内部逻辑走清理路径,再由所有者触发 destroy。
这套约定看着简单,但它把“谁销毁、何时销毁”变成了一种规章制度,而不是临时拍脑袋的决策。协程帧的所有权一旦确定,资源管理器就可以按预期行为工作。
~Task() { if (handle_) handle_.destroy(); }如果想要更保险,可以在 Task 里增加std::atomic<bool> done_,并让协程在final_suspend时置位。这样避免在协程已结束之后重复销毁句柄,也能在调试时快速识别“悬垂的协程句柄”。不要嫌这个标志位多余——多线程环境下的竞态问题通常就出在这些细枝末节上。
5.2 取消、超时与所有权:分布式场景下最容易忽略的三种状态
协程的确定性销毁并不等于自动处理取消。取消是一个独立的协议设计问题。比如你发起一个 HTTP 请求,如果客户端已经关闭了连接,服务端还在继续计算,那么这次计算就是无效的。协程允许你在挂起点检查取消状态,但你必须自己决定在哪些等待点去检查。
一个可用的模式是超时和取消都建模成 Awaitable:
class CancellableAwaiter { public: bool await_ready() noexcept { return false; } void await_suspend(std::coroutine_handle<> h) { token_.register_callback([h] { h.resume(); }); } bool await_resume() { if (token_.is_cancelled()) throw CancelledException{}; return true; } private: CancellationToken& token_; };如果取消事件触发,它会直接 resume 协程,协程从await_resume里抛出异常,然后沿着协程体内的异常路径走到unhandled_exception()。这样取消不会静默吞掉,也不会让协程继续无意义地跑下去。
超时本质上也是取消的一种。把计时器挂到 Awaitable 上,超时先触发 resume,恢复后检测到超时状态即可。这里最值得注意的问题是:外部在超时后调用 destroy() 之前,要确保等待中的 I/O 回调不会再碰那个协程句柄。否则就会出现悬垂指针。解决思路是增加一个“是否已失效”的原子标志位,回调在触发前先检查它,巧妙避开竞态。
5.3 实战中的三个坑:不要在挂起点跨作用域持锁、不要重复析构句柄、不要忽略异常
最后聊几个我踩过的实际坑,都是协程落地时特别容易遇到的。
第一,不要在 co_await 挂起点持有锁。我上面已经说过,跨挂起点持锁会让其他等待者长时间阻塞。如果你控制不住自己的代码,可以考虑把等待操作拆成两段:先取数据,再在锁外等待,最后拿锁写入。尽管不能把所有并发问题一招解决,但至少能避免“拿着锁睡觉”这种低级事故。
第二,永远不要让一个 coroutine_handle 被两个 RAII 对象共同持有。协程句柄不是智能指针,它不会自动计数。两处对象同时调用 destroy() 的后果是双重重释放,这类问题在崩溃转储里几乎查不出线索。唯一的根治法则是约定所有权:要么 Task 是唯一所有者,要么调度器是唯一所有者,或者用一个显式的 owner 对象管理整个生命周期。
第三,unhandled_exception 必须真的处理异常,不能留空。一个留空的unhandled_exception会让异常被吞掉,协程静默结束,看起来像成功,但业务状态完全不对。比较合理的做法是至少记录一条错误日志,或者把异常重新抛出到某个统一的处理入口。宁可让任务失败得响亮一点,也不要让失败变得无声无息。
还有一个经验是:协程的调试天生比普通函数困难,因为调用栈上没有那么多“中间帧”,暂停点和销毁点往往横跨多个线程。我的建议是尽早把协程状态和句柄地址打点,打印出resume、suspend、destroy三类事件,配合统一的日志 tag 去分析。一旦把“执行流”变成日志流,再复杂的状态机问题也会露出原型。
C++20 协程不是银弹,它只是把异步程序中“转移控制权”和“确定性释放资源”两件最难的事,重新放回语言语法允许你控制的位置。真正让它发挥价值的关键,仍然在于你对帧生命周期、所有权和取消语义的把握。把这些想清楚了,从阻塞到流转的演进,就不再是口号,而是一套可以交付、可以排障、可以长期维护的工程实践。