概念
死锁的四个必要条件
- 互斥
- 至少有一个资源必须处于非共享模式,即一次只有一个进程可使用。如果另一进程申请该资源,那么申请进程应等到该资源释放为止
- 占有并等待
- —个进程应占有至少一个资源,并等待另一个资源,而该资源为其他进程所占有
- 非抢占
- 资源不能被抢占,即资源只能被进程在完成任务后自愿释放
- 循环等待
- 有一组等待进程 {P0,P1,…,Pn},P0 等待的资源为 P1 占有,P1 等待的资源为 P2 占有,……,Pn-1 等待的资源为 Pn 占有,Pn 等待的资源为 P0 占有
注:可调度的任务都可能引发死锁,进程只是可调度的一个基本单位
避免死锁
互斥是资源自身的属性,而破坏任一其他三个条件即可以避免死锁发生
比如《深入理解计算机》提到的一种简单面有效的避免死锁的规则(互斥锁加锁顺序规则):给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得(多个)互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。这相当于破坏了“循环等待”条件(因为按相同顺序加解锁不会有循环等待)
实践分析
多线程读写锁死锁
读写锁适合于读多写少的场景,它允许多个线程同时加读锁,但读锁与写锁、写锁与写锁是互斥的
问题背景
本地缓存通常基于并发安全的map结构实现,比如TBB的ConcurrentHashMap,对ConcurrentHashMap的并发访问需要通过两种 accessor (可理解为map中pair的智能指针):
- tbb::concurrent_hash_map<TKey, HashMapValue, THash>::const_accessor
- Reader lock – permits shared access with other readers
- tbb::concurrent_hash_map<TKey, HashMapValue, THash>::accessor
- Writer lock – permits exclusive access by a thread. Blocks access by other threads
每个请求包含许多keys,服务内部将keys分若干批,每批由一个线程处理。服务中有两个本地缓存 A 和 B,线程中执行的一个步骤是读取 A 与 B 中指定key对应的value,伪代码如下:
const_accessor_a;
const_accessor_b;
find_a = A.find(const_accessor_a, key_a);
find_b = B.find(const_accessor_b, key_b);
if( find_a && find_b ) {
// read the data
} else {
if( !find_a ) {
// calculate the data
A.insert(key_a, data); // there is an accessor in insert member function of local cache
}
if( !find_b ) {
// calculate the data
B.insert(key_b, data); // there is an accessor in insert member function of local cache
}
}
问题表现
某一时刻后每秒内大量错误日志:"thriftServerEventHandler: accept() Too many open files",并且在另一时刻后不再打印日志;业务监控显示另一时刻后服务不再处理请求,主机监控显示 CPU 利用率不太稳定,在发现问题之前较长一段时间内服务连接数持续增加;线上环境不固定的单个服务偶发这种问题,重启后恢复正常
分析步骤
- 由于问题现象是服务hang住了,所以最初的猜测就是有死锁
- 线下使用测试流量压测没有复现问题
- 等待线上复现时,使用pstack获取服务的线程栈快照
- 分析glog日志,根据日志内容确定thrift worker线程号,然后在线程栈中找到了所有worker线程都阻塞在等待某个结果上,而产生这个结果的调用是一个比较长的流程
- 再次分析glog日志,查找所有worker进程打印的最后一条日志都在同一个操作之后(之后就没有打印日志了),但还是无法准确定位
- 分析线程栈,查找wait操作,由于服务开了许多线程池,线程池中的线程也大多处于等待任务状态,所以没有太大收获
- 分析第四步定位出的操作之后的所有代码,在分析上述访问两个缓存的代码时发现可疑点:
time | thread1 | thread2 | thread3 |
---|---|---|---|
0 | A.find(key)==false | ||
1 | B.find(key)==false | ||
2 | A.find(key)==false | ||
3 | A.insert(data) //add write lock and release | ||
4 | A.find(key)==true // add read lock | ||
5 | B.find(key)==false | ||
6 | B.insert(data) //add write lock and release | ||
7 | B.find(key)==true //add read lock | ||
8 | A.insert(data) //need add write lock | B.insert(data) // need add write lock |
- 即当key_a与key_b相同时可能会触发死锁,而对应的worker线程又在等待这两个线程处理的结果,它也被阻塞住
- 相同的key可能来自同一请求,也可能来自不同请求
解决方案
缩小持有锁的范围,相当于破坏了循环等待的条件,伪代码如下:
{
const_accessor_a;
const_accessor_b;
find_a = A.find(const_accessor_a, key_a);
find_b = B.find(const_accessor_b, key_b);
if( find_a && find_b ) {
// read the data
}
}
if( !find_a ) {
// calculate the data
A.insert(key_a, data); // there is an accessor in insert member function of local cache
}
if( !find_b ) {
// calculate the data
B.insert(key_b, data); // there is an accessor in insert member function of local cache
}
另外使用std::future的wait_for成员函数等等
多线程互斥锁死锁
问题背景
客户端与服务端的通信采用 RPC (Thrift 0.12.0)
问题表现
在某一时刻服务业务监控显示不再处理请求,thrift排队任务数量保持设置的最大值,服务主机监控显示 CPU 使用率非常低;线上偶发集群故障,重启集群后恢复正常
分析步骤
0 线下使用测试流量压测,复现问题
1 修改 thrift IDL 中定义的函数定义为空转或者sleep一段时间,仍然可以复现问题,基本定位到是在thrift代码中出现问题
2 使用pstack获取服务线程栈快照,并重点分析wait操作
3 分析Thrift TNonblockingServer源码
4 梳理源码过程中发现可疑点,参考任务数量超出最大限制的死锁情况与过期任务不恰当处理的死锁情况
解决方案
第一种死锁情况实际上是可用的worker数量上的死锁,worker线程等待 IO 线程发送完它的数据才变为可用,IO 线程等待可用的worker线程处理新任务,可以设置添加任务时阻塞状态的超时时间,即破坏循环等待条件;第一种解决方案同时也可以解决第二种情况,它还有一种解决方案是缩小持有锁的范围,即破坏占有并等待条件
对于这种 IO 线程死锁的情况,其实也可以通过一个测试用的client发送请求,判断连接是否能建立,从而快速定位
参考
什么是死锁,死锁的原因及解决办法
Concurrent Access
Correctly using an accessor in tbb
https://github.com/apache/thrift/tree/0.12.0