17. 信号量与屏障:std::counting_semaphore的使用、std::barrier与std::latch、线程同步的另一种思路
说到线程同步,大家第一反应肯定是互斥锁、条件变量这些老朋友。但说实话,我在项目里待久了,发现有些场景用锁和条件变量反而别扭。比如控制并发数量、等待一组线程全部就绪——这些活儿,用信号量和屏障来做,代码会清爽很多。
这一章我们就聊聊C++20带来的三个新工具:std::counting_semaphore、std::barrier 和 std::latch。它们不是要替代锁,而是提供另一种思路——更轻量、更贴合特定场景的同步方式。
17.1 std::counting_semaphore:控制并发度的利器
信号量这个概念其实很老了,Dijkstra老爷子在1965年就提出来了。C++20终于把它纳入了标准库,我个人觉得这是补了一个大坑。
std::counting_semaphore 维护一个内部计数器,表示可用资源的数量。线程可以“获取”(acquire)一个资源,计数器减1;也可以“释放”(release)一个资源,计数器加1。如果计数器为0,acquire就会阻塞。
看个最简单的例子:限制同时访问数据库连接的线程数。
#include <semaphore>
#include <thread>
#include <vector>
#include <iostream>
std::counting_semaphore<3> db_sem(3); // 最多3个并发连接
void query_database(int id) {
db_sem.acquire(); // 获取一个连接槽位
// 模拟数据库查询
std::cout << "线程 " << id << " 正在查询...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "线程 " << id << " 查询完成\n";
db_sem.release(); // 释放连接槽位
}
int main() {
std::vector<std::jthread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(query_database, i);
}
return 0;
}
这里有个细节:模板参数 3 是信号量的最大计数值。为什么要有这个上限?因为信号量内部用无符号整数,你得告诉它最大值是多少,防止溢出。我刚开始用的时候忘了设这个参数,编译器报错才反应过来。
std::binary_semaphore,它是 std::counting_semaphore<1> 的别名。我在实现线程池的暂停/恢复功能时就用过它。
17.2 信号量的两种典型用法
信号量有两种常见的使用模式,我分别说一下。
17.2.1 资源池模式
就像上面的数据库连接例子,信号量充当一个“令牌分发器”。线程拿到令牌才能干活,干完活归还令牌。这种模式的好处是——你不用显式管理队列,信号量自己会排队。
我曾经在一个网络爬虫项目里用过这个模式。要控制同时发起的HTTP请求数,但又不想引入复杂的连接池。一个信号量搞定,代码量少了一半。
17.2.2 一次性通知模式
把信号量初始值设为0,一个线程调用 acquire() 阻塞等待,另一个线程调用 release() 唤醒它。这其实就是一个轻量级的“事件通知”。
std::counting_semaphore<1> event(0);
void waiter() {
event.acquire(); // 等待事件
std::cout << "收到通知,开始干活\n";
}
void notifier() {
std::this_thread::sleep_for(std::chrono::seconds(2));
event.release(); // 发送通知
std::cout << "通知已发送\n";
}
比起条件变量,信号量做通知的好处是:不需要互斥锁配合,不会出现“虚假唤醒”的问题。当然,它只能传递一个“信号”,不能传递数据。
acquire() 和 release() 可以在不同线程调用,不需要配对。这是信号量和互斥锁最大的区别。但也正因为如此,你更容易写出“释放次数多于获取次数”的bug。我曾经调试过一个bug,就是某个分支多调了一次release,导致信号量计数异常,线程提前被唤醒。
17.3 std::latch:一次性屏障
好,聊完信号量,我们来看看屏障类工具。先说 std::latch,它是个“一次性”的同步点。
想象一下:你启动了10个工作线程,每个线程都需要做一些初始化工作。主线程必须等所有线程初始化完毕,才能继续往下走。这就是latch的典型场景。
#include <latch>
#include <thread>
#include <vector>
#include <iostream>
std::latch work_done(5); // 需要5个线程都到达
void worker(int id) {
std::this_thread::sleep_for(std::chrono::milliseconds(100 * id));
std::cout << "线程 " << id << " 到达屏障\n";
work_done.count_down(); // 计数器减1
// 注意:这里不会阻塞,线程继续执行
}
int main() {
std::vector<std::jthread> threads;
for (int i = 0; i < 5; ++i) {
threads.emplace_back(worker, i);
}
work_done.wait(); // 等待计数器归零
std::cout << "所有线程已到达,主线程继续\n";
return 0;
}
latch的关键特性:计数器只能减一次,不能重置。一旦归零,所有等待的线程都会被释放。这个设计很巧妙——它天然适合“一次性的初始化同步”场景。
我个人的习惯是:用latch来做“启动同步”。比如一个服务器程序,多个工作线程需要等待配置加载完毕才能开始处理请求。主线程加载配置,然后调用 latch.count_down(),工作线程调用 latch.wait()。简洁明了。
17.4 std::barrier:可复用的屏障
如果说latch是一次性的,那 std::barrier 就是可复用的。它允许一组线程在多个阶段进行同步。
barrier的用法稍微复杂一点:你需要指定一个“完成回调函数”,当所有线程都到达屏障时,由其中一个线程执行这个回调。然后所有线程继续执行下一轮。
#include <barrier>
#include <thread>
#include <vector>
#include <iostream>
std::barrier sync_point(3, []() noexcept {
std::cout << "--- 所有线程已到达,进入下一阶段 ---\n";
});
void worker(int id, int rounds) {
for (int r = 0; r < rounds; ++r) {
std::this_thread::sleep_for(std::chrono::milliseconds(50 * id));
std::cout << "线程 " << id << " 完成第 " << r << " 轮工作\n";
sync_point.arrive_and_wait(); // 到达并等待
}
}
int main() {
std::vector<std::jthread> threads;
for (int i = 0; i < 3; ++i) {
threads.emplace_back(worker, i, 4);
}
return 0;
}
这里有个细节:arrive_and_wait() 表示“我到了,我等你”。如果你只想“我到了,不等你”,可以用 arrive()。这个区别在性能敏感的场景下很重要——有些线程可以先走,不用等所有人。
17.5 三种工具的对比与选择
说了这么多,到底什么时候用哪个?我画了一张图,帮你理清思路。
总结一下我的选择经验:
| 场景 | 推荐工具 | 原因 |
|---|---|---|
| 限制并发数(连接池、线程池) | std::counting_semaphore |
天然适合资源计数,acquire/release语义清晰 |
| 一次性初始化同步 | std::latch |
计数器只减不增,用完即弃,简单可靠 |
| 多阶段并行计算 | std::barrier |
可复用,支持阶段回调,适合流水线模式 |
| 线程间简单通知 | std::counting_semaphore |
比条件变量轻量,无需互斥锁配合 |
17.6 避坑指南
最后分享几个我踩过的坑,希望能帮你少走弯路。
- 信号量计数溢出:模板参数设得太小,release次数超过最大值,导致未定义行为。解决办法:仔细估算最大并发数,留出余量。
- latch重复使用:以为latch可以像barrier一样重置,结果第二次wait直接返回。记住:latch是一次性的。
- barrier回调抛异常:barrier的回调函数必须是noexcept的。我一开始没注意,回调里抛了个异常,整个程序崩溃了。
- 忘记调用arrive_and_wait:只调了arrive()没调wait(),导致其他线程永远等不到。这个bug我查了整整一下午。
嗯,这些工具用熟了之后,你会发现很多同步问题其实不需要锁。信号量和屏障提供了一种“无锁”的同步思路——不共享数据,只同步状态。这种思路在性能敏感的场景下特别有价值。
说白了,选择同步工具就像选工具:锤子能钉钉子,但拧螺丝还是螺丝刀顺手。多掌握几种工具,你写代码的时候就能选最趁手的那一个。
公众号:蓝海资料掘金营,微信deep3321