2026-09-07 · 每日技术导师 · 接续 08-26 事件循环与 08-28 多线程并发之后的异步编程进阶篇
作为 Web 架构师,你在 JS/TS 里用 async/await 如同呼吸——它是语言内置的异步武器。可一旦转向 C++/Qt 桌面开发,你会发现主流异步手段仍是「信号槽 + 回调 + 事件循环」:一次串行 IO 往往要拆成三五个 lambda 层层嵌套,业务逻辑被切碎,调试时调用栈支离破碎,这正是你当年在 Node.js 里逃离过的「回调地狱」在 C++ 世界的翻版。C++20 协程让函数真正拥有「挂起而不阻塞线程」的能力,是大型客户端把复杂异步流(网络请求、任务编排、串行动画)写回线性代码的关键方向。但它的学习曲线不在关键字本身,而在于理解「无栈协程 = 编译器生成的状态机」这一本质——只有理解了状态机、堆帧、调度权归属,你才能避开悬垂引用与线程亲和性这些致命陷阱,也才能以架构师身份判断:这个项目到底该不该引入协程。
协程是「可以挂起并在之后恢复的函数」。挂起(suspend)不是返回——函数的状态(局部变量、执行位置、暂存表达式)被完整保留,线程被释放去做别的事,稍后从挂起点继续执行。按实现分两类:有栈协程(stackful)为每个协程分配独立调用栈,挂起时保存整个栈,如微信后台的 libco、Go 的 goroutine、Lua 协程;无栈协程(stackless)不保留调用栈,只把「这一个函数」的局部状态打包保存。C++20 采用无栈模型——这正是它与 JS async/await 在架构上最接近的地方。
当一个函数体内出现 co_await / co_return / co_yield 任一关键字,编译器就不再把它编译成普通函数,而是执行一次「闭包变换」(closure conversion),本质上和你熟悉的 Babel 把 async 函数转成 generator/状态机是同一件事:
co_await expr 都是切分点,切出的每一段代码变成一个状态分支,函数入口处维护一个隐藏的状态编号,恢复时按编号跳到对应分支继续执行——一个典型的有限状态机。promise_type(定义协程的结果、初始终止行为、异常处理)、awaiter(定义一次挂起的三阶段:await_ready 问「要不要挂起」→ await_suspend 挂起后干什么,如把恢复函数投递到某线程 → await_resume 恢复时把什么值传给 co_await 表达式)、以及三个关键字本身。写库的人实现这套协议,写业务的人只见到 co_await。JS 的 await 背后是引擎内建的事件循环与微任务队列,调度模型被固定;C++20 的 co_await 背后什么都没有——调度器完全由 awaiter 的作者决定:可以在当前线程立即恢复,可以投递到线程池,也可以用 QMetaObject::invokeMethod 投回 GUI 线程。这是 C++ 协程最强大也最危险之处:协程本身既不开线程也不绑定线程,它是「可暂停的计算」,放在哪个线程跑完全取决于你的调度代码。
一次无栈协程挂起/恢复的成本是:保存若干寄存器 + 更新帧内状态编号 + 一次间接跳转,量级约在几十纳秒(编译器未内联时),远低于线程切换的微秒级,更远低于创建线程。代价是:每个协程帧要一次堆分配(可用自定义分配器优化)、代码体积膨胀(每个挂起点生成状态分支)、以及——最大的隐性成本——调试复杂度与心智负担。另外请注意工程现实:C++20 标准库只提供了 std::suspend_always 这类原语,没有 task、没有 generator、没有调度器(std::generator 到 C++23 才进标准);生产代码需要 cppcoro、folly 或自研的极简 task 封装。这决定了「用协程」在 C++ 里首先是库选型决策,其次才是语言特性。
你在 Web 端写的每个 async 函数,V8 都会在编译期做一次改写:函数体按每个 await 切段,成为挂在 Promise 链上的状态机,局部变量被捕获进闭包对象。这与 C++20 协程的帧提升是同一思想,差异只在「谁写状态机」——JS 引擎替你写,C++ 编译器替你写。所以对你而言,协程不是新概念,只是把 V8 藏在黑盒里的东西掀开给你看:await 之后的代码就是回调的语法糖,这一心智模型在 C++ 中一字不差地成立。
腾讯开源的 libco 是典型有栈协程:为每个协程预分配独立栈(默认 128KB 量级),挂起时整栈保存。微信后台早期用「一个连接一个线程」扛不住十万级并发,改造为「少量线程 + 海量有栈协程」后单机并发能力提升一个量级,业务代码却几乎保持同步写法。它说明当「深调用栈 + 存量同步代码」是主矛盾时,有栈协程侵入性最小;代价是每协程 KB~MB 级栈内存、切换需拷贝/换栈,无法支撑「百万协程」的极端规模——而 C++20 无栈协程帧仅存活的局部变量,几十字节到几百字节,数量级上更适合高并发。
Chromium 的 IPC 与网络层长期是「Mojo 回调链 + BindOnce/RepeatingCallback」的天下:一个下载流程横跨 IO 线程、网络服务进程、UI 线程,代码被拆成十几个回调,可读性与生命周期管理都极差。正因如此,Chromium 社区这些年持续推动在 Mojo/网络层试点 C++20 协程,把「等待 IPC 响应」写成线性 co_await——动机与你逃离回调地狱完全一致。它的谨慎推进速度本身就是教训:存量代码库引入协程的最大成本不是语法,而是「回调边界」与「协程边界」两种风格长期共存。
Qt6 官方至今未提供协程模块,异步高层 API 是 QFuture + QtConcurrent + QPromise(配合 QtConcurrent::run 与 future 的 then 链)。社区做法是自写一个小 awaiter:在 await_suspend 里用 QMetaObject::invokeMethod 把恢复动作投递回 GUI 线程,从而让「协程恢复必然发生在主线程」成为编译期后依然成立的约定。JetBrains 系产品则是 Kotlin 协程的重度用户,其 Dispatchers.Main 解决的问题与 Qt 完全同构:协程挂起后可能跑在线程池,恢复时如何安全跳回 UI 线程并保证「UI 对象只在主线程被触碰」。理解这三家的设计,你就理解了协程落地的通用骨架:协程(可暂停的计算)+ 调度器(线程亲和约定)+ 生命周期所有权(取消与析构)。
std::jthread 或 QtConcurrent::run,二者组合而非互替。std::coroutine_handle 封装进 RAII 类(析构里 destroy),禁用裸句柄外传,就像你永远不会手动 delete 一个 unique_ptr 指向的对象。你不需要重新学习协程,只需要做一张映射表,把 V8 黑盒里发生的事情翻译成 C++ 的显式协议:
| 维度 | JS async/await | C++20 协程 |
|---|---|---|
| 状态机谁生成 | V8 引擎编译期改写 | C++ 编译器(闭包变换) |
| 局部变量存放 | 闭包对象(GC 管理) | 协程帧(堆,RAII 管理) |
| 调度器 | 内建事件循环+微任务队列,唯一 | 语言不提供,由库/awaiter 决定 |
| 结果载体 | Promise | promise_type + task 封装 |
| 挂起原语 | await 任意 Promise | co_await 任意 awaiter |
| 线程模型 | 单线程 + 事件循环(worker 另算) | 任意线程,亲和性由调度代码保证 |
| 取消 | AbortSignal | 无标准,自建 token |
迁移要点:第一,「await 之后是回调的语法糖」这条你已经内化的直觉,在 C++ 里是字面真理——每个挂起点后的代码段就是一段回调。第二,你在 Node 里用 Promise.all 编排并发、用 AbortController 取消,对应到 C++ 是 folly::coro::collectAll / cppcoro 的 when_all 与自建取消——概念相同,只是 C++ 生态碎片化,需要你先选定一个库。第三,浏览器/Node 的「主线程不可阻塞」纪律与 Qt 的「GUI 线程不可阻塞」完全同源,你过去用 async 保护浏览器主线程的习惯,原样迁移到 Qt 主线程即可;差别在于浏览器替你保证了 await 之后一定回到同一个线程,而 C++ 里这需要你的调度封装来保证——这是唯一必须补的课。
作为 TL,你不需要亲手写复杂 awaiter,但要在三个层面把关:第一,规范先行:在团队 wiki 里固化「协程使用契约」——只能在封装层看到 coroutine_handle、恢复线程必须可预期、跨挂起点禁止裸引用、析构即取消。Code Review 时按此清单检查,而不是凭感觉。前端团队最容易犯的错是把 JS 里「await 之后还在原线程」的直觉带进 C++ 评审,所以要在 review 里专门问一句:「这段恢复发生在哪个线程?谁保证的?」第二,控制引入节奏:先让 1-2 名资深成员在 IO/网络边界试点一个模块,沉淀出项目自己的 task 封装与调度约定,再推广;切忌全团队同时铺开,否则错误模式会在每个模块里各自发明一遍。第三,把协程当作培养抽象能力的教材:让组员对比「信号槽链 vs 协程」实现同一需求的两种代码,讨论可读性、调试性与生命周期差异——这比讲十遍设计模式更能建立架构判断力。人员培养上,新人先补「状态机与事件循环」心智,再碰协程,顺序不要反。
用纯 C++20(无需 Qt)实现一个可手动驱动的极简协程:不要用任何现成库,亲手完成 promise_type、initial/final_suspend、RAII 句柄封装,然后观察「函数体被挂起点分段执行」。保存为 coro_demo.cpp,用 g++ -std=c++20 -O0 coro_demo.cpp -o coro_demo 编译(需 GCC 11+ 或 Clang 14+),运行并对照输出理解状态机。
#include <coroutine>
#include <cstdio>
class Counter {
public:
struct promise_type {
Counter get_return_object() {
return Counter{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; } // 创建后先不执行
std::suspend_always final_suspend() noexcept { return {}; } // 结束前再挂一次
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
explicit Counter(std::coroutine_handle<promise_type> h) : m_h(h) {}
~Counter() { if (m_h) { m_h.destroy(); m_h = nullptr; } } // RAII:帧随对象销毁
Counter(const Counter&) = delete;
Counter& operator=(const Counter&) = delete;
bool resume() { // 前进到下一个挂起点;完成则返回 false
if (!m_h || m_h.done()) return false;
m_h.resume();
return !m_h.done();
}
private:
std::coroutine_handle<promise_type> m_h; // 裸句柄禁止外泄
};
Counter counter() { // 协程函数:编译器会把它改写成状态机
int step = 0; // 局部变量被搬到堆上的协程帧
while (step < 3) {
std::printf("协程段 %d 执行中(step=%d)\n", step + 1, step);
++step;
co_await std::suspend_always{}; // 挂起点:函数体在此被切分
}
}
int main() {
Counter c = counter(); // 此刻函数体一行都没跑(initial_suspend)
int round = 0;
while (c.resume()) // 手动驱动:每轮只执行「一段」
std::printf(" -- 主流程第 %d 轮 resume 返回,控制权回到 main\n", ++round);
std::printf("协程走完,main 结束时析构函数销毁协程帧\n");
}
预期输出:交替打印「协程段 N 执行中」与「主流程第 N 轮 resume 返回」,共 3 段——直接证明协程体不是一次跑完,而是被 co_await 切成了状态机片段。扩展挑战(完成基础版后再做):把 std::suspend_always{} 换成自写 awaiter,在其 await_suspend 中打印当前线程 id,并把 resume 挪到另一个 std::jthread 中调用,观察协程跨线程恢复的行为——这正是 Qt 中「协程跑在线程池、恢复回主线程」问题的雏形。