为什么需要std::latch:一次性多线程同步场景
在日常的多线程开发中,经常遇到这样的需求:一个或多个线程需要等待一组线程全部完成某个阶段性工作后,才能继续往下执行。比如,主线程要等待多个工作线程加载配置、初始化资源,然后才能启动后续业务。C++20 之前,可以用 std::promise/future、condition_variable 或 atomic 自己造轮子,但要么代码繁琐,要么容易出错。std::latch 正是为这种“一次性同步”场景设计的轻量原语。
基本概念:计数器与阻塞等待
std::latch 是 C++20 引入的一次性同步原语,内部维护一个计数器的值。创建时指定初始计数,线程可以通过调用 count_down 减少计数,而其他线程可以通过调用 wait 阻塞等待,直到计数归零。latch 只能使用一次,一旦计数降到零,所有等待线程被释放,且 latch 不再可用。
理解这几个关键动作:创建时传入初始值(比如 N),每个完成任务的线程调用 count_down() 使得计数器减 1,需要等待的线程调用 wait() 阻塞。注意,count_down 和 wait 可以由不同线程调用,也可以由同一个线程先 count_down 再 wait,但后者较少见。还有一个 try_wait 方法,如果计数器已经为零则返回 true,否则返回 false,不阻塞。通常用于非阻塞轮询场景,但标准库并不保证一定及时返回,所以建议优先使用 wait。
典型使用场景:主线程等待子线程初始化完成
假设你有 N 个子线程各自负责一部分初始化工作,主线程需要等待所有子线程初始化完成后才能继续执行。这时可以创建一个初始计数为 N 的 latch,每个子线程完成初始化后调用 count_down,主线程调用 wait 阻塞,直到所有子线程完成。
下面是一个具体的实现骨架:
std::latch init_latch(N);
for (int i = 0; i < N; ++i) {
std::thread t([&init_latch, i]() {
// 执行初始化工作
do_init(i);
init_latch.count_down();
});
t.detach(); // 或者存入 vector 后续 join
}
init_latch.wait(); // 主线程阻塞,直到所有子线程调用了 count_down
// 所有初始化完成,继续执行
这里有个细节:子线程如果使用 detach,主线程的 wait 会正确等待,但建议把线程句柄存入 vector 并在 wait 后逐个 join,这样可以确保线程对象在退出前被完整回收。如果使用 join,注意 join 只能调用一次,且必须在 wait 之后(因为线程可能还没执行完 count_down 就已经 join 了,导致 wait 一直等待)。通常做法是 wait 在所有线程都启动后调用,而 join 在 wait 返回之后逐一执行,形成一个安全的屏障。
还有一个常见变体:多个线程都调用 wait 等待同一批工作完成。比如,多个消费者线程等待生产者线程把数据准备完毕。latch 可以同时阻塞任意数量的线程,所有阻塞在 wait 上的线程都会在计数归零时被唤醒。这比 condition_variable 的 notify_all 更简洁,不需要手动管理谓词和锁。
常见错误与风险:计数不匹配与未定义行为
使用 std::latch 时,一个常见的陷阱是线程调用了 count_down 的次数少于预期,导致计数器永远无法归零,其他线程永久阻塞。另一个极端是调用了比初始计数更多的 count_down,这会导致计数器提前变为负数,但标准规定不允许计数低于零,实际行为是未定义的,可能立即释放等待线程。
这两种错误在实际开发中并不少见。对于计数不足,常见原因是某个线程在执行 count_down 之前异常退出了,又没有在 catch 块中补偿计数。对于计数过多,通常发生在循环中误调用了多次 count_down,或者多个路径都执行了减操作。一个稳健的做法是:将 count_down 放在 RAII 封装或异常安全的代码中执行。比如使用一个 guard 对象,在其析构函数中调用 count_down,这样即使函数中途抛出异常,也能保证计数减少。标准库没有提供这样的封装,需要自己简单实现。
另外,latch 没有提供“当前计数”的查询接口,你无法在运行时判断是否还有线程未完成。因此,必须在设计层面确保所有线程的路径都覆盖了 count_down 调用,并且调用次数恰好等于初始计数。一种验证方式是在调试阶段加入日志,记录每个线程的 count_down 时机和总数,但生产环境中通常去掉这些日志。
与 std::barrier 的对比:生命周期决定选择
std::latch 与 std::barrier 虽然都用于线程同步,但关键区别在于生命周期:latch 是一次性的,计数归零后不可重用;而 barrier 可以在每次阶段完成后自动重置,适用于需要反复同步的迭代场景。如果只需要同步一次(如启动所有线程前),选择 latch 更简洁。
barrier 还支持 completion function,即所有线程到达屏障后可以执行一个回调,然后一起继续。而 latch 没有该功能。如果你的场景是反复进行“所有线程完成某阶段工作,然后一起进入下一阶段”,就应该使用 barrier。如果只是单次等待,latch 的 API 更简单,性能也稍好一些(因为不需要维持可重用的状态)。
如何判断?先问自己:这个同步点会执行多次吗?如果是,barrier;如果否,latch。另外,latch 允许等待者和工作者是同一组线程,也可以不同组;barrier 通常要求所有参与的线程都到达同一点。例如,主线程不参与工作只等待,用 latch;所有线程(包括主线程)各自完成工作后一起进入下一步,用 barrier。
使用建议:异常安全、计数设计与调试检查
在使用 std::latch 时,应确保每个线程在合适的时机调用 count_down,最好在 RAII 封装或异常安全的代码中执行,以避免因异常导致计数未减少而引发死锁。同时,注意 latch 的 wait 函数不返回状态,因此必须在外部确保所有线程已完成相应工作。
具体来说,可以设计一个简单的 RAII wrapper:
class LatchGuard {
std::latch& l;
public:
explicit LatchGuard(std::latch& latch) : l(latch) {}
~LatchGuard() { l.count_down(); }
// 禁止拷贝和移动
LatchGuard(const LatchGuard&) = delete;
LatchGuard& operator=(const LatchGuard&) = delete;
};
使用时,在线程函数入口创建 guard,这样无论是正常返回还是异常退出,析构函数都能确保计数减少。但这有一个前提:线程函数不能有多个出口或者提前返回而绕过 guard 的作用域。可以将整个线程函数体放入一个 try 块,并将 guard 定义在 try 块最前面。
另一个检查点是:latch 的 wait 是否可能被虚假唤醒?标准规定 wait 只有在计数降到零时才返回,不会虚假唤醒。所以可以放心使用。
最后,注意头文件:需要 #include <latch>,且必须使用 C++20 标准。编译时添加 -std=c++20(gcc/clang)或 /std:c++20(MSVC)。不同的编译器实现可能略有差异,但基本行为一致。如果你发现某个版本下 latch 的行为不符合预期,可以降级到 std::promise 或 atomic 做备用方案。但通常不需要。
验证方式:日志与压力测试
写完代码后,如何验证 latch 的正确性?首先,在测试中故意让某个线程不调用 count_down(比如注释掉),观察主线程是否一直阻塞(可以设置超时断言)。其次,让一个线程调用两次 count_down,观察是否出现异常或提前唤醒。由于未定义行为,这可能导致程序崩溃,所以应该在设计阶段就避免。最后,用 ThreadSanitizer 或 AddressSanitizer 检测数据竞争和死锁。latch 本身是线程安全的,但周围的数据访问需要额外保护。
总之,std::latch 是一个简洁同步工具,适用于一次性等待场景。正确使用它的关键是确保计数精确匹配,并用异常安全的手段保护 count_down 调用。如果你的场景需要多次同步,请改用 std::barrier。