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

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

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

而形成死锁的缘由差异也比较大,有的可能只是程序员的一时疏忽,可有的也会让你头痛。程序员

咱们之前平台的死锁也是屡见不鲜,我记得的常见的有两种状况。session

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

(二)一把锁,涉及范围很大,锁定一个对象的操做可能已经有四五种,可是涉及使用到的函数倒是翻倍甚至几十个都有可能。虽然也在一个类里面,可是类很长,带有同一把锁的函数之间就可能出现互相调用。分布式

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

 

案例分析

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

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

    

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

看看我留下的痕迹:

     

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

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

咱们再来看看定义:

    

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

因此这几个状况就表明了几种功能,这里是四种状况,但是实现类里面却有31处!你说能不死锁吗?

 

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

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

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

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

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

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

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

由于要实现二进制运算,因此他们的的二进制位对应就是,0000、000一、00十、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.减小锁的使用

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

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

固然,这一点提及来容易,作起来难!具体怎么少用,有没有好的方法?

个人回答固然是有,请听下回分解。

相关文章
相关标签/搜索