在多线程编程中,死锁无疑是最令人头疼的问题之一。它不像段错误那样导致程序立即崩溃,而是悄无声息地让程序“卡死”——CPU占用不高,内存不见增长,但程序就是不再向前执行。今天,我们就来深入盘点在 C++ 实战开发中 最常见的7种死锁场景,并给出对应的解决方案,看看你的代码是否也曾陷入这些陷阱。
场景1: 经典的锁顺序不一致
这是最经典、也是最常见的死锁场景,通常发生在多个线程需要以不同顺序获取相同的两把(或多把)锁时。
💀 问题代码
std::mutex mtx1, mtx2;
void thread1_func() {
std::lock_guard<std::mutex> lock1(mtx1); // 先锁mtx1
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx2); // 再锁mtx2
// 业务逻辑...
}
void thread2_func() {
std::lock_guard<std::mutex> lock2(mtx2); // 先锁mtx2
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock1(mtx1); // 再锁mtx1
// 业务逻辑...
}
死锁原因: 线程1持有 mtx1 等待 mtx2,线程2持有 mtx2 等待 mtx1,形成了循环等待。
✅ 正确解决方案
方案1: 统一加锁顺序
最简单的方法是规定一个全局的加锁顺序,所有线程都遵守。
void safe_thread_func() {
// 所有线程都按照 mtx1 -> mtx2 的顺序加锁
std::lock_guard<std::mutex> lock1(mtx1);
std::lock_guard<std::mutex> lock2(mtx2);
// 业务逻辑...
}
方案2: 使用 std::lock 原子获取多锁(推荐)
std::lock 可以原子性地同时获取多个锁,避免因顺序问题导致的死锁。
void safe_thread_func() {
std::lock(mtx1, mtx2); // 原子地同时获取两个锁
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
// 业务逻辑...
}
方案3: 使用 std::scoped_lock (C++17,最推荐)
std::scoped_lock 是 C++17 引入的 RAII 风格多锁管理器,它会自动处理加锁顺序。
void safe_thread_func() {
std::scoped_lock lock(mtx1, mtx2); // 自动按顺序加锁,RAII管理
// 业务逻辑...
}
场景2: 忘记解锁导致的死锁
这个错误看似低级,但在实际项目中,尤其是在复杂的条件分支或异常处理路径中,出现的频率相当高!
💀 问题代码
std::mutex mtx;
void process_data() {
mtx.lock();
if (some_error_condition) {
return; // 糟糕!忘记unlock就返回了
}
// 业务逻辑...
mtx.unlock();
}
死锁原因: 第一个线程在错误条件下提前返回,锁没有释放。后续所有尝试获取该锁的线程都会永久阻塞,这正是许多并发编程初学者容易踩的坑。
✅ 正确解决方案
永远使用RAII管理锁,不要手动 lock/unlock
利用 C++ 的 RAII (Resource Acquisition Is Initialization) 机制,让锁的生命周期与作用域绑定。
void safe_process_data() {
std::lock_guard<std::mutex> lock(mtx); // 构造时加锁,自动管理,异常安全
if (some_error_condition) {
return; // lock_guard析构时会自动unlock,安全!
}
// 业务逻辑...
} // 离开作用域,lock_guard析构,自动unlock
这种内存管理思想是 C++ 的核心优势之一。
场景3: 递归锁的误用
同一个线程对同一个普通的 std::mutex 进行多次加锁会导致死锁!这在递归调用或复杂调用链中容易发生。
💀 问题代码
std::mutex mtx;
void func_a() {
std::lock_guard<std::mutex> lock(mtx);
// 业务逻辑...
func_b(); // 调用func_b
}
void func_b() {
std::lock_guard<std::mutex> lock(mtx); // 再次锁同一个mutex,死锁!
// 业务逻辑...
}
死锁原因: std::mutex 不支持递归加锁(也称为可重入锁)。同一线程试图第二次获取已持有的互斥锁时,标准行为是阻塞(导致死锁)或抛出异常。
✅ 正确解决方案
方案1: 使用递归锁 std::recursive_mutex
将普通的互斥锁替换为递归锁,允许同一线程多次加锁。
std::recursive_mutex mtx; // 改用递归锁
void func_a() {
std::lock_guard<std::recursive_mutex> lock(mtx);
func_b(); // 可以再次加锁,不会死锁
}
void func_b() {
std::lock_guard<std::recursive_mutex> lock(mtx);
// 业务逻辑...
}
注意: 递归锁通常性能稍差,且可能掩盖糟糕的设计。它需要被解锁与加锁次数相同的次数。
方案2: 重构代码,避免递归锁(更推荐)
更好的做法是重新设计代码结构,将需要加锁的公共逻辑提取出来。
std::mutex mtx;
void func_a() {
std::lock_guard<std::mutex> lock(mtx);
// 业务逻辑...
func_b_internal(); // 调用不加锁的内部函数
}
void func_b() {
std::lock_guard<std::mutex> lock(mtx);
func_b_internal();
}
void func_b_internal() {
// 实际的业务逻辑,不加锁
// 调用者保证已经持有锁
}
场景4: 条件变量的死锁陷阱
条件变量 (std::condition_variable) 使用不当也会导致死锁,这个陷阱很多人没有意识到。
💀 问题代码
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// 生产者
void producer() {
ready = true; // 没有在锁保护下修改共享变量!
cv.notify_one();
}
// 消费者
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // 可能永久等待!
// 业务逻辑...
}
死锁原因: 消费者线程调用 wait() 时会释放锁并等待通知。如果生产者线程的 notify_one() 在消费者调用 wait() 之前就已经执行,那么这个通知就会“丢失”。随后进入等待的消费者线程将永远等不到下一次通知,因为条件(ready)已经为真,但通知事件已经错过。
✅ 正确解决方案
共享变量的修改必须在 mutex 保护下进行
确保条件变量的判断条件(ready)的修改与检查都在锁的保护下,或者修改后能被正确观察到。
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// 生产者
void producer() {
{
std::lock_guard<std::mutex> lock(mtx);
ready = true; // 在锁保护下修改条件变量
}
cv.notify_one(); // 可以在锁外通知(性能更好)
}
// 消费者
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return ready; }); // 即使通知先到,lambda也会检测到ready为true
// 业务逻辑...
}
使用带谓词的 wait() 可以防止“丢失通知”的问题,因为即使通知先发生,线程被唤醒后也会重新检查谓词条件。
场景5: 线程 join 引起的死锁
没有锁也能死锁?是的!线程间的相互等待,例如 std::thread::join(),也可能形成循环等待,导致死锁。
💀 问题代码
std::thread t1, t2;
void thread1_func() {
// 业务逻辑...
t2.join(); // 等待t2结束
}
void thread2_func() {
// 业务逻辑...
t1.join(); // 等待t1结束
}
int main() {
t1 = std::thread(thread1_func);
t2 = std::thread(thread2_func);
// 两个线程互相等待,死锁!
t1.join();
t2.join();
}
死锁原因: t1 在等待 t2 结束,而 t2 同时在等待 t1 结束,形成了典型的循环等待。
✅ 正确解决方案
不要让线程互相 join,在主线程统一管理线程生命周期
线程的汇合(join)工作应交由创建它们的主线程(或一个专门的管理线程)来负责。
std::thread t1, t2;
void thread1_func() {
// 业务逻辑,不join其他线程
}
void thread2_func() {
// 业务逻辑,不join其他线程
}
int main() {
t1 = std::thread(thread1_func);
t2 = std::thread(thread2_func);
// 在主线程按顺序join
t1.join();
t2.join();
}
场景6: 持锁时间过长导致的隐性死锁
这种场景并非严格意义上的死锁(最终可能解开),但会让程序“看起来像死锁”,严重降低并发性能。
💀 问题代码
std::mutex io_mutex;
void thread_func() {
std::lock_guard<std::mutex> lock(io_mutex);
// 持锁期间做耗时操作
std::cout << "Processing..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(10)); // 模拟耗时操作
// 其他线程会长时间阻塞
}
问题: 其他需要获取 io_mutex 的线程会被长时间阻塞,导致系统吞吐量急剧下降,从用户角度看与死锁无异。
✅ 正确解决方案
缩小临界区,减少持锁时间
只在对共享资源进行访问的“临界区”内持有锁,将耗时的计算、I/O 操作等移出锁的保护范围。
std::mutex io_mutex;
void thread_func() {
// 耗时操作放在锁外
std::this_thread::sleep_for(std::chrono::seconds(10));
// 只在必要时加锁
{
std::lock_guard<std::mutex> lock(io_mutex);
std::cout << "Processing..." << std::endl;
} // 尽快释放锁
}
这是提高多线程程序性能的关键原则之一。
场景7: 对象交换时的死锁(高级场景)
这是一个非常隐蔽的死锁场景,经常出现在需要同时锁定两个对象进行操作的情况下,比如银行账户间的转账。
💀 问题代码
class Account {
mutable std::mutex mtx;
int balance;
public:
void transfer(Account& to, int amount) {
std::lock_guard<std::mutex> lock1(this->mtx); // 锁自己
std::lock_guard<std::mutex> lock2(to.mtx); // 锁对方
this->balance -= amount;
to.balance += amount;
}
};
// 使用
Account acc1, acc2;
std::thread t1([&]{ acc1.transfer(acc2, 100); }); // acc1 -> acc2
std::thread t2([&]{ acc2.transfer(acc1, 50); }); // acc2 -> acc1,死锁!
死锁原因: t1 锁定 acc1 后尝试锁 acc2,而 t2 锁定 acc2 后尝试锁 acc1,加锁顺序相反,形成循环等待。
✅ 正确解决方案
使用唯一ID确定全局加锁顺序
为每个对象分配一个唯一的ID(如创建顺序),在需要锁多个对象时,始终按照ID从小到大的顺序加锁。
class Account {
static std::atomic<unsigned int> next_id;
const unsigned int id;
mutable std::mutex mtx;
int balance;
public:
Account() : id(next_id++), balance(0) {}
void transfer(Account& to, int amount) {
if (this == &to) return; // 防止自己给自己转账
// 根据ID决定加锁顺序
std::mutex* first = (this->id < to.id) ? &this->mtx : &to.mtx;
std::mutex* second = (this->id < to.id) ? &to.mtx : &this->mtx;
std::lock_guard<std::mutex> lock1(*first);
std::lock_guard<std::mutex> lock2(*second);
this->balance -= amount;
to.balance += amount;
}
};
std::atomic<unsigned int> Account::next_id{0};
更简洁的方案:使用 std::scoped_lock (C++17)
std::scoped_lock 在构造多个锁时,内部会使用类似 std::lock 的算法来避免死锁,通常可以按锁对象的地址进行排序。
void transfer(Account& to, int amount) {
if (this == &to) return;
// std::scoped_lock 会自动处理加锁顺序
std::scoped_lock lock(this->mtx, to.mtx);
this->balance -= amount;
to.balance += amount;
}
📝 避免死锁的黄金法则
总结一下,要编写健壮的多线程程序,避免死锁,请牢记以下原则:
- 使用RAII管理锁: 永远优先使用
lock_guard, unique_lock 或 scoped_lock,避免手动调用 lock()/unlock(),这是利用智能指针等现代 C++ 特性保障资源安全的核心思想。
- 统一加锁顺序: 当需要获取多个锁时,保证所有线程都遵循一个全局一致的加锁顺序。
- 使用
std::lock 或 std::scoped_lock: 需要同时获取多个锁时,使用这些工具可以原子性地获取,从根本上避免顺序问题。
- 避免嵌套锁: 尽量让设计简单,一次只持有一个锁。如果必须持有多个锁,仔细规划其生命周期。
- 缩小临界区: 尽量减少锁的持有时间,只在对共享数据操作的必要时段加锁。
- 条件变量要正确使用: 确保条件变量的状态变更(谓词)在互斥锁的保护下进行,并使用带谓词的
wait()。
- 不要循环等待: 避免线程间形成复杂的相互依赖和等待关系,如互相
join。
理解和避免死锁是掌握并发编程的必修课。希望本文剖析的这7个场景能帮助你更好地诊断和预防代码中的并发问题。如果你对更多 C++ 并发、网络或系统编程的深入讨论感兴趣,欢迎关注云栈社区,与其他开发者一起交流学习。