C++20 协程 × 屏障:手写一个 co_await 版 Barrier,让等待不再钉死线程

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 密集用协程屏障,两者都在工具箱里,才谈得上为场景做取舍。

把「阻塞线程」换成「挂起任务」,是并发原语协程化最值得的一步。

相关推荐
山东孙燕姿5 小时前
设备台账重构:从一张表到三层模型
android·java·网络·低代码·重构·rxjava
蒸鱼Yuzheng6 小时前
移动端 CPU 调度分析:线程亲和性、大小核迁移与卡顿
android·perfetto·性能分析·线程优化·cpu调度
booboosui6 小时前
安卓唤醒词:12 维 MFCC 加 DTW,阈值 1.8
android
传奇开心果6 小时前
Compose Multiplatform 共享边界实战:架构契约、互操作同步与可回退设计
android·kotlin
事圆则缓6 小时前
从开机到首帧:Android 系统与应用启动流程源码分析
android
AI吃大瓜7 小时前
指针压力表读数4:Android实现指针压力表读数(可实时检测)
android·指针读数·指针压力表·压力表·压力表读数
fengci.7 小时前
CTF2刷题记录(困难部分)
android·开发语言·前端·学习·php
hunterandroid7 小时前
[Android 从零到一] ARouter 路由框架原理:APT 生成路由表与组件化通信
android
最好一梦8 小时前
佳维视安卓工控机在堆垛机中的应用
android