【CPP】死锁产生、排查、避免
一、死锁产生
死锁是指两个或多个线程互相等待对方释放资源,导致程序无法继续执行的现象。在多线程编程中,死锁是一种常见且严重的并发问题。死锁产生必须要四个条件同时满足才会发生:
- 互斥条件:某些资源只能由一个线程占用。
- 占有且等待:线程已经占有至少一个资源,同时等待其他资源。
- 不可剥夺:资源不能被强制剥夺,只能由持有线程主动释放。
- 环路等待:多个线程形成环形等待链,导致死锁。
二、死锁排查
假如我们有如下代码需要排查死锁。
#include <iostream>
#include <thread>
#include <mutex>std::mutex mtx1;
std::mutex mtx2;void thread_func1() {std::lock_guard<std::mutex> lock1(mtx1); // 锁定 mtx1std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟工作std::lock_guard<std::mutex> lock2(mtx2); // 尝试锁定 mtx2
}void thread_func2() {std::lock_guard<std::mutex> lock1(mtx2); // 锁定 mtx2std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟工作std::lock_guard<std::mutex> lock2(mtx1); // 尝试锁定 mtx1
}int main() {std::thread t1(thread_func1);std::thread t2(thread_func2);t1.join();t2.join();return 0;
}
由于主线程的存在,在后续的表述中
t1对应线程2,t2对应线程3
1)Linux命令
通过Linux命令初步排查判断死锁的发生。
1. ps
查看进程状态:ps -T -p <pid> -o pid,tid,user,stat,cmd
- -T:显示指定进程的所有线程
- -p <pid>:指定进程的 PID
- -o:自定义输出的列,后面可以指定字段列表,用逗号分隔。
Ubuntu ❯❯❯ ps -T -p 2311810 -o pid,tid,user,stat,cmdPID     TID USER     STAT CMD
2311810 2311810 Raizero+ Sl+  ./deadlock
2311810 2311811 Raizero+ Sl+  ./deadlock
2311810 2311812 Raizero+ Sl+  ./deadlock
- 线程状态(STAT):所有的线程都处于 S(Sleeping)状态。S表示线程在等待某些操作(如 I/O、资源等),并且处于可中断的睡眠状态。此时线程可能处于的正常状态,而不是死锁的直接标志。死锁线程可能处于S或D状态,需结合工具进一步分析。
2. top
实时监控线程和进程:top -H -p <pid>
- -H:显示线程信息(Thread)
- -p:指定进程的 PID(Process ID)
- <pid>:可能发生死锁的进程pid
 进程号 USER      PR  NI    VIRT    RES    SHR    %CPU  %MEM     TIME+ COMMAND                                   
2292389 Raizero+  20   0   87992   2880   2880 S   0.0   0.0   0:00.00 deadlock                                  
2292390 Raizero+  20   0   87992   2880   2880 S   0.0   0.0   0:00.00 deadlock                                  
2292391 Raizero+  20   0   87992   2880   2880 S   0.0   0.0   0:00.00 deadlock     
- 所有线程的 %CPU和%MEM都是 0.0,说明它们没有占用 CPU 资源,也没有执行任何工作。
- 线程正在处于某种等待状态,而没有消耗 CPU 时间。死锁中,线程通常会持续等待并且不会执行任何任务,可能会表现出这种行为。
2)工具
1. gdb
使用 gdb 附加到正在运行的进程:gdb -p <pid>
- -p:表示附加到正在运行的进程
- <pid>:目标进程的进程 ID
Ubuntu ❯❯❯ gdb -p 2301629
...
__futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=2301630, futex_word=0x7c29d5a00910) at ./nptl/futex-internal.c:57
...
显示了一个关于 futex 的调用,futex 是一种用于线程同步的机制,通常与死锁或等待条件相关。这里的错误表明某个线程在等待某个 futex 锁,但 gdb 不能找到相关的源文件,因此它无法提供更多的调试信息。需要进一步调试:
-  查看所有线程的堆栈: info threads(gdb) info threads ··· Thread 0x7c29d62ac740 (LWP 2301629) "deadlock" __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=2301630, futex_word=0x7c29d5a00910) at ./nptl/futex-internal.c:57 ···- 这表示线程 1 正在调用 futex等待操作,可能是等待某个资源或锁的释放。
 ... Thread 0x7c29d5a00640 (LWP 2301630) "deadlock" futex_wait (private=0, expected=2, futex_word=0x63974eebd1a0 <mtx2>) at ../sysdeps/nptl/futex-internal.h:146 ...- 线程 2 正在等待 mtx2锁。
 ··· Thread 0x7c29d5000640 (LWP 2301631) "deadlock" futex_wait (private=0, expected=2, futex_word=0x63974eebd160 <mtx1>) at ../sysdeps/nptl/futex-internal.h:146 ···- 线程 3 正在等待 mtx1锁。
 从这些信息可以推断出死锁的可能性。线程 2 正在等待 mtx2锁,而线程 3 正在等待mtx1锁。考虑到这两个锁的互相依赖性,很可能发生了一个循环依赖(死锁):- 线程 1 可能持有 mtx1或mtx2锁,并且等待另一个锁。
- 线程 2 等待 mtx2,而线程 3 等待mtx1,它们互相等待对方释放锁,导致无法继续执行。
 
- 这表示线程 1 正在调用 
-  检查线程堆栈: thread <thread> bt(gdb) thread 1 bt [Switching to thread 1 (Thread 0x7c29d62ac740 (LWP 2301629))] #0 __futex_abstimed_wait_common64 (private=128, cancel=true, abstime=0x0, op=265, expected=2301630, futex_word=0x7c29d5a00910) at ./nptl/futex-internal.c:57 57 in ./nptl/futex-internal.c(gdb) thread 2 bt [Switching to thread 2 (Thread 0x7c29d5a00640 (LWP 2301630))] #0 futex_wait (private=0, expected=2, futex_word=0x63974eebd1a0 <mtx2>) at ../sysdeps/nptl/futex-internal.h:146(gdb) thread 3 bt [Switching to thread 3 (Thread 0x7c29d5000640 (LWP 2301631))] #0 futex_wait (private=0, expected=2, futex_word=0x63974eebd160 <mtx1>) at ../sysdeps/nptl/futex-internal.h:146- 线程 1 正在执行 __futex_abstimed_wait_common64,这表示它在等待某个资源或锁释放。
- 线程 2 和 线程 3 都在执行 futex_wait,分别等待mtx2和mtx1锁的释放。
 
- 线程 1 正在执行 
这些信息表明,线程 2 和线程 3 正在等待彼此持有的锁,这可能是死锁的典型表现。死锁的发生通常是由于线程形成了循环等待的依赖关系:
- 线程 2 等待 mtx2锁,而mtx2锁由线程 3 持有。
- 线程 3 等待 mtx1锁,而mtx1锁由线程 2 持有。
2. valgrind
使用其线程分析工具 Helgrind 检测死锁:valgrind --tool=helgrind <process_name>
Ubuntu ❯❯❯ valgrind --tool=helgrind ./a.out
-  锁的首次获取路径 -  mtx1 ( 0x10E160) 首次被线程2获取:==2305017== Lock at 0x10E160 was first observed ==2305017== by 0x109365: thread_func1() (deadlock.cc:9)对应 thread_func1()中第9行的std::lock_guard<std::mutex>锁定mtx1。
-  mtx2 ( 0x10E1A0) 首次被线程3获取:==2305017== Lock at 0x10E1A0 was first observed ==2305017== by 0x10946C: thread_func2() (deadlock.cc:17)对应 thread_func2()中第17行的std::lock_guard<std::mutex>锁定mtx2。
 
-  
-  线程终止时的锁持有状态 -  线程2(持有 mtx1)尝试获取mtx2时被阻塞:==2305017== Thread #2: Exiting thread still holds 1 lock ==2305017== by 0x1093BA: thread_func1() (deadlock.cc:12)对应 thread_func1()中第12行尝试获取mtx2时卡住(例如std::lock_guard<std::mutex> lock2(mtx2))。
-  线程3(持有 mtx2)尝试获取mtx1时被阻塞:==2305017== Thread #3: Exiting thread still holds 1 lock ==2305017== by 0x1094C1: thread_func2() (deadlock.cc:20)对应 thread_func2()中第20行尝试获取mtx1时卡住(例如std::lock_guard<std::mutex> lock1(mtx1))。
 
-  
根据以上信息说明两个线程在终止前未能释放已获得的锁,导致其他线程无法获取这些锁。Thread #2(假设对应代码中的线程1)在 thread_func1() 中先锁定 mtx1,然后尝试获取 mtx2。Thread #3(假设对应代码中的线程2)在 thread_func2() 中先锁定 mtx2,然后尝试获取 mtx1。这种交叉锁定形成了循环等待,是死锁的典型条件。
3. 日志
在代码中加入日志记录,标注线程锁定和解锁的时间点、资源ID等信息,以分析死锁发生的位置。我们对于死锁代码加入如下日志:
#include <iostream>
#include <thread>
#include <mutex>std::mutex mtx1;
std::mutex mtx2;void thread_func1() {std::cout << "Thread 1: Trying to lock mtx1\n";std::lock_guard<std::mutex> lock1(mtx1); // 锁定 mtx1std::cout << "Thread 1: Acquired mtx1\n";std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟工作std::cout << "Thread 1: Trying to lock mtx2\n";std::lock_guard<std::mutex> lock2(mtx2); // 尝试锁定 mtx2std::cout << "Thread 1: Acquired mtx2 and completed\n";
}void thread_func2() {std::cout << "Thread 2: Trying to lock mtx2\n";std::lock_guard<std::mutex> lock1(mtx2); // 锁定 mtx2std::cout << "Thread 2: Acquired mtx2\n";std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟工作std::cout << "Thread 2: Trying to lock mtx1\n";std::lock_guard<std::mutex> lock2(mtx1); // 尝试锁定 mtx1std::cout << "Thread 2: Acquired mtx1 and completed\n";
}int main() {std::thread t1(thread_func1);std::thread t2(thread_func2);t1.join();t2.join();return 0;
}
-  线程 1 停止在尝试锁定 mtx2:此时mtx1被线程 1 持有,而mtx2被线程 2 持有,两个线程互相等待解锁。Thread 2: Trying to lock mtx2 Thread 2: Acquired mtx2 Thread 2: Trying to lock mtx1 Thread 1: Trying to lock mtx1 Thread 1: Acquired mtx1 Thread 1: Trying to lock mtx2
-  线程 2 停止在尝试锁定 mtx1:情况相同,线程 1 和线程 2 分别持有一个锁,互相等待。Thread 1: Trying to lock mtx1 Thread 1: Acquired mtx1 Thread 1: Trying to lock mtx2 Thread 2: Trying to lock mtx2 Thread 2: Acquired mtx2 Thread 2: Trying to lock mtx1
通过分析日志,如果发现某些线程的锁定日志输出到一半停止,后续锁未能成功获取且程序未继续运行,则可以推测程序可能发生了死锁。
三、死锁预防与解决
实际上来讲死锁几乎无法避免,我们只能说尽量避免并在死锁发生时利用各种手段解决。
1. 使用C++ RAII,避免忘记解锁造成的死锁
利用RAII(Resource Acquisition Is Initialization)机制,通过对象的构造和析构自动管理锁的生命周期,确保异常安全。
-  std::lock_guardstd::mutex mtx; {std::lock_guard<std::mutex> lock(mtx); // 自动加锁// 临界区操作 } // 离开作用域自动解锁
-  std::unique_lockstd::mutex mtx; try {std::unique_lock<std::mutex> lock(mtx);// 可能抛出异常的操作 } catch (...) {// 锁会在栈展开时自动释放 }
优势:
- 避免因忘记调用unlock()导致的死锁。
- 异常安全:即使临界区代码抛出异常,析构时仍会释放锁。
2. 使用c++ std::lock锁,来避免多次加锁顺序导致的死锁
std::lock(mtx1, mtx2, ...)原子性地同时锁定多个互斥量,避免因不同线程加锁顺序不一致导致的死锁。
std::mutex mtx1, mtx2;
{std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);std::lock(lock1, lock2); // 原子性同时加锁// 操作共享资源
} // 自动解锁
关键点:
- 结合std::adopt_lock标记表示锁已被获取,避免重复加锁。
- 必须使用std::unique_lock(std::lock_guard不支持手动管理)。
3. 递归锁解决单线程重复加锁问题
使用std::recursive_mutex允许同一线程多次加锁。
std::recursive_mutex rmtx;
{std::lock_guard<std::recursive_mutex> lock1(rmtx); // 第一次加锁{std::lock_guard<std::recursive_mutex> lock2(rmtx); // 同一线程内再次加锁}
}
关键点:
- 递归锁性能低于普通锁,且需确保lock()与unlock()次数匹配。
- 优先考虑重构代码逻辑,避免嵌套加锁。
4. 避免嵌套锁
减小临界区范围仅在必须访问共享资源时加锁,或者将临界区代码提取为独立函数,减少锁的嵌套层次。
void critical_operation() {std::lock_guard<std::mutex> lock(mtx);// 仅包含必须同步的操作
}void outer_function() {// 非临界区代码critical_operation(); // 调用独立加锁的函数
}
5. 使用锁顺序
全局约定所有线程以相同顺序获取锁,破坏循环等待条件。
// 总是先锁地址最小的
void lock_in_order(std::mutex& mtx1, std::mutex& mtx2) {if (&mtx1 < &mtx2) {std::lock_guard<std::mutex> lock1(mtx1);std::lock_guard<std::mutex> lock2(mtx2);} else {std::lock_guard<std::mutex> lock2(mtx2);std::lock_guard<std::mutex> lock1(mtx1);}
}
按地址排序的锁顺序策略可能因平台或编译器差异失效。
6. 锁超时
使用try_lock_for()或try_lock_until()设置超时。
std::timed_mutex tmtx;
if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {// 成功获取锁tmtx.unlock();
} else {// 超时处理
}
7. 原子操作
使用原子变量彻底避免锁的使用,消除死锁风险。
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
8. 检测并恢复
- 动态检测:记录锁获取顺序,检测循环等待。
- 超时回滚:设定超时时间,超时后释放资源并重试。
9. 数据分区
通过分区技术避免不同线程同时访问同一个资源,消除死锁可能性。
- 将数据划分为多个独立的部分,分配给不同的线程。
- 常用于多线程处理大规模数据的场景。
