17. 信号量与屏障:std::counting_semaphore的使用、std::barrier与std::latch、线程同步的另一种思路

说到线程同步,大家第一反应肯定是互斥锁、条件变量这些老朋友。但说实话,我在项目里待久了,发现有些场景用锁和条件变量反而别扭。比如控制并发数量、等待一组线程全部就绪——这些活儿,用信号量和屏障来做,代码会清爽很多。

这一章我们就聊聊C++20带来的三个新工具:std::counting_semaphorestd::barrierstd::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 是信号量的最大计数值。为什么要有这个上限?因为信号量内部用无符号整数,你得告诉它最大值是多少,防止溢出。我刚开始用的时候忘了设这个参数,编译器报错才反应过来。

小技巧:如果你只需要0和1两种状态(即二进制信号量),可以用 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()。这个区别在性能敏感的场景下很重要——有些线程可以先走,不用等所有人。

实战经验:我在一个并行渲染器里用过barrier。渲染分多个阶段:几何处理、光栅化、着色、后处理。每个阶段之间需要所有线程同步。用barrier实现这个“阶段屏障”非常自然,比用条件变量+计数器的方式代码量少一半。

17.5 三种工具的对比与选择

说了这么多,到底什么时候用哪个?我画了一张图,帮你理清思路。

线程同步工具选择指南 需要同步什么? 控制并发资源数量? → std::counting_semaphore 协调线程执行步调? 是一次性同步还是可复用? → std::latch → std::barrier 一次性 可复用 选择依据:先看需求类型,再看是否需要复用

总结一下我的选择经验:

场景 推荐工具 原因
限制并发数(连接池、线程池) 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