资讯动态

浅谈c++如何实现并发中的Barrier

发布时间:2026/10/22 13:20:56 来源:尧图企业网站定制
前言并行计算里经常出现这样的结构所有线程先完成第一阶段等大家都到齐再一起进入第二阶段。典型例子是多轮迭代的数值计算、分阶段流水线、以及「先各自写入缓冲区、再统一读取结果」的批处理。如果只靠互斥量线程之间无法表达「等满 N 个再放行」这层语义如果靠轮询计数器又会浪费 CPU 且难以保证可见性。屏障barrier就是为这个语义设计的同步原语它有一个参与计数每个线程到达后等待直到第 N 个线程到达所有等待者同时被释放屏障随即复位可以进入下一轮。它与「一次性事件」不同屏障是可复用的。常见的误解有两个。第一把屏障和条件变量直接画等号条件变量只是实现屏障的工具之一单独使用并不能保证「整批放行」还需要代际计数来区分不同轮次。第二以为屏障能保证线程之间的执行顺序。屏障只保证「所有线程都已经到达某个点」不保证谁先谁后也不保证被释放后谁先运行。需要说明标准版本std::barrier和std::latch是C20才加入标准库的组件头文件barrier、latch。本文先讲 C20 的现成工具再给出 C17 下手写可复用屏障的完整实现——后者在旧工具链上是必需的能力。一、屏障的语义计数、代际与放行一个正确的屏障要同时满足三件事计数记录当前这一轮已经到达的线程数满额即触发。代际区分「上一轮的放行」和「这一轮的等待」。没有代际线程从等待中醒来后可能误判自己还在旧轮次或者新一轮的到达被旧一轮的唤醒吞掉。复用一轮结束后计数复位屏障回到可用状态而不是像一次性事件那样永久打开。用条件变量实现时代际通常是一个单调递增的整数。每个线程进入时先记下当前代际号然后把自己的到达计数减一如果减到零说明自己是最后一个就把代际加一、计数复位并唤醒所有等待者否则在条件变量上等待「代际号发生变化」。用「代际号变了」而不是「被唤醒过」作为谓词可以天然过滤虚假唤醒spurious wakeup——条件变量的等待本来就可能无缘无故返回。这里有一个必须讲清的标准细节条件变量的谓词在持有锁时求值wait(lock, pred)等价于「循环检查 pred为假就等待」。所以代际号的读取和比较都在锁保护下完成不会出现数据竞争。反过来如果自己写while (!flag) cv.wait(lock);同样要保证flag的读写都在锁内。屏障还涉及内存可见性。以 C20 的std::barrier为例标准规定一个线程对arrive的调用与另一个线程从对应的wait返回之间具有同步关系synchronizes-with。这意味着最后一个线程在到达之前写入的数据对其他线程在放行之后是可见的。手写实现时互斥量的加锁解锁已经提供了同样的保证不需要额外加std::atomic或内存屏障。二、C20 的 std::barrier 与 std::latchC20 提供了两个容易混淆的组件先看区别特性std::barrierstd::latch复用可重复使用一轮接一轮一次性计数到零后永久打开计数调整支持arrive_and_drop()减少后续参与者不支持减少参与者完成回调构造时可传入完成函数每轮执行一次没有典型用途多阶段并行算法的阶段同步启动等待、任务完成汇合头文件barrierlatchstd::barrier的模板参数是完成函数的类型构造时传入参与线程数和完成函数。完成函数由「最后一个到达的线程」在放行其他人之前执行适合做阶段切换时的汇总工作例如交换双缓冲、递增轮次编号。标准要求完成函数不抛异常不可抛出否则会调用std::terminate因此实践中应写成noexcept。基本用法如下// 需要 C20#include barrier#include iostream#include thread#include vectorint main() {constexpr int kWorkers 4;int phase 0;// 完成函数在每轮最后一个线程到达时执行然后才放行其他线程std::barrier sync_point(kWorkers, [phase]() noexcept {phase;std::cout phase phase completed\n;});std::vectorstd::thread pool;for (int i 0; i kWorkers; i) {pool.emplace_back([sync_point, i]() {for (int round 0; round 3; round) {// 第一阶段各自计算int sink i * round;(void)sink;// 等待所有线程完成第一阶段sync_point.arrive_and_wait();// 第二阶段读取其他人的结果}});}for (auto t : pool) {t.join();}return 0;}arrive_and_wait()的语义是「到达并等待」返回时本轮已经结束、屏障已复位。若某个线程要中途退出、不再参与后续轮次应调用arrive_and_drop()它同样到达但会把后续轮次的期望计数减一避免剩下的线程永远等不到满额。这一点在多阶段任务中动态减少工作线程时非常重要。std::latch更简单构造时给定计数count_down()减一wait()阻塞到零arrive_and_wait()等价于先减一再等待。它没有「轮次」概念归零后不能复位。// 需要 C20#include latch#include thread#include vectorint main() {std::latch start_signal(1); // 发令枪std::latch done_signal(4); // 汇合点std::vectorstd::thread pool;for (int i 0; i 4; i) {pool.emplace_back([]() {start_signal.wait(); // 等主线程发令// 执行任务done_signal.count_down();});}start_signal.count_down(); // 放行所有工作线程done_signal.wait(); // 等全部完成for (auto t : pool) {t.join();}return 0;}注意这个例子同时用到了「一次性」的两个方向start_signal是主线程通知多个线程开始done_signal是多个线程通知主线程结束。这类场景用 latch 比 barrier 更贴切因为不需要复用。三、C17 下手写可复用屏障没有 C20 时屏障要自己用互斥量和条件变量拼出来。核心就是前面说的「计数 代际」。下面这个实现支持任意数量的参与者并且在最后一轮到达时先复位再唤醒保证醒来的线程看到的是新一轮的状态。// 适用于 C17#include condition_variable#include cstddef#include mutex#include stdexceptclass Barrier {public:explicit Barrier(std::size_t expected): threshold_(expected), count_(expected), generation_(0) {if (expected 0) {throw std::invalid_argument(Barrier requires at least one participant);}}Barrier(const Barrier) delete;Barrier operator(const Barrier) delete;// 到达并等待本轮结束void arrive_and_wait() {std::unique_lockstd::mutex lock(mutex_);const std::size_t gen generation_;if (--count_ 0) {// 自己是最后一个复位计数、推进代际count_ threshold_;generation_;lock.unlock(); // 先解锁再唤醒减少唤醒后的争抢cv_.notify_all();return;}// 谓词在持锁状态下求值代际变化即代表本轮已放行cv_.wait(lock, [this, gen]() { return gen ! generation_; });}private:std::mutex mutex_;std::condition_variable cv_;const std::size_t threshold_;std::size_t count_;std::size_t generation_;};逐行推演一下关键路径。假设有 3 个线程。线程 A 先到count_从 3 变 2记下gen 0进入等待。线程 B 到达count_变 1等待。线程 C 到达count_变 0把自己的gen也记为 0进入最后一个分支把count_复位为 3generation_变 1解锁后notify_all。A 和 B 被唤醒重新加锁检查谓词0 ! 1成立返回。C 直接返回。此时屏障已处于新一轮的初始状态。真正关键的是先复位再唤醒如果先唤醒再复位醒来的线程可能在新一轮里看到还没复位的计数导致计数错乱。为什么最后一个线程要先unlock再notify_all这是性能考虑。持有锁时唤醒被唤醒的线程会立刻尝试加锁又失败形成「匆忙上车再等待」现象。解锁后通知唤醒者可以立即拿到锁。两种写法都正确。这个实现有一个限制没有完成函数也不能动态减少参与者。如果需要完成函数可以在最后一个分支里、复位之前调用一个std::functionvoid()成员但要像标准一样要求它不抛异常否则异常会从arrive_and_wait里穿出其他线程永远等不到放行。还需要注意异常安全cv_.wait可能抛std::system_error但此时线程已经把自己的到达计数减掉了如果异常继续传播这一轮就永远凑不齐。标准库的std::barrier没有这个问题因为它的到达操作本身不分配、不抛异常完成函数除外。手写版本应把wait的异常视为致命错误或者在设计上保证等待不会被中断。四、完整实例三阶段并行处理下面把屏障用在一个具体的三阶段任务里4 个工作线程每个线程在每一阶段处理自己负责的分片阶段之间用屏障同步最后一个阶段结束后主线程汇总。// 适用于 C17#include condition_variable#include cstddef#include iostream#include mutex#include stdexcept#include thread#include vectorclass Barrier {public:explicit Barrier(std::size_t expected): threshold_(expected), count_(expected), generation_(0) {if (expected 0) {throw std::invalid_argument(Barrier requires at least one participant);}}Barrier(const Barrier) delete;Barrier operator(const Barrier) delete;void arrive_and_wait() {std::unique_lockstd::mutex lock(mutex_);const std::size_t gen generation_;if (--count_ 0) {count_ threshold_;generation_;lock.unlock();cv_.notify_all();return;}cv_.wait(lock, [this, gen]() { return gen ! generation_; });}private:std::mutex mutex_;std::condition_variable cv_;const std::size_t threshold_;std::size_t count_;std::size_t generation_;};int main() {constexpr int kWorkers 4;constexpr int kData 16;std::vectorint data(kData);for (int i 0; i kData; i) {data[i] i;}Barrier barrier(kWorkers);std::mutex print_mutex;auto worker [](int id) {const int begin id * (kData / kWorkers);const int end begin (kData / kWorkers);// 阶段一各自把自己的分片加一for (int i begin; i end; i) {data[i] 1;}barrier.arrive_and_wait();// 阶段二汇总本线程的分片此时阶段一的写入已全部可见int local_sum 0;for (int i begin; i end; i) {local_sum data[i];}{std::lock_guardstd::mutex guard(print_mutex);std::cout worker id phase2 sum local_sum \n;}barrier.arrive_and_wait();// 阶段三所有线程都已完成阶段二if (id 0) {long long total 0;for (int v : data) {total v;}std::lock_guardstd::mutex guard(print_mutex);std::cout total total \n;}barrier.arrive_and_wait();};std::vectorstd::thread pool;for (int i 0; i kWorkers; i) {pool.emplace_back(worker, i);}for (auto t : pool) {t.join();}return 0;}这段代码的同步逻辑值得说明三点。第一data的每个分片只被一个线程写分片之间没有重叠所以阶段一内部不需要额外加锁屏障负责的是「阶段一全部写完」这件事而不是保护单个元素。第二print_mutex与屏障是两个独立的同步工具前者保护std::cout不被字符级交错后者负责阶段划分二者不能互相替代。第三阶段三只有id 0的线程读取全局数据但它仍然要调用最后一次arrive_and_wait()否则其他线程会在第三次等待处永远阻塞——屏障要求每个参与者都到达。如果某个线程提前退出而不再调用屏障必须使用支持「减少参与者」的机制C20 的arrive_and_drop或手写版本里增加一个原子的期望计数。否则剩下的线程会一直等一个永远不会到达的参与者这是屏障类代码最常见的死锁原因。常见坑点❌ 用一个布尔标志加条件变量实现屏障不区分轮次。✅ 用单调递增的代际号做谓词避免上一轮的唤醒影响下一轮。❌ 谓词写成while (!released) cv.wait(lock);而released从不清零。✅ 每轮复位状态或改用代际号比较保证每一轮都有独立的判定条件。❌ 最后一个线程先notify_all再复位计数。✅ 先复位计数和代际再解锁、再通知保证被唤醒者看到的是新一轮状态。❌ 屏障参与者数量与实际线程数不一致。✅ 构造时用参与线程数初始化线程增减时同步调整退出线程用arrive_and_drop。❌ 某个线程在屏障之后抛出异常直接退出其余线程仍在等待。✅ 保证每个参与者要么到达屏障要么通过「减少参与者」的机制退出异常路径也要处理。❌ 把std::latch当屏障重复使用。✅ latch 是一次性的归零后不能复位多轮同步用std::barrier或手写可复用屏障。❌ 认为屏障能保证放行后的执行顺序。✅ 屏障只保证所有线程都已到达被唤醒后的调度顺序不确定。❌ 在 C17 项目里直接写std::barrier并声称可移植。✅std::barrier需要 C20 及以上且各标准库实现的支持时间不同使用前确认工具链。总结需求推荐方案标准版本一次性等待 N 个任务完成std::latchC20多阶段、可复用的同步点std::barrierC20阶段结束需要汇总动作std::barrier的完成函数C20旧工具链互斥量 条件变量 代际号C11 起线程中途退出arrive_and_drop或动态期望计数视实现屏障的正确性集中在两点计数必须准确反映参与者数量代际必须让每一轮的等待互不干扰。做到这两点条件变量版本和标准库版本在语义上就是等价的。新项目若工具链支持 C20应优先使用std::barrier它的完成函数、arrive_and_drop和内存序保证都是手写版本容易漏掉的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑