屏障与闩:C++20中的std::barrier与std::latch
多线程同步这件事,说白了就是让一堆线程「对齐脚步」。
我早年做游戏服务器时,经常要处理一波线程同时到达某个点、再一起往下走的场景。那时候只能用条件变量加计数器,代码又臭又长,还容易出bug。C++20终于给了我们两个好东西——std::latch和std::barrier。它们就是专门干这个的。
先说说std::latch:一次性闸门
std::latch是个「一次性」的同步工具。你设定一个计数,多个线程各自干活,干完了就调用count_down()。当计数归零时,所有等待在wait()上的线程一起被释放。
嗯,这里要注意:latch只能用一次。计数归零后,它就永久处于「打开」状态了。
核心特点
- 一次性使用,不可重置
- 支持多个线程同时等待
- 计数可以一次减多(
count_down(n)) - 线程可以既做「减数者」又做「等待者」
我举个例子。假设你要加载游戏资源,主线程必须等所有资源加载完才能继续。用latch就特别自然:
#include <latch>
#include <thread>
#include <vector>
void load_resources() {
const int task_count = 4;
std::latch completion_latch(task_count);
std::vector<std::jthread> workers;
for (int i = 0; i < task_count; ++i) {
workers.emplace_back([&completion_latch, i] {
// 模拟加载资源
std::this_thread::sleep_for(std::chrono::milliseconds(100 * (i + 1)));
printf("Worker %d done\n", i);
completion_latch.count_down(); // 干完活就减一
});
}
// 主线程等待所有worker完成
completion_latch.wait();
printf("All resources loaded, game starts!\n");
}
你看,代码干净利落。没有条件变量,没有锁,没有复杂的标志位。这就是latch的价值。
再说std::barrier:可复用的多阶段同步
std::barrier比latch更灵活。它可以重复使用,适合「多阶段同步」的场景。
什么叫多阶段同步?你想想看,一个流水线:第一阶段所有线程处理完数据,然后一起进入第二阶段。第二阶段处理完,再一起进入第三阶段。每个阶段结束,所有线程都要「对齐」一下。
barrier就是干这个的。它每次到达计数点后,会执行一个「完成回调」,然后自动重置,进入下一轮。
核心特点
- 可重复使用,自动重置
- 支持完成回调(arrive_and_wait后执行)
- 每个线程可以提前离开(arrive_and_drop)
- 适合迭代计算、流水线处理
我曾经在做一个物理仿真引擎时,每个时间步需要分三步:碰撞检测、约束求解、位置更新。每步之间所有线程必须同步。用barrier简直完美:
#include <barrier>
#include <thread>
#include <vector>
void physics_simulation() {
const int num_threads = 4;
int phase = 0;
// 完成回调:每次所有线程到达屏障后执行
auto completion_fn = [&phase]() noexcept {
++phase;
printf("Phase %d complete, moving to next\n", phase);
};
std::barrier sync_point(num_threads, completion_fn);
std::vector<std::jthread> workers;
for (int t = 0; t < num_threads; ++t) {
workers.emplace_back([&sync_point, t] {
for (int step = 0; step < 3; ++step) {
// 阶段1:碰撞检测
printf("Thread %d: collision detection\n", t);
sync_point.arrive_and_wait();
// 阶段2:约束求解
printf("Thread %d: constraint solving\n", t);
sync_point.arrive_and_wait();
// 阶段3:位置更新
printf("Thread %d: position update\n", t);
sync_point.arrive_and_wait();
}
});
}
}
注意看,每个线程在每个阶段结束时调用arrive_and_wait()。当所有线程都到达后,barrier自动执行回调,然后所有线程继续往下走。干净、安全、可读性强。
latch vs barrier:怎么选?
| 特性 | std::latch | std::barrier |
|---|---|---|
| 可重用 | 否,一次性 | 是,自动重置 |
| 完成回调 | 无 | 有,每次到达后执行 |
| 线程数固定 | 计数可大于线程数 | 计数必须等于参与线程数 |
| 典型场景 | 一次性初始化、资源加载 | 迭代计算、多阶段流水线 |
| 提前退出 | 不支持 | 支持arrive_and_drop |
我个人习惯这样判断:如果只需要「等一次」,用latch。如果需要「反复对齐」,用barrier。
小技巧
latch的计数可以大于实际线程数。比如你有4个线程,但每个线程要处理2个任务,可以设计数为8,每个线程完成一个任务就count_down一次。barrier不行,barrier的计数必须等于参与线程数。
避坑指南:我踩过的几个坑
坑一:barrier的回调必须是noexcept
我曾经在回调里抛了个异常,程序直接崩了。标准规定barrier的完成回调必须是noexcept的,否则是未定义行为。所以回调里别做可能抛异常的事。
坑二:latch的count_down和wait可以分开调用
同一个线程可以先count_down()再wait(),这没问题。但如果你先wait()再count_down(),那就死锁了——因为wait()会阻塞直到计数归零,而计数还没减呢。
坑三:barrier的arrive_and_drop要慎用
arrive_and_drop()会让当前线程永久退出barrier的参与集。如果你不小心调多了,barrier的计数会变成0,然后所有等待的线程都被释放。我调试过一个bug,就是某个线程误调了arrive_and_drop,导致其他线程提前「越狱」了。
重要提醒
barrier的完成回调是在某个到达的线程上执行的,具体哪个线程不确定。不要在回调里做耗时操作,否则会拖慢所有线程。回调里只做轻量级的「阶段切换」逻辑。
多阶段同步的实战场景
我最近在做一个图像处理管线,每个线程处理图像的一块区域。处理流程分三步:滤波、边缘检测、特征提取。每步之间必须同步,因为下一步依赖上一步的全局结果。
用barrier实现起来非常直观:
void image_pipeline() {
const int num_workers = 8;
std::barrier stage_barrier(num_workers);
auto worker = [&stage_barrier](int id) {
// 阶段1:滤波
apply_filter(id);
stage_barrier.arrive_and_wait();
// 阶段2:边缘检测(依赖滤波后的完整图像)
edge_detection(id);
stage_barrier.arrive_and_wait();
// 阶段3:特征提取(依赖边缘检测结果)
extract_features(id);
stage_barrier.arrive_and_wait();
};
std::vector<std::jthread> threads;
for (int i = 0; i < num_workers; ++i)
threads.emplace_back(worker, i);
}
你看,每个阶段结束,所有线程在barrier处对齐。没有锁,没有条件变量,没有忙等待。代码读起来就像在描述「我们要做什么」,而不是「怎么同步」。这才是现代C++该有的样子。
性能考量
latch和barrier内部通常用原子操作和futex实现,性能比手写条件变量好得多。我做过简单测试:在64线程的机器上,barrier的同步开销大约在几百纳秒级别,而手写条件变量版本要几微秒。
不过要注意一点:barrier的完成回调是在临界区内执行的。如果回调里做了耗时操作,会延长所有线程的等待时间。所以回调要尽量轻量。
我的建议
能用latch和barrier的地方,就别自己造轮子了。标准库的实现经过了充分优化和测试,比你手写的条件变量+计数器方案要可靠得多。我早期项目里自己写的同步原语,后来重构时全换成了标准库版本,bug少了一大半。
多阶段同步的核心逻辑
下面这张图展示了barrier在多阶段同步中的工作流程。每个线程在阶段边界处对齐,然后一起进入下一阶段。
从图中可以清楚看到:每个线程独立执行阶段任务,然后在屏障处「对齐」。屏障自动执行回调,然后所有线程一起进入下一阶段。这就是多阶段同步的核心逻辑。
好了,关于latch和barrier就聊这么多。这两个工具虽然简单,但用好了能大幅简化多线程同步代码。我个人建议你多在实际项目中试试,特别是那些「等所有人到齐再继续」的场景。你会发现,代码变得好写多了,bug也少多了。