程序员的踩坑经验总结(四):死锁

死锁也是程序员最常见的问题之一了,但是死锁跟内存泄露不同,原理和原因都相对简单,简单说就是你等我,我也等你,就这么耗着!

但死锁的影响有时比内存泄露更严重。内存泄露主要是渐进式的,可能重启一下就可以从头开始了。而死锁是重启不了,这只是直接影响而已。死锁一般会出现某个功能或者操作无反应,可能进一步没有了心跳而下线,服务停止。而一般的看门狗也发现不了,进程还在。一般都需要手动杀进程。所以对于绝大多数的业务都是不可以接受的。

而造成死锁的原因差别也比较大,有的可能只是程序员的一时疏忽,可有的也会让你头痛。

我们以前平台的死锁也是家常便饭,我记得的常见的有两种情况。

(一)锁跨度很大,代码的跨度,看上去两个不怎么相关的类,竟然在互相调用!还带着锁。我印象中我们的流媒体出现过的一次死锁,就是有两个TCP session各自的两个函数在嵌套调用。

(二)一把锁,涉及范围很大,就是四五种情况,例如四五个函数,都用同一把锁。虽然也在一个类里面,但是类很长,带有同一把锁的函数之间就可能出现互相调用。

一看就知道都是设计的问题,不出问题才怪。可是问题要解决啊,针对这些问题,后面我琢磨出了一套方法。

 

案例分析

案例有点久远,当时没有留下文档,所幸代码还在,针对上面第二种情况的。所以只能是稍微描述下当时的情况和截图看看最后是如何解决的。

首先我们看下这个类有多长:

    

有没有傻眼。这又要勾起我多少痛苦的回忆。也好吧,让你们开心一下。不过你们也开心不了多久,我都有解决之道:)

看看我留下的痕迹:

     

改动了31行,这还只是关于关键字的搜索。有多少个函数,你猜,哈。我们主要看后面的注释,有两次提到“可能同时”调用或者进来。你也可以看到,我的解决方法是使用了位运算

这一招又是从上一家学来的。其实现在看很多开源库和内核都是大量使用了位运算,很多文档也提到了,像Redis文件系统虚拟内存等。

我们再来看看定义:

    

老的锁已经放注释里面的了,锁的对象是一个链表list。新添加了一个整型变量,把变量的几个值定义成一个枚举类型。

所以这几个情况就代表了几种功能,这里是四种情况,可是实现类里面却有31处!你说能不死锁吗?

 

我们再次还原下当时的情景。

这个list是文件列表,而它的业务无非是增删改查。如果设计简单的话,一把锁也够了。但是真正简单设计有这么容易吗?

我们又回到这个类,第一个截图显示2500行,根据设计基本原则,一般一个类不能超过1000行。这里早就可以划分至少三个类了。

怎么划分,有人会建议把这个list单独拿出去,是,我也想过。但是关系复杂了,所以我们又到了第二张图,你看涉及到的函数只会有增删改查吗?

和其他的对象和方法交织在一起了!要想抽丝剥离,只能重构!事实上,后面都重构了。

但是问题要解决。重构是后面的事,一旦出现这种严重问题,当下就是解决问题。所以我后面去掉了锁,重现定义了新的变量。具体怎么弄? 

见最后这张图,一个变量四个值,但是这四个值可不是连续的,看到了吗,0、1、2、4,为什么?

因为要实现二进制运算,所以他们的的二进制位对应就是,0000、0001、0010、0100。每个值用一位表示一种操作,互不干扰。该位为1表示占用,如果是0表示未占用。代表了以前的锁状态。

所以虽然锁没有了,但是(锁的)功能还是有的。这是一个方面,不能影响原有的功能,原来的样子(虽然不好看,但是不能再引发其他问题了)。另一方面,问题也要解决,仍然是利用了这几个位!

上面的四个值,对应的不完全是增删改查,具体对应了:初始化、查、删、删并且加四个状态,但实际上操作是后三种。事实上初始化值0也可以说没有占位。

开始我们提到了每个位互不干扰,现在确定是三个位互不干扰。所以在进入某种操作时,首先判断当前状态,是可重入还是需要等待

例如说,如果当前只是查,那么继续查(另一个查操作)肯定没问题,而其他两种需要稍微等一下,这里的等待是20次sleep的20ms循环,只要查操作结束,马上进入下一步。

但是如果循环已经完成,而状态依然没变化,那么这里不等待了,直接退出。下次再进来询问。

所以这里不同的操作对应了不同的方式,因情况而异。这样就不会导致死锁。同时,这些改变都需要加日志跟踪,可以发现等待了多久,哪个函数占用时间太长,如果能减少该函数占用的时间就是最好的了。在实际项目中,能优化的也有。但有的就只能惊讶了,有碰到过一个方法里面有调用两个while嵌套循环,简单的计算也行了,有些循环里面还调用多个方法。所以只能用这种方法了。

 

当然这个解决方法是有点抽象,所以为了说清楚这个方法,我想了很久,其他部分早写完了,剩下这里反复改,希望你能看明白。

我后面再看分布式的锁的实现,原理和复杂程度也不过如此:),因为我们这些代码早就把我给臣服了:(

 

总结和建议

(一)原理与依据

我们上面提到了解决方法,那么它的理论依据是什么?

我们稍微窥视一下锁的实现。linux 2.6 kernel的源码,互斥锁所使用的数据结构:

这里只是列出了内核中,锁的定义,其实它的实现还有很多。有兴趣的可以看源码。我们回到这个主题,不知大家发现没有,其实锁的本质也是一个整型变量

而我就是利用了这个特性,当然也有一点自旋锁的特性。你可以再往会看,第二张图,其中有三处for循环,就是说我会根据情况进行判断和等待一会,但不是忙等待,就是说到了一定的时间后,我会强制改变状态和退出。所以和自旋锁又有不同

所以总结一下,原理很重要!

(二)死锁的预防

和内存泄露一样,死锁的预防也在于设计。所以代码的质量在于设计!这里同样只针对死锁的问题提几个建议。

1.减少锁定代码的范围

锁定的代码行数,一定用到的时候才用,只将相关的变量括起来。而不是锁定整个函数。

写段伪代码说明下。

std::mutex  m_mutex;

int  g_diff = 3;

int funA()

{

unique_lock<mutex> lock(m_mutex);

int a = 5;

//中间省去若干

return a+g_diff; 

} 

int funB()

{

int a = 5;

int b = 0;

  {

    unique_lock<mutex> lock(m_mutex);

    b = a+g_diff; 

  }

//中间省去若干

return b;

} 

函数funB肯定比函数funA更好。

2.降低锁的粒度

通常,一个变量一把锁,或者一个功能点一把锁,而不是一个类一把锁。

那有的人会说如果要锁住一个类,怎么办?

我见过的只有在一种情况下一个类才需要用到锁,就是把这个类当变量使用。所以这种情况也可以归纳到一个变量,或者说一个对象。而这种情况一般用在单例模式中,所以即使锁住也不可能出现方法的嵌套而导致死锁。如果你还没明白过来,我建议你看下单例模式的定义,我后面也还会有文章将会介绍。很快,后面第二篇吧。

而且这里说的一个变量,或者一个功能点要职责单一。一个类何尝不是如此!

案例里面其实就是函数的功能模糊,类的职责模糊,估计当时都没有设计,反正把相关的都放一起,一锅乱炖!

所以这是设计和开发里面的大忌!后面就是改不完的Bug、踩不完的坑。。

3.减少锁的使用

尽量不用锁、少用锁。非用不可才用锁。

一方面因为多了容易造成死锁,另一方面锁有一定的消耗。上面提到的源码只是一个定义而已,而它的实现不仅仅有几处循环,还有回调函数。

当然,这一点说起来容易,做起来难!具体怎么少用,有没有好的方法?

我的回答当然是有,请听下回分解。