前言
多个线程同时读写同一块内存,如果没有同步,就是数据竞争(data race)。C++ 标准把数据竞争明确定义为未定义行为(undefined behavior, UB)——不是「结果可能偶尔不对」,而是编译器可以假设它不存在,从而做出任何优化,包括让循环永不结束、让判断被提到循环外。很多初学者看到程序「大部分时候跑得对」就以为没问题,实际上那只是 UB 恰好没有显形。
互斥锁(mutex)是最基础的同步手段:它把一段临界区变成同一时刻只允许一个线程进入。但锁本身也会引入新问题,最典型的就是死锁(deadlock)——两个线程各自持有一把锁,又都在等对方手里的那把,于是永远停在那里。另一类常见错误是「加了锁但没锁对地方」:锁的粒度太粗导致性能塌陷,粒度太细导致本该原子的复合操作被拆开。
本文先说明互斥锁要解决的问题和标准库提供的几种锁类型,再分析死锁的形成条件与代码成因,最后给出避免死锁的几种工程手段,并配合条件变量给出完整的生产者—消费者示例。文中代码以 C++17 为基准,均为按标准语义推演的示例,本机没有编译环境,未做实际运行验证。
一、为什么需要互斥锁:数据竞争是 UB
考虑一个最普通的自增操作++counter。在源码层面它是一条语句,但在机器层面通常是「读取—加一—写回」三步。两个线程交错执行时,可能都读到旧值 0,各自加一后都写回 1,最终结果是 1 而不是 2。这被称为丢失更新(lost update)。
更严重的是,C++ 的内存模型并不保证一个线程能看到另一个线程的写入。编译器可以把变量缓存在寄存器里,CPU 可以重排访存,缓存之间也可能不一致。标准通过「happens-before」关系来定义可见性:只有建立了同步关系,一个线程的写入才保证对另一个线程可见。互斥量的lock/unlock正是建立这种关系的手段之一:前一个线程unlock之前的所有写入,对后一个成功lock的线程可见。
因此,判断要不要加锁的标准不是「这段代码看起来短不短」,而是「是否存在两个线程,至少一个是写,且没有同步关系」。只读的共享数据不需要锁,前提是它在所有线程启动前就已经构造完成、之后不再修改。
一个常被忽略的点是:std::atomic<int>的自增是原子的,但「先判断再自增」这样的复合操作不是。if (flag) { ... }与另一线程对flag的修改之间仍然需要同步。原子类型只保证单次操作的不可分割性,不自动保护它周围的控制流。
二、mutex 家族与 RAII 锁管理
标准库在<mutex>中提供了几种互斥量:
| 类型 | 语义 | 典型用途 |
|---|
std::mutex | 独占所有权,不可递归 | 绝大多数临界区 |
std::recursive_mutex | 同一线程可重复加锁 | 递归函数或回调重入 |
std::timed_mutex | 支持try_lock_for/try_lock_until | 需要超时退避 |
std::recursive_timed_mutex | 递归 + 超时 | 少数老代码 |
std::shared_mutex | 读写锁(C++17) | 读多写少 |
直接用lock()/unlock()是危险的:一旦临界区中间抛出异常或提前return,unlock就不会执行,锁永远不释放。标准库的解法是 RAII(resource acquisition is initialization)包装器,把解锁放进析构函数。
std::lock_guard是最轻的包装:构造时加锁,析构时解锁,没有其他成员函数,不能提前释放,也不能转移所有权。std::unique_lock更灵活:可以延迟加锁(std::defer_lock)、可以接管已经锁住的互斥量(std::adopt_lock)、可以手动unlock()再lock(),还可以被移动。灵活是有代价的:unique_lock通常要保存一个「是否持有锁」的标志,比lock_guard略重,且可能产生额外分支。
选择原则很简单:能用lock_guard就用lock_guard;需要在condition_variable::wait中反复解锁/加锁,或需要中途释放锁时,才用unique_lock。
// 适用于 C++17
#include <mutex>
class Counter {
public:
void increment() {
std::lock_guard<std::mutex> lock(mutex_);
++value_;
}
int value() const {
std::lock_guard<std::mutex> lock(mutex_);
return value_;
}
private:
mutable std::mutex mutex_;
int value_ = 0;
};这里mutex_必须声明为mutable,否则const成员函数里无法调用非 const 的lock()。另外,mutex_本身不可拷贝、不可移动,因此Counter默认就是不可拷贝的;如果需要按值传递,得自己写拷贝构造,在锁的保护下复制value_,而不是复制互斥量。
三、死锁的成因与四个必要条件
死锁是指一组线程各自持有资源并等待其他线程释放资源,形成循环等待,谁也无法推进。操作系统教材常引用四个必要条件:互斥、持有并等待、不可剥夺、循环等待。四个条件同时满足才可能死锁,因此破坏其中任意一个就能避免。
在 C++ 代码里,最典型的死锁长这样:
// 适用于 C++17(反面示例:会死锁)
#include <mutex>
struct Account {
std::mutex mtx;
int balance = 100;
};
void transfer_bad(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lk1(from.mtx);
std::lock_guard<std::mutex> lk2(to.mtx); // 另一个线程反向加锁就会死锁
from.balance -= amount;
to.balance += amount;
}线程 A 执行transfer_bad(x, y),线程 B 同时执行transfer_bad(y, x)。A 持有x.mtx等y.mtx,B 持有y.mtx等x.mtx,循环等待形成。注意这种 bug 与数据量无关,可能运行几百万次才出现一次,极难复现。
还有几种不那么直观的死锁:
- 自死锁:同一线程对同一个非递归
std::mutex连续加锁两次。标准规定这是 UB,常见表现就是挂起。典型来源是「公有函数加锁后调用另一个也会加锁的公有函数」。 - 回调重入:持锁时调用用户提供的回调,回调内部又去拿同一把锁。即使换成了
recursive_mutex,如果回调在另一个线程等待,也仍然可能死锁。 - 条件变量使用不当:忘记在
wait前持有锁,或者用notify代替谓词检查,导致错过唤醒后永久等待。 - 锁与 join 交叉:线程 A 持锁等待线程 B 结束,而 B 结束前需要同一把锁。
std::recursive_mutex常被当成万能药,但它只是让同一线程重入不挂起,并不能解决跨线程的循环等待;而且它掩盖了「锁的职责边界不清」这一设计问题。多数情况下,正确做法是把加锁的公有函数拆成「加锁的外壳 + 不加锁的内部实现」。
四、避免死锁:同时加锁、固定顺序、缩小临界区
C++17 提供了std::scoped_lock,它可以一次锁住多个互斥量,内部用避免死锁的算法(通常与std::lock相同)完成加锁。上面的转账函数可以改写为:
// 适用于 C++17
void transfer(Account& from, Account& to, int amount) {
if (&from == &to) {
return; // 同一账户不能自己转给自己
}
std::scoped_lock lock(from.mtx, to.mtx); // 一次性锁住,内部避免循环等待
from.balance -= amount;
to.balance += amount;
}std::scoped_lock的模板参数由构造函数自动推导(类模板实参推导,CTAD),所以不必写std::scoped_lock<std::mutex, std::mutex>。它持有的锁在析构时按逆序释放。C++11/14 中对应的写法是std::lock(from.mtx, to.mtx)加两个std::lock_guard<std::mutex>(m, std::adopt_lock),代码更长且容易漏掉adopt_lock。
如果所在标准库版本不支持scoped_lock,或者需要更细的控制,可以给每个互斥量分配一个全局唯一的序号,所有线程按序号从小到大加锁。这是破坏「循环等待」的经典办法,代价是需要维护序号,且序号不能与业务逻辑冲突。
第三种办法是超时:用std::timed_mutex的try_lock_for,拿不到就释放已持有的锁、退避后重试。它能保证程序不永久卡死,但会引入活锁(livelock)的可能,且需要仔细处理「部分加锁成功」的回滚。
第四种办法是缩小临界区。锁内不要做 I/O、不要分配大块内存、不要调用未知的回调、不要等待另一个线程。临界区越短,持锁时间越短,发生循环等待的概率越低,整体吞吐也越好。但要注意,缩小临界区不能以破坏逻辑原子性为代价:把「读—改—写」拆成三段各自加锁,等于没加锁。
条件变量也必须与互斥量配合正确:
// 适用于 C++17
#include <condition_variable>
#include <mutex>
#include <queue>
class BoundedQueue {
public:
explicit BoundedQueue(std::size_t cap) : cap_(cap) {}
void push(int v) {
std::unique_lock<std::mutex> lk(m_);
not_full_.wait(lk, [this] { return q_.size() < cap_; });
q_.push(v);
lk.unlock();
not_empty_.notify_one();
}
int pop() {
std::unique_lock<std::mutex> lk(m_);
not_empty_.wait(lk, [this] { return !q_.empty(); });
int v = q_.front();
q_.pop();
lk.unlock();
not_full_.notify_one();
return v;
}
private:
std::mutex m_;
std::condition_variable not_full_;
std::condition_variable not_empty_;
std::queue<int> q_;
std::size_t cap_;
};wait(lk, pred)会在持有锁的情况下求值谓词;谓词为假时原子地释放锁并阻塞,被唤醒后重新加锁再求值。这个循环同时处理了虚假唤醒(spurious wakeup)和「通知早于等待」的丢失唤醒问题。手动写while (!pred) cv.wait(lk);等价,但直接用带谓词的重载更不容易写错。notify_one放在解锁之后是常见优化,避免被唤醒的线程立刻又阻塞在锁上。
完整示例
下面是一个可直接搭起来的多线程计数程序,四个线程各自累加,主线程等待全部结束后读取结果:
// 适用于 C++17
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
class Counter {
public:
void increment() {
std::lock_guard<std::mutex> lock(mutex_);
++value_;
}
int value() const {
std::lock_guard<std::mutex> lock(mutex_);
return value_;
}
private:
mutable std::mutex mutex_;
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 k = 0; k < 1000; ++k) {
counter.increment();
}
});
}
for (auto& t : workers) {
t.join(); // 必须 join 或 detach,否则析构时 terminate
}
std::cout << counter.value() << '\n';
return 0;
}按标准语义,四个线程的 4000 次自增都被同一把互斥量保护,最终结果应当是 4000;如果把lock_guard去掉,程序就存在数据竞争,结果不可预期。
常见坑点
- ❌ 在
const成员函数里直接mutex_.lock(),编译失败后干脆去掉const。✅ 把互斥量声明为mutable std::mutex,保持接口的 const 正确性。
- ❌ 持锁调用回调或未知函数,回调内部再次加锁。✅ 明确锁的保护范围,回调在锁外调用;或把「加锁的公有接口」与「不加锁的实现」分离。
- ❌ 两个线程以不同顺序锁两个互斥量。✅ 用
std::scoped_lock一次锁住,或全局规定加锁顺序。
- ❌ 用
std::lock_guard手动unlock,或试图把锁移交给别的对象。✅ 需要提前释放或转移时改用std::unique_lock。
- ❌ 条件变量写成
if (!ready) cv.wait(lk);,被虚假唤醒后继续执行。✅ 用cv.wait(lk, [&]{ return ready; }),谓词在持锁状态下循环求值。
- ❌ 认为
volatile能替代互斥量,靠它让线程看到最新值。✅volatile不是同步手段,跨线程共享数据必须用std::atomic或互斥量。
- ❌ 为了「提高并发」把锁的粒度拆得过细,复合操作被拆到多个临界区。✅ 保证逻辑上的原子单元在同一临界区内,再在此基础上缩小范围。
- ❌ 线程对象析构前没有
join()或detach()。✅ 每个std::thread都必须明确生命周期,否则析构时调用std::terminate。
总结
| 问题 | 正确做法 | 关键标准 |
|---|
| 数据竞争 | 互斥量或原子操作保护 | 数据竞争是 UB |
| 异常导致漏解锁 | RAII 锁包装器 | lock_guard/unique_lock |
| 多锁循环等待 | 一次性加锁或固定顺序 | std::scoped_lock(C++17) |
| 等待条件 | 谓词 + 条件变量 | wait(lock, pred) |
| 持锁过久 | 缩小临界区,锁外做 I/O | 设计与测量 |
互斥锁解决的是「可见性与原子性」,死锁解决的是「加锁顺序与持锁范围」,两者其实是一体两面:锁用得越少、持得越短、顺序越统一,程序就越不容易出错。写多线程代码时,先想清楚哪些数据被哪些线程访问,再决定锁保护什么,比事后到处补锁有效得多。