C++20 把 co_await 带进了标准库,但很多人对协程的期待还停在「替代回调」上。其实它真正的价值之一,是重写那些「等待型」并发原语------最典型的就是屏障(barrier)。
多阶段并行流水线里最难受的时刻,是每个阶段都要等最慢的那一个。传统 std::barrier::wait() 的代价是整条线程被钉死 :动辄 1~8 MB 的线程栈陪着他一起睡,线程数一上去,上下文切换和内存开销立刻把并发收益吃光。而协程挂起只是一块几十字节的堆上 frame,等待期间线程完全可以转身去跑别的任务。
常规做法不够用:std::latch 只能倒数一次、不能复位,std::barrier 只会阻塞线程而不是挂起任务。本文分享一套可落地的方案:手写 promise_type 与 awaiter 构造 co_await 版 Barrier + 对称传输一次性唤醒 + 与 std::barrier 的取舍对照表,全部代码可直接编译。
一、屏障语义:三种等待姿势的账要算清
先把「等」的姿势摆正:屏障语义在不同原语下的代价完全不同:
std::barrier:阻塞 OS 线程。语义最正,但每个等待者都要占住一条线程和它的栈。std::latch:单向倒数、一次性。等待者同样是被阻塞的线程,而且用完不能复位。- 协程 Barrier :挂起的是协程 frame,等待期间线程是空闲的,可被调度器拿去跑别的协程。
三者的账摆在一起看:
| 原语 | 等待姿态 | 适合场景 | 主要代价 |
|---|---|---|---|
std::barrier |
线程阻塞到全员到达 | 固定大小 CPU 线程池的阶段同步 | 线程栈占用 + 上下文切换 |
std::latch |
线程阻塞到计数归零 | 主线程等 N 个一次性任务就绪 | 不可复用、不可重置 |
| 协程 Barrier | co_await 挂起 frame |
异步 IO / 任务编排 | 需自行管理 frame 生命周期 |
核心结论: 线程数等于并发度时用 std::barrier 最省事;任务数远大于线程数时,协程屏障才是正确答案。

二、拆开 promise_type:协程给你的四个钩子
想自己写同步原语,必须先弄清协程的握手协议,关键点有四个:
promise_type:定义返回对象、挂起状态和异常出口,是协程与外界唯一的契约。initial_suspend返回suspend_always:协程体创建后停在第一行,等外部恢复,方便插入调度逻辑。await_suspend返回coroutine_handle:这就是对称传输,由编译器直接跳转到目标协程,省一次调度器往返。final_suspend返回suspend_always:协程跑完不自毁,frame 的destroy()由持有者负责,否则就是内存泄漏。
下面是最小可编译骨架,适配 C++20(GCC 10+ / Clang 14+ / MSVC 19.29+):
cpp
struct Task {
struct promise_type {
Task get_return_object() {
return Task{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() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
};
// 持有 Task 的一方负责调用 handle.destroy(),否则协程 frame 泄漏
四个钩子定生死:谁挂起、谁恢复、谁销毁 frame,边界必须一次说清。
三、co_await 版 Barrier:最后到的人负责放行
把屏障语义翻译成 await_suspend,只需抓住一个判定:
cpp
#include <coroutine>
#include <cstddef>
#include <mutex>
#include <vector>
struct Barrier {
explicit Barrier(std::ptrdiff_t n)
: threshold(n), count(n), generation(0) {}
struct Awaiter {
Barrier& bar;
std::size_t my_gen = 0;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
std::unique_lock lk(bar.mtx);
my_gen = bar.generation;
if (--bar.count == 0) { // 我是最后一个到的
bar.generation++; // 开启新一轮
bar.count = bar.threshold;
auto batch = std::move(bar.waiting);
bar.waiting.clear();
lk.unlock();
for (auto w : batch) w.resume(); // 唤醒同批等待者
return h; // 自己经对称传输继续
}
bar.waiting.push_back(h);
return std::noop_coroutine(); // 自己也挂起
}
void await_resume() const noexcept {}
};
Awaiter operator co_await() { return Awaiter{*this}; }
std::ptrdiff_t threshold, count;
std::size_t generation;
std::mutex mtx;
std::vector<std::coroutine_handle<>> waiting;
};
三个要点必须说透:
- 计数复位 :最后一个到达者把
count恢复成threshold,屏障天然可复用,std::latch做不到这一点。 generation代际:防止旧批次的唤醒逻辑误伤新批次,是并发屏障的经典防呆设计。noop_coroutine():明确告诉编译器「别再恢复我」,配合返回h的对称传输,避免栈上递归式唤醒。
核心结论: 等待者挂起的是 frame,唤醒者返回的是 handle,两者解耦就是协程屏障的全部秘密。

四、唤醒时机:丢唤醒与重入死锁的坑
协程版屏障最容易翻车的地方,都在「谁在什么时候 resume 谁」这条链上,先看排错表:
| 现象 | 根因 | 修法 |
|---|---|---|
| 程序卡死、无线程可运行 | 有人挂起却没被 resume | 用 noop_coroutine() 明确放弃自我恢复权 |
| 唤醒瞬间崩溃或死锁 | 持锁 resume 别人,被调方重入同一把锁 | 先摘下整批 handle,解锁后再逐个 resume |
| 屏障只过一轮就失效 | 忘记 generation 或计数未复位 |
每轮放行时同步 generation++ 与计数重置 |
正确顺序可以压缩成一条链路:
加锁 → 减计数 → 判定是否最后到达 → 摘下整批 handle → 解锁 → 逐个 resume
另外两个工程细节:std::coroutine_handle 是非拥有 的,resume 之前务必确认它还活着;批量唤醒用普通循环逐个 resume 即可,别在 await_suspend 里递归唤醒,否则等待链会压成调用栈。
核心结论: 所有 resume 都必须发生在锁外、且在 handle 被摘下之后。
五、放进流水线:屏障只摆在真正的汇合点
有了协程屏障,异步流水线的编排就变得直白,用法如下(prepare / consume 为业务协程):
cpp
Task stage(Barrier& bar, Worker& w) {
co_await w.prepare(); // 阶段一:各自异步准备,快慢不一
co_await bar; // 屏障:全员就绪才放行
co_await w.consume(); // 阶段二:统一消费,杜绝读到半成品
}
void run(WorkerPool& pool) {
Barrier bar(pool.size());
for (auto& w : pool) w.spawn(stage(bar, w)); // 全部同时起跑
pool.join();
}
落地时的三点提示:
- 屏障只放在真正的汇合点:例如「全部下载完才能一起入库」,别在每个 await 后面都插一个。
- 参与者数量必须与实际协程数一致,少一个都会让整批永久挂起。
- frame 回收 :在
Task析构或join时统一handle.destroy(),把生命周期收敛到一处。
核心结论: 屏障负责对齐进度,协程负责不占用线程,两者合起来才是现代 C++ 的流水线写法。
六、取舍清单:什么时候别用协程屏障
选型先别急着上协程,把三个判断条件列清楚:
- CPU 密集 + 固定线程池 :直接
std::barrier,等待者本来就是满负荷的线程,协程化收益为零。 - IO 密集 + 任务数远超线程数:协程屏障,等待期间线程可以继续派发新任务。
- C++23 环境 :配合
std::expected与结构化并发思路,自己造屏障的机会更少。 - 跨库集成:若已有 Asio / libuv,优先用它们的 awaitable 与既有调度器配合,别引入第二套恢复逻辑。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 固定 N 线程并行计算 | std::barrier |
语义标准、零自定义代码 |
| 万级异步 IO 任务分批 | 协程 Barrier | 等待不占线程、内存占用低 |
| 需要超时或取消的等待 | std::barrier + 超时包装 |
协程版需自行实现取消传播 |
核心结论: 协程屏障换来的不是「更快」,而是「等待不占用线程」这一条本质优势。

结语
std::barrier 解决的是「谁到齐了才走」的语义问题,但它默认等待者是线程。当并发模型从线程切到协程,等待就应该降到 frame 粒度:几十字节的堆对象代替几 MB 的线程栈,线程在等待期重新变得可用。手写一遍 promise_type 与 awaiter,也就把 co_await 的握手协议彻底弄懂了------谁挂起、谁恢复、谁销毁,边界清清楚楚。
真正的工程价值在于选型判断:CPU 密集用标准库,IO 密集用协程屏障,两者都在工具箱里,才谈得上为场景做取舍。
把「阻塞线程」换成「挂起任务」,是并发原语协程化最值得的一步。