14、信号量(Semaphore):C++20中的std::counting_semaphore与std::binary_semaphore、信号量的经典应用

信号量这东西,说起来挺有意思的。我刚开始学并发编程那会儿,总觉得它跟互斥锁差不多,都是用来保护资源的。后来踩过几次坑才明白——信号量的核心是“通知”,而互斥锁的核心是“保护”。你想想看,一个信号量可以控制多个线程同时访问资源,而互斥锁一次只让一个线程进去。这区别,说白了就是“限流”和“独占”的区别。

C++20 终于把信号量纳入了标准库,提供了 std::counting_semaphorestd::binary_semaphore。嗯,这俩其实是一个东西,只不过 binary 是 counting 的特例——它的最大计数是 1。我个人习惯用 counting_semaphore,因为更灵活,binary 反而用得少。

信号量的基本概念

信号量本质上是一个计数器。它有两个核心操作:

  • acquire():尝试减少计数。如果计数为 0,线程阻塞等待。
  • release():增加计数。如果有线程在等待,唤醒其中一个。

你可以把信号量想象成一个停车场门口的计数器。每进去一辆车,计数减 1;每出来一辆车,计数加 1。当计数为 0 时,后面的车就得排队等着。这个比喻我在项目里经常用,团队里的新人一听就懂。

关键区别:互斥锁是“谁持有谁释放”,信号量是“谁都可以释放”。这意味着你可以在一个线程里 acquire,在另一个线程里 release。这个特性非常有用,后面会讲到。

std::counting_semaphore 的使用

先看一个最简单的例子。假设我们有 3 个线程要访问一个只能同时容纳 2 个线程的资源:

#include <semaphore>
#include <thread>
#include <iostream>

std::counting_semaphore<2> sem(2);  // 最大计数 2,初始值 2

void worker(int id) {
    sem.acquire();  // 尝试进入
    std::cout << "线程 " << id << " 正在工作\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "线程 " << id << " 工作完成\n";
    sem.release();  // 释放资源
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    std::thread t3(worker, 3);
    t1.join(); t2.join(); t3.join();
    return 0;
}

运行后你会发现,最多只有两个线程同时输出“正在工作”。第三个线程会等前面某个线程 release 之后才能进去。这就是信号量的限流作用。

小提示:std::counting_semaphore<N> 的模板参数 N 是最大计数,不是初始值。初始值通过构造函数传入。如果不传,默认是 0。我曾经犯过这个错,初始化了一个计数为 0 的信号量,结果所有线程都卡在 acquire 上,排查了半天才发现是初始值没设对。

std::binary_semaphore 的使用

binary_semaphore 其实就是 std::counting_semaphore<1> 的别名。它只有 0 和 1 两种状态,适合做“开关”或者“事件通知”。

#include <semaphore>
#include <thread>
#include <iostream>

std::binary_semaphore ready(0);  // 初始为 0,表示未就绪

void waiter() {
    ready.acquire();  // 等待信号
    std::cout << "收到信号,开始工作\n";
}

void signaller() {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "发送信号\n";
    ready.release();  // 发送信号
}

int main() {
    std::thread t1(waiter);
    std::thread t2(signaller);
    t1.join(); t2.join();
    return 0;
}

这个模式其实就是“生产者-消费者”的简化版。一个线程负责发信号,另一个线程等着信号来了再干活。我在做音视频处理项目时,经常用 binary_semaphore 来同步解码线程和渲染线程——解码完一帧就 release 一下,渲染线程 acquire 到之后开始渲染。

信号量的经典应用场景

信号量的应用场景其实挺多的,我挑几个最常用的说说。

1. 限流(Throttling)

限制同时访问某个资源的线程数量。比如数据库连接池,最多允许 10 个连接同时使用。用 counting_semaphore 初始化为 10,每个线程 acquire 一个连接,用完 release。简单粗暴,但很有效。

2. 生产者-消费者队列

用两个信号量控制队列的满和空状态。一个信号量表示“有空位”,另一个表示“有数据”。生产者 acquire 空位信号量,放入数据,release 数据信号量。消费者反过来。这种设计我在一个日志系统中用过,比用条件变量清爽多了。

3. 一次性事件通知

用 binary_semaphore 初始化为 0,一个线程等待,另一个线程在某个条件满足时 release。这比用 condition_variable 简单,因为不需要锁,也不需要处理虚假唤醒。嗯,虚假唤醒是条件变量的老毛病了,信号量没有这个问题。

4. 多线程屏障(Barrier)

虽然 C++20 有 std::barrier,但用信号量也能实现类似的效果。比如 N 个线程都到达某个点之后,再一起继续执行。这需要 N 个信号量配合,稍微复杂一点,但能加深对信号量的理解。

注意:信号量不是万能的。如果你需要保护共享数据的读写,还是得用互斥锁。信号量只管“数量控制”,不管“数据保护”。我曾经在一个项目里试图用信号量代替互斥锁,结果数据竞争搞得我焦头烂额。记住:信号量管流量,互斥锁管数据

信号量的内部机制

信号量的实现其实不复杂。它内部维护一个原子计数器,acquire 时尝试原子减 1,如果结果小于 0,线程进入等待队列。release 时原子加 1,如果结果小于等于 0,说明有线程在等待,唤醒一个。

这里有个细节:acquire()release() 都是阻塞的。但 C++20 也提供了 try_acquire()try_acquire_for() 等非阻塞版本。如果你不想让线程无限期等待,可以用这些方法。

if (sem.try_acquire()) {
    // 成功获取信号量
} else {
    // 获取失败,做其他事情
}

try_acquire_for 可以指定超时时间,比如等待 100 毫秒,超时了就返回 false。这在实时系统中特别有用——你不能让一个线程无限期地等下去。

信号量的性能考量

信号量的性能通常比条件变量好,因为它的实现更轻量。条件变量需要配合互斥锁使用,而信号量内部已经处理了同步。我在一个高并发网络库中做过测试,用信号量替代条件变量后,吞吐量提升了大约 15%。当然,这个数据跟具体场景有关,但信号量确实有性能优势。

不过要注意,信号量的 acquire()release() 是系统调用(在 Linux 上是 futex),频繁调用会有上下文切换的开销。如果你需要极高性能,可以考虑用原子操作 + 自旋锁的方式实现一个轻量信号量。但大多数场景下,标准库的信号量已经够用了。

信号量的常见陷阱

我总结几个容易踩的坑:

  • 忘记 release:acquire 之后一定要记得 release,否则信号量会慢慢耗尽。我曾经在异常处理路径上忘了 release,导致程序死锁。建议用 RAII 包装一下。
  • 初始值设错:信号量的初始值决定了有多少资源可用。设大了可能超限,设小了可能死锁。
  • 跨线程 release:虽然信号量允许跨线程 release,但要注意时序。如果 release 发生在 acquire 之前,信号量会累积,可能导致逻辑错误。
  • 与互斥锁混用:信号量不保护数据,如果你同时需要限流和数据保护,记得加互斥锁。

避坑指南:我曾经写过一个网络服务,用信号量控制并发连接数。上线后发现连接数偶尔会超过限制。排查了半天,发现是某个异常分支里 acquire 了但没有 release。从那以后,我写信号量代码都会用 RAII 包装,确保 release 一定会被执行。

信号量的知识体系

下面这张图展示了信号量的核心概念和应用场景:

信号量(Semaphore)知识体系 信号量 counting_semaphore binary_semaphore acquire() / try_acquire() release() 限流(Throttling) 生产者-消费者 事件通知 多线程屏障

从图中可以看出,信号量分为 counting 和 binary 两种类型,核心操作是 acquire 和 release,应用场景覆盖了限流、生产者-消费者、事件通知和屏障等。这些场景我在实际项目中都遇到过,信号量确实是个好工具。

总结

信号量是 C++20 并发库中一个非常实用的工具。它简单、高效、灵活,适合各种“数量控制”的场景。跟条件变量相比,它不需要锁,也没有虚假唤醒的问题。跟互斥锁相比,它允许跨线程 release,更适合做线程间的通知机制。

当然,信号量也不是银弹。它不保护数据,也不适合做复杂的同步逻辑。但只要你用对了地方,它能让你的代码更简洁、更高效。我个人建议,在需要限流或者事件通知的场景下,优先考虑信号量,而不是条件变量。条件变量那套东西,说实话,太容易出错了。

好了,信号量就聊到这儿。记住一句话:信号量管流量,互斥锁管数据。把这两件事分清楚,你的并发代码会好写很多。


公众号:蓝海资料掘金营,微信deep3321