在并发编程中,“死锁”是每一个工程师都绕不开的问题。它不会导致程序崩溃,却能让系统彻底卡死,而且调试极其困难。
本篇我们专注于一个目标:彻底理解什么是死锁,它是怎么发生的,在真实开发中长什么样。
一、死锁是什么?
死锁(Deadlock)是指两个或多个线程在运行过程中因争抢资源而导致互相等待的状态,如果没有外力干预,它们将永远无法继续执行。
通俗理解:
线程A拿着资源1,等资源2;线程B拿着资源2,等资源1;
双方都不释放手上的资源,程序就这样“卡死”了。
二、死锁的四个必要条件(Coffman 条件)
条件名 | 含义 |
---|---|
互斥 | 资源一次只能被一个线程占用 |
占有且等待 | 线程持有一个资源的同时还要等待其他资源 |
不可剥夺 | 已分配给线程的资源不能强制剥夺,只能线程自己释放 |
循环等待 | 存在一个资源等待链,线程之间形成环形依赖 |
四个条件缺一不可,只要打破任意一个,就能避免死锁。
三、真实场景:线程交叉访问两个共享文件
背景设定:
系统中存在两个关键文件资源:
log.txt
:日志文件,多个线程要写入config.json
:配置文件,多个线程要读取并更新
存在两个线程任务:
- 线程A:先写日志,再更新配置
- 线程B:先更新配置,再写日志
问题来了:
- 如果两个线程加锁顺序不同,就可能互相卡住,进入死锁。
四、清晰完整的实战代码示例
#include <iostream>
#include <fstream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex logFileMutex;
std::mutex configFileMutex;
void writeLogThenUpdateConfig() {
std::cout << "[线程A] 尝试加锁 log.txt...\n";
logFileMutex.lock();
std::cout << "[线程A] 成功加锁 log.txt\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟写日志
std::cout << "[线程A] 尝试加锁 config.json...\n";
configFileMutex.lock();
std::cout << "[线程A] 成功加锁 config.json\n";
std::cout << "[线程A] 写入日志 + 修改配置完成\n";
configFileMutex.unlock();
logFileMutex.unlock();
}
void updateConfigThenWriteLog() {
std::cout << "[线程B] 尝试加锁 config.json...\n";
configFileMutex.lock();
std::cout << "[线程B] 成功加锁 config.json\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟读配置
std::cout << "[线程B] 尝试加锁 log.txt...\n";
logFileMutex.lock();
std::cout << "[线程B] 成功加锁 log.txt\n";
std::cout << "[线程B] 修改配置 + 写入日志完成\n";
logFileMutex.unlock();
configFileMutex.unlock();
}
int main() {
std::thread t1(writeLogThenUpdateConfig);
std::thread t2(updateConfigThenWriteLog);
t1.join();
t2.join();
return 0;
}
五、输出与死锁现象
一种典型输出:
[线程A] 尝试加锁 log.txt...
[线程A] 成功加锁 log.txt
[线程B] 尝试加锁 config.json...
[线程B] 成功加锁 config.json
[线程A] 尝试加锁 config.json...
[线程B] 尝试加锁 log.txt...
然后……程序停住了。
没有崩溃、没有异常、没有后续输出。这就是死锁!
六、我们来剖析发生了什么
线程A | 线程B |
---|---|
锁住 log.txt | 锁住 config.json |
尝试锁 config.json(被B占用) | 尝试锁 log.txt(被A占用) |
两线程各自拿住一个资源,等待对方释放另一个资源,构成互相等待闭环,死锁就发生了。
七、实际项目中会遇到哪些类似场景?
场景 | 死锁可能表现 |
---|---|
多线程日志记录系统 | 多个线程抢写日志与状态文件 |
配置中心热更新机制 | 一线程读取配置、一线程写入日志 |
数据库读写操作 | 多线程同时读写同一组表结构/文件锁 |
驱动开发中对 I/O 资源的管理 | 控制器状态与硬件状态锁重叠 |
多模块并行执行 | 各模块使用全局共享资源顺序不同 |
八、如何避免死锁?五个方法一表搞定
方法 | 原理与说明 |
---|---|
统一加锁顺序 | 所有线程按照相同顺序加锁资源,例如都先加 config.json 再加 log.txt |
使用 std::lock() | 一次性尝试加多个锁,避免交叉等待 |
使用 try_lock() 重试 | 若加锁失败则释放已获得资源并延迟重试,避免永久等待 |
缩短锁持有时间 | 尽量将资源访问逻辑控制在极小范围内,提高系统并发性 |
使用 std::scoped_lock | C++17 提供的自动管理多个锁的机制,避免写错加锁顺序 |
九、如何用 std::lock 一次性锁定多个资源?
下面是一个用 std::lock()
改写后的线程函数,安全且不会死锁:
void safeWriteLogAndUpdateConfig() {
std::lock(logFileMutex, configFileMutex); // 同时加锁两个资源
std::lock_guard<std::mutex> lock1(logFileMutex, std::adopt_lock);
std::lock_guard<std::mutex> lock2(configFileMutex, std::adopt_lock);
std::cout << "[SafeThread] 安全操作 log.txt 和 config.json\n";
}
十、死锁调试难点与建议
死锁调试难是因为:
- 无错误输出,程序只是“卡住”
- 与线程调度有关,不容易复现
- 只有 runtime 状态可分析,日志极其关键
调试建议:
- 开启详细日志(打印锁资源、线程 ID)
- 使用
htop
或gdb
查看线程状态 - 多线程关键路径单独测试,验证加锁顺序
- 使用工具:
valgrind
,perf
,strace
,threadsanitizer
十一、知识总结表
核心问题 | 说明 |
---|---|
死锁定义 | 多线程间因互相持锁而无限等待,程序无法继续 |
必要条件 | 互斥、占有且等待、不可剥夺、循环等待 |
实战示例 | 线程交叉访问 log.txt 和 config.json,顺序不同导致死锁 |
避免技巧 | 加锁顺序统一、try_lock、自旋重试、std::lock 等 |
调试方法 | 打日志 + GDB + strace/valgrind/perf 等系统工具分析 |
十二、结语:死锁不可怕,设计先预防
死锁是多线程系统中的“沉默杀手”,它不会引发异常,却会让程序永久卡住。在设计阶段就统一好资源加锁顺序、合理安排资源持有范围,往往比事后修复来得更容易、更可靠。
📌 建议练习:
- 修改本文例子,加入
try_lock
实现可恢复逻辑 - 用
std::scoped_lock
编写简洁的多资源加锁逻辑 - 在实际项目中搜索:
lock
,mutex
,分析是否存在加锁顺序不一致的问题
📽️ 视频讲解欢迎关注 B 站:“嵌入式 Jerry”