前言
互斥量(mutex,mutual exclusion)看起来只是一个"能加锁、能解锁"的对象,很多教材也只讲到"锁住临界区就不会有竞争"。但真正理解它,需要回答三个层次的问题:语言标准给了什么契约,标准库在平台上调用了什么,操作系统又依赖哪些硬件能力。这三个层次混在一起,就会出现"锁就是一个布尔变量""加锁一定要进内核""自旋锁比互斥量快"之类的片面结论。
标题里的 C/C++ 需要先澄清:C 语言在 C11 的<threads.h>中提供了mtx_t与mtx_lock等接口,但该头文件是可选的,实现可以定义__STDC_NO_THREADS__表示不支持;C++ 从 C++11 起在<mutex>中提供std::mutex。两者的语义相近,底层往往落到同一套平台原语上——Linux 上通常是 pthread 互斥量,Windows 上是临界区或 SRW 锁。
另一个常见误解是把"互斥量"和"信号量"混为一谈。互斥量有所有者概念,通常只能由加锁的线程解锁;信号量是计数资源,任何线程都可以增加计数。本文讲的是互斥量,并在原理部分说明它为什么必须有所有者。
本文不依赖任何具体编译器的内部实现细节,凡是标准未规定的部分都会明确标注"实现相关"。文中代码只用于说明语义,不声称经过编译或运行。
一、互斥量的语义契约
标准对互斥量的核心要求只有两条:同一时刻至多一个线程处于临界区;解锁操作与后续成功的加锁操作之间建立同步关系。第二条常被忽略,但它才是互斥量能安全传递数据的根本原因。
以std::mutex为例,它的契约可以概括为:
| 操作 | 语义 | 违反后果 |
|---|
lock() | 获取所有权,必要时阻塞 | 已持有同一互斥量时再次调用:未定义行为 |
unlock() | 释放所有权 | 由非所有者线程调用:未定义行为 |
try_lock() | 尝试获取,失败返回false | 允许"伪失败",即没有竞争也可能返回false |
| 析构 | 销毁互斥量 | 仍被锁定时销毁:未定义行为 |
try_lock允许伪失败,是为了给实现留出优化空间;因此正确的重试逻辑必须写在循环里,不能"失败一次就认定锁被占用"。
内存序方面,成功的lock()具有 acquire 语义,unlock()具有 release 语义。直观地说:线程 A 在临界区内写的所有数据,在线程 A 解锁之前不会重排到解锁之后;线程 B 成功加锁之后读到的,应当是 A 解锁前完成的值。这不是"缓存被刷新"这种硬件描述,而是标准层面的 happens-before 保证。
std::mutex不可递归。同一线程对同一个std::mutex连续调用两次lock()是未定义行为,实践中常常表现为自死锁。需要递归语义时应使用std::recursive_mutex,它内部维护持有计数,同一线程可以多次加锁,但解锁次数必须匹配。
二、从硬件原子指令到"睡眠等待"
2.1 只用普通变量为什么不行
假设互斥量内部只有一个bool locked,加锁逻辑写成"读取、若为假则写入真"。两个线程可能同时读到假,然后都写入真,双方都认为自己拿到了锁。问题不在于代码写得不够小心,而在于"读—改—写"这三步在多核之间不是原子的。要修复它,必须借助处理器提供的原子读改写指令,例如 x86 的lock cmpxchg、ARM 的ldrex/strex或ldaxr/stxr序列。
C++ 把这些指令抽象成std::atomic。一个最小化的自旋锁可以这样写:
// 适用于 C++17
#include <atomic>
class SpinMutex {
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// 抢不到就继续尝试,形成忙等
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};test_and_set是原子的"写入真并返回旧值"操作。第一个线程看到旧值为假,成功进入;其余线程看到旧值为真,继续循环。acquire与release保证临界区内的读写不会被重排到锁外。
这个自旋锁在语义上是正确的,但作为通用互斥量并不合格:它忙等消耗 CPU,在单核上尤其糟糕;它不保证公平,某个线程可能长期抢不到;它也不能重入。它适合临界区极短、线程数很少且确定不会长期持锁的场景,标准库并不用它来实现std::mutex。
2.2 两阶段设计:用户态快路径加内核慢路径
真实实现通常把加锁分成两个阶段。
快路径:先用一条原子指令尝试把状态从"未锁"改成"已锁"。如果没有竞争,这一步在用户态完成,不涉及系统调用。这就是"无竞争时加锁很便宜"的来源。
慢路径:如果发现锁已被占用,说明存在竞争。此时继续自旋可能浪费整个时间片,实现会转而把当前线程挂到该锁的等待队列上并睡眠,等持有者解锁时再唤醒。Linux 上承担这个角色的是 futex(fast userspace mutex)。
futex 的关键在于它本身不维护锁,只维护"等待队列"。用户态仍然用原子变量表示锁状态;只有当线程准备睡眠时,才调用FUTEX_WAIT,并传入它期望看到的值。内核会原子地检查该值是否仍等于期望值:相等则睡眠,不等则立即返回,避免"检查后、睡眠前锁被释放"的丢失唤醒。解锁方调用FUTEX_WAKE唤醒等待者。整个机制只在真正发生竞争时才付出系统调用的代价。
Windows 上的思路类似但对象不同:CRITICAL_SECTION内部有自旋计数,先自旋若干次,仍拿不到才创建或等待内核事件;SRWLock是更轻量的读写锁,同样区分用户态与内核态路径。具体自旋次数、内部字段布局都属于实现细节,标准不规定,也不应依赖。
2.3 为什么互斥量需要"所有者"
自旋锁只关心"有没有人持有",不关心"是谁持有"。互斥量则需要记录所有者,原因有二:一是解锁时必须确认调用者就是持有者,否则一个线程可以替另一个线程解锁,临界区保护就失效了;二是实现睡眠等待时,需要区分"自己已持有"与"别人持有",前者应报告错误而不是睡眠。
这也解释了为什么std::mutex重复加锁是未定义行为,而std::recursive_mutex要额外维护计数:所有者的身份是这套语义的一部分,不是实现附赠的便利。
三、正确使用:RAII、多锁与条件变量
理解了原理,使用时仍要回到契约。最重要的一条是:不要手写lock()与unlock()的配对。任何提前return、任何抛异常、任何中途break,都可能让解锁被跳过,而std::mutex在析构时仍被持有是未定义行为。
std::lock_guard在构造时加锁、析构时解锁,把配对交给作用域。需要中途解锁或配合条件变量时用std::unique_lock,它支持unlock()、lock()与转移到条件变量等待。需要同时锁多个互斥量时,C++17 的std::scoped_lock接受多个互斥量并按避免死锁的算法加锁:
// 适用于 C++17
#include <mutex>
std::mutex m1;
std::mutex m2;
void transfer() {
std::scoped_lock lock(m1, m2); // 一次性锁住两个,内部避免死锁
// 同时访问受 m1 与 m2 保护的数据
}如果分别写std::lock_guard<std::mutex> a(m1); std::lock_guard<std::mutex> b(m2);,另一个线程以相反顺序加锁就可能死锁。标准要求std::scoped_lock与std::lock在不抛异常的前提下避免这种死锁,具体算法由实现决定,常见做法是尝试加锁再回退。
条件变量则必须与互斥量配合,并且等待要放在谓词循环里:
// 适用于 C++17
#include <condition_variable>
#include <mutex>
#include <queue>
#include <utility>
template <typename T>
class BlockingQueue {
public:
void push(T value) {
{
std::lock_guard<std::mutex> lock(m_);
queue_.push(std::move(value));
}
cv_.notify_one();
}
T pop() {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [this] { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
return value;
}
private:
std::mutex m_;
std::condition_variable cv_;
std::queue<T> queue_;
};wait(lock, pred)会在持有锁的情况下求值谓词;谓词为假时原子地释放锁并睡眠,被唤醒后重新加锁再求值。用if代替谓词循环会踩到虚假唤醒:线程被唤醒并不保证条件已经成立,必须重新检查。
下面是完整示例,演示互斥量保护共享计数器。按标准语义,四个线程各加十万次,最终值应等于四十万,与线程调度顺序无关:
// 适用于 C++17
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
class Counter {
public:
void add(int delta) {
std::lock_guard<std::mutex> lock(m_);
value_ += delta;
}
int value() const {
std::lock_guard<std::mutex> lock(m_);
return value_;
}
private:
mutable std::mutex m_;
int value_ = 0;
};
int main() {
Counter counter;
std::vector<std::thread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&counter] {
for (int j = 0; j < 100000; ++j) {
counter.add(1);
}
});
}
for (auto& t : workers) {
t.join();
}
std::cout << counter.value() << '\n';
return 0;
}value()是const成员函数,却要加锁修改互斥量的内部状态,因此互斥量声明为mutable。这不是取巧,而是"逻辑常量性"与"物理可变性"分离的标准做法:读取操作不改变对象的值,但必须改变锁的状态。
std::thread在析构前必须join()或detach(),否则标准要求调用std::terminate。示例中统一join,是最容易推理的写法。
常见坑点
- ❌ 手动
m.lock()后在某个return分支忘记m.unlock()。
✅ 用std::lock_guard或std::scoped_lock,让析构负责解锁。
- ❌ 同一线程对同一个
std::mutex再次lock(),指望它像递归锁一样工作。
✅ 这是未定义行为;需要重入时改用std::recursive_mutex,或重构代码避免嵌套加锁。
- ❌ 两个线程以相反顺序锁
m1、m2。
✅ 用std::scoped_lock(m1, m2)一次性加锁,或全局约定统一的加锁顺序。
- ❌ 在临界区里做文件 I/O、网络请求或长时间计算。
✅ 临界区只保护共享数据的不变量,把耗时操作移到锁外。
- ❌ 用
volatile修饰共享变量,认为它能替代互斥量。
✅volatile不是同步手段;数据竞争是未定义行为,应使用std::mutex或std::atomic。
- ❌ 条件变量写成
if (!ready) cv.wait(lock);。
✅ 写成cv.wait(lock, [&]{ return ready; });,用谓词循环抵御虚假唤醒。
- ❌ 认为
try_lock()返回false就一定代表锁被别的线程持有。
✅ 标准允许伪失败,重试逻辑必须放在循环中。
- ❌ 以为
std::shared_ptr的控制块线程安全,就等于它指向的对象访问也安全。
✅ 控制块的引用计数是线程安全的,被指向对象的读写仍需自行同步。
总结
| 层次 | 关键机制 | 标准是否规定 | 典型开销 |
|---|
| 语言契约 | 互斥、所有权、acquire/release 同步 | 是 | 无 |
| 硬件 | 原子读改写指令(CAS、交换) | 否 | 一条指令量级,受缓存一致性影响 |
| 用户态快路径 | 无竞争时原子操作直接成功 | 否 | 通常不进入内核 |
| 内核慢路径 | futex 等待队列、事件对象 | 否 | 系统调用与上下文切换 |
| 使用方式 | RAII、多锁算法、条件变量谓词 | 部分是 | 取决于临界区长度与竞争程度 |
互斥量的本质是"用硬件原子指令维护一个所有者状态,用操作系统等待队列解决抢不到时的睡眠问题"。无竞争时它很轻,竞争激烈时它会变重,所以优化方向通常不是换一种锁,而是缩短临界区、减少共享、降低竞争。把契约用对——RAII 管生命周期、scoped_lock管多锁、谓词管条件变量——比背诵某個平台的内部字段更有价值。