多个工作线程完成各自初始化后,如何在主线程继续之前精确等待这些一次性事件?反过来,如果一组线程要反复执行三个阶段,每次都必须全体到齐才能进入下一阶段,又该用什么机制?C++20 标准库给出的答案是 std::latch 与 std::barrier。这两个同步原语都用来协调线程到达点,但生命周期和计数行为截然不同:latch 像一道一次性的门闩,barrier 则像多轮关卡。本文从 API 用法、代码实例、选型对比和常见陷阱几个角度展开,帮助你在多线程协作中选对工具。

std::latch:一次性倒计时门闩
std::latch 定义在头文件 <latch> 中。它的核心是一个可倒计时但不能增加的计数器。构造时需要给出初始计数,典型值是参与并发任务的线程数量。线程完成自己的工作后调用 count_down 将计数减一;调用 wait 的线程会一直阻塞,直到计数变为零。计数到达零后,所有等待中的线程同时被唤醒,并且 latch 不会回到非零状态,所以它天然只适合表达一次性的事件集合。
这个设计解决了一个非常常见的场景:主线程需要等若干个子线程全部就绪后才统一放行,或者所有子线程需要等主线程完成初始化后再开始执行。放行与收尾可以使用同一个 latch,也可以分别使用两个 latch。比如启动阶段主线程持有一个 start_latch,子线程执行到读取共享配置前先 wait;主线程完成配置加载后 count_down,所有子线程就同时醒来。反过来,主线程等所有子任务结束后继续,可以用一个 done_latch,子线程任务末尾执行 count_down,主线程调用 wait。
下面是一个两个 latch 配合的启动门示例:
#include <latch>
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
int main() {
const int n = 4;
std::latch ready(1); // 启动门,由主线程打开
std::latch done(n); // 收尾门,等待 n 个子线程
std::vector<std::thread> workers;
for (int i = 0; i < n; ++i) {
workers.emplace_back([i, &ready, &done] {
ready.wait(); // 等待主线程信号
std::cout << "worker " << i << " running\n";
done.count_down(); // 报告自己完成
});
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
ready.count_down(); // 打开启动门
done.wait(); // 主线程等待全部完成
std::cout << "all workers done\n";
for (auto& t : workers) {
t.join();
}
return 0;
}
latch 还提供 arrive_and_wait,它等价于先执行一次 count_down(1) 再调用 wait。当某个线程既是任务的执行者又是等待者时,这个接口可以减少漏写 count_down 或 wait 的概率。此外还有 try_wait,它会立即返回计数是否已经到零,不会阻塞,适合轮询或不能无条件阻塞的线程。
需要注意的是,latch 的计数不允许小于零。如果在计数已经为零后继续调用 count_down,行为是未定义的,标准不保证能安全地恢复或报错。所以使用 latch 时,团队成员数量和 count_down 次数必须严格匹配。对于需要多次汇聚的场景,应该切换到 std::barrier。
std::barrier:多轮同步与完成函数
std::barrier 定义在头文件 <barrier> 中。和 latch 一样,它内部维护一个预期到达数,但 barrier 在一次同步完成后会自动恢复到初始计数,从而允许同一组线程反复使用。创建 barrier 时通常传入线程数量,以及一个可选的完成函数。这个完成函数在每一轮所有线程都到达后执行,并且只由其中一个到达线程调用。完成函数执行完成后,阻塞中的线程才会被放行进入下一阶段。
barrier 最关键的接口是 arrive_and_wait。它把“我到了”和“等其他人”组合在一起:当前线程将内部计数减一,然后阻塞,直到本轮所有参与者都完成同样的调用。参与线程数量固定时,每一轮调用都会把计数从预期值减到零,再恢复为预期值。对于需要动态退出某个线程的长期任务,可以使用 arrive_and_drop。它会让当前线程离开屏障,并永久减少后续轮次的预期到达数,这样其余线程不必再等待已经离开的参与者。
下面是一个三阶段流水线示例,四个线程在每个阶段同步一次,阶段完成后由完成函数输出提示:
#include <barrier>
#include <iostream>
#include <thread>
#include <vector>
#include <string>
int main() {
const int n = 4;
std::barrier phase_sync(n, [] {
std::cout << "phase complete\n";
});
std::vector<std::thread> workers;
for (int i = 0; i < n; ++i) {
workers.emplace_back([i, &phase_sync] {
for (int phase = 1; phase <= 3; ++phase) {
std::cout << "thread " << i
<< " processing phase " << phase << "\n";
phase_sync.arrive_and_wait(); // 每阶段末对齐
}
});
}
for (auto& t : workers) {
t.join();
}
return 0;
}
这段代码的输出顺序会因调度而变化,但可以确定的是:所有线程都会先完成第一阶段输出,然后完成函数打印 phase complete,接着才会进入第二阶段。barrier 把并行的执行流切成明显的阶段边界,非常适合图像处理、物理模拟、并行排序中“计算、交换、再计算”的迭代模式。
完成函数有一个重要约束:它不能抛出异常。如果完成函数抛出异常,程序会调用 std::terminate。完成函数也不应该做的事情包括:再次调用同一个 barrier 的等待接口,这几乎必然导致死锁;修改所有参与线程都依赖的数据时,要清楚它运行在未知线程上,必须自行保证可见性和互斥。通常完成函数只做汇总、递增轮次、输出进度等轻量操作。
如何选择:latch 与 barrier 的边界
从生命周期上看,latch 是单向的,barrier 是循环的。如果问题描述中存在“只等一次”或“只发一次开始信号”,优先 latch;如果问题描述里有“每一轮”“每个阶段”“下一轮迭代”这类词,优先 barrier。这个判断标准虽然简单,但能过滤掉大部分错误设计。
从参与者角色看,latch 更灵活一些。任意线程都可以调用 count_down,也可以只调用 wait 而不参与倒计数,因此非常适合主线程等待多个工作线程,或者工作线程等待主线程初始化。barrier 的典型用法是所有参与者都执行 arrive_and_wait,主线程如果要等待所有阶段完成,通常也要以参与者身份加入,或者另用其他机制观察阶段完成状态。如果让一个非参与线程去等待 barrier,而所有参与线程都在等它,就很容易形成互相等待。
再考虑计数变化。latch 的计数从初始值单调递减到零后结束,中途虽然可以一次减少多个计数,但不能增加。barrier 在每轮结束自动恢复,还可以用 arrive_and_drop 减少下一轮的预期线程数,这给动态线程池带来了便利。比如某个线程发现自己负责的数据已经处理完毕,就可以 arrive_and_drop 离开,其他线程不需要修改整个屏障对象。
将两者差异归纳如下:
| 特性 | std::latch | std::barrier |
|---|---|---|
| 复用能力 | 一次性 | 多轮可重用 |
| 计数变化 | 只能减少 | 每轮恢复,可动态减少预期数 |
| 完成函数 | 不支持 | 每轮结束执行一次 |
| 等待接口 | wait / arrive_and_wait | arrive_and_wait |
| 典型场景 | 启动门、一次性收尾 | 分阶段并行、迭代计算 |
表格显示了边界,但实际选择还要结合异常安全和可测试性。latch 语义简单,测试起来直观;barrier 的完成函数和动态计数增加了一些复杂度,如果团队中有成员不理解完成函数在哪个线程执行,可能写出有数据竞争或死锁的代码。因此,能用 latch 解决的场景,不必强行使用 barrier。
常见误区和实现细节
第一种误区是把 latch 当 barrier 用。有人会写出 for 循环里反复等待同一个 latch 的代码,但第一轮计数到零后 latch 已经失效,后续等待会立即返回,完全起不到阶段对齐的作用。第二种误区是忽略完成函数不能抛异常。C++ 标准明确要求 completion function 不抛出,即使内部捕获了所有异常,完成函数之外同步状态也依赖它正常返回。
第三种误区是假设完成函数运行在主线程。完成函数由最后一个到达的参与线程执行,这个线程可能是任意一个工作线程。只有这样才能保证在所有参与者都到达后立即执行阶段边界操作。如果完成函数里需要更新 UI 或访问某个线程专属资源,应该在完成函数中设置标志,再由对应线程检查。
第四种误区涉及 arrive_and_drop。它减少的是后续轮次的预期到达数,而不是当前轮次。当前线程必须仍然完成本轮需要做的工作,再调用 arrive_and_drop 表示本轮到达并离开。另一个线程如果在同一轮中反复调用 barrier,可能造成计数错误;标准要求每个线程每轮只能 arrive 一次。
从内存序角度看,barrier 和 latch 都提供了足够的同步关系。一个线程在 count_down 或 arrive_and_wait 之前写入的数据,在其他线程成功等待返回后可见。具体而言,到达操作带有 release 语义,等待返回带有 acquire 语义。因此不必为了共享数据的可见性再手动添加额外的原子变量或互斥量。但如果完成函数要修改共享数据,仍然需要保证同一时刻只有一个线程执行完成函数,而这正是标准已经提供的保证。
在实践中,如果线程数量很多且阶段很短,barrier 的完成函数可能成为串行瓶颈。例如完成函数里进行复杂的日志输出、全局统计、内存分配等,会拖慢每一轮阶段切换。这时应该尽量把重操作移出完成函数,改成由某个参与线程在阶段内部执行,或者使用无锁结构保存统计数据。latch 没有完成函数,通常开销更低,一次唤醒大量线程时更直接。
还有一点容易忽略:两个原语都不处理线程创建和销毁,它们只负责到达点同步。线程仍然需要由调用方通过 std::thread、线程池或其他执行器来管理。不要期望 barrier 或 latch 会主动结束线程,它们只是协调既有执行流。
C++20std::barrierstd::latch修改时间:2026-10-04 01:48:48