C++ 多线程里,信号量和互斥量到底差在哪

Alice
Alice
发布时间:2026-08-24 | 105人浏览过
原创

前言

在 C++ 并发代码里,`std::mutex` 和 `std::counting_semaphore` 常被误认为只是“写法不同的两种锁”,但它们控制的其实不是同一类问题。本文从所有权、资源计数和误用后果三条线拆开说明,帮你快速判断什么时候该保护临界区,什么时候该限制并发名额。

很多并发 bug 不是因为语法写错,而是同步原语选错了。`std::mutex` 和 `std::counting_semaphore` 看起来都能“拦住线程”,但它们表达的约束完全不同:一个管临界区所有权,一个管可用资源数量。下面按语义、适用场景和常见误用拆开讲,方便你判断该用哪一个、哪些场景坚决不要替换着用。

`std::mutex`:它解决的是临界区保护

`std::mutex` 的设计目标很明确:保护共享数据所在的临界区,保证同一时刻只有一个线程进入。它表达的是排他访问,而不是“有人处理完了通知一下”。

这类互斥量带有线程所有权语义。也就是说,只有执行了 lock() 的那个线程,才能执行对应的 unlock()。如果换成别的线程去解锁,行为就是未定义的。标准库里的 std::mutex 通常不会主动替你检查,但在一些基于 pthread_mutex_t 的调试环境里,可能会报出 EPERM 之类的错误。

  • 不支持跨线程释放,这不是缺点,而是边界保护
  • 状态只有“已锁定 / 未锁定”,不负责资源数量计数
  • 更推荐用 std::lock_guard 或 std::unique_lock 做 RAII 封装,减少手动 unlock() 遗漏
  • 如果同一线程存在递归进入同一临界区的需求,应考虑 std::recursive_mutex,而不是硬把 std::mutex 用成可重入锁

实际判断很简单:只要你的目标是“保护某个共享变量、容器或对象在修改时不被并发访问”,首选就应该是互斥量,而不是信号量。

`std::counting_semaphore`:它解决的是资源池计数

std::counting_semaphore 是 C++20 标准提供的计数型信号量,适合描述“当前还剩几个名额”“最多允许多少并发访问”这类问题。它的重点不是所有权,而是数量。

可以把它理解成“一个原子整数加一组等待线程”。当线程调用 acquire() 时,计数减 1;当计数为 0 时,线程阻塞等待。调用 release() 时,计数加 1,并唤醒等待者。这里最关键的一点是:调用 release() 的线程,不要求必须是此前执行过 acquire() 的线程。

  • 它没有“当前持有者”这个概念,因此天然不适合表达互斥访问
  • 初始化值可以是任意非负整数,例如 std::counting_semaphore<5> 可用于表示最多 5 个并发访问名额
  • 这种无所有权约束的设计很适合生产者-消费者、任务投递、资源池限流等模型
  • 在 C++20 之前,标准库没有正式信号量,常见做法是用 std::condition_variable 配合 std::mutex 模拟,但实现细节多,容易写错,性能也未必理想

也正因为如此,不要把 std::counting_semaphore<1> 当成 std::mutex 的替代品。即便它看起来也能“只放一个线程进去”,语义上仍然不是一回事:它不记录谁拿到了访问权,也不保证谁释放谁负责,代码可读性和安全边界都差一截。

为什么二者不能互相替代

很多误解来自表面行为相似:互斥量能阻塞线程,信号量也能阻塞线程,于是有人觉得只是“不同写法”。问题在于,并发原语最重要的不是能不能阻塞,而是它在代码里表达了什么约束。

用纸张卡片对比互斥量与信号量核心语义的暖色技术专栏插图
互斥量管排他,信号量管数量用两组卡片把“临界区保护”和“资源数量协调”拆开,帮助读者快速判断两者不能互换的根本原因。

互斥量强调的是排他性控制,核心问题是“现在能不能进”。信号量强调的是容量型协调,核心问题是“现在还能进几个”。前者适合给共享状态建立访问边界,后者适合给资源池、任务槽位或并发额度做数量限制。

如果把两者混用,维护成本会明显上升。读代码的人很难从同步原语本身判断你的意图:你到底是在保护共享内存,还是只是在做并发配额控制?语义一旦模糊,后续排查并发问题时就会特别痛苦。

常见误用:把信号量当互斥量,结果引出数据竞争

一个很典型的错误模式是:线程 A 先 acquire(),然后开始修改共享变量;线程 B 在别处调用 release(),把它当成“处理完成通知”;这时线程 C 可能提前 acquire() 成功,并在 A 还没写完时进入临界区,最终读到中间态数据。

展示把信号量误当互斥量后产生数据竞争的暖色专栏示意图
跨线程释放为什么会读到中间态用时间线和便签标出 A、B、C 三个线程的先后关系,说明跨线程 release。

问题的根源不在于信号量失效,而在于它从来就没有承诺“只有完成修改的线程才能释放访问权”。这是互斥量的职责,不是信号量的职责。

  • 互斥量的 lock() / unlock() 通常应该成对出现在同一作用域,或至少维持清晰的调用边界
  • RAII 能自动保证异常路径和提前返回路径上的解锁行为,这也是它比手动调用更稳妥的原因
  • 信号量的 acquire() / release() 可以跨函数、跨线程、跨模块,灵活性很高,但一致性约束需要你自己保证
  • 如果调试时发现 shared_data 的值偶尔异常,先检查所有修改点是否都由同一把 std::mutex 保护,而不是只靠信号量在做“计数协调”

实际选型可以按这条线判断

当问题是“这段共享数据一次只能有一个线程改”,用 std::mutex。当问题是“这类资源最多允许 N 个线程同时占用”,用 std::counting_semaphore。

换句话说,互斥量处理的是访问排他,信号量处理的是容量协调。把红绿灯当停车位计数器,或者拿停车位计数器去决定谁能穿过路口,表面上都像是在“控流”,但规则完全不同。在 C++ 并发代码里,这种语义混淆往往就是 bug 的起点。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表