本文做者:杜雨阳
滴滴 | 高级专家工程师-Linux内核算法
导读:死锁是多线程和分布式程序中常见的一种严重问题。死锁是毁灭性的,一旦发生,系统很难或者几乎不可能恢复;死锁是随机的,只有知足特定条件才会发生,而若是条件复杂,虽然发生几率很低,可是一旦发生就很是难重现和调试。使用锁而产生的死锁是死锁中的一种常见状况。Linux 内核使用 Lockdep 工具来检测和特别是预测锁的死锁场景。然而,目前 Lockdep 只支持处理互斥锁,不支持更为复杂的读写锁,尤为是递归读锁(Recursive-read lock)。所以,Lockdep 既会出现由读写锁引发的假阳性预测错误,也会出现假阴性预测错误。本工做首先解密 Lockdep工具,而后提出一种通用的锁的死锁预测算法设计和实现(互斥锁能够看作只使用读写锁中的写锁),同时证实该算法是正确和全面的解决方案。bash
今年初,咱们相继解决了对滴滴基础平台大规模服务器集群影响严重的三个内核故障,在咱们解决这些问题的时候,不少时间和精力都花在去寻找是谁在哪里构成了死锁,延误了故障排除时间,所以当时就想有没有什么通用的方法可以帮助咱们对付死锁问题。可是由于时间紧迫,只能针对性地探索和处理这几个具体问题。在最终成功修复了这几个内核故障后,终于有一些时间静下来去深刻思考死锁发生的缘由和如何去检测和预测死锁。随着对这个问题的深刻研究,我相继作出了一些内核死锁预测方面的算法优化和算法设计工做,其中部分已经被 Linux 内核接收,其余还在评审阶段。在这里我和你们分享其中的一个比较重要的工做:一个通用的读写锁的死锁预测算法。这个工做提出了一个通用的锁的死锁预测算法,支持全部 Linux 内核读写锁,同时证实该算法是正确和全面的解决方案。这个算法所解决的核心问题已经存在超过10年以上(目前还在社区评审阶段)。在介绍这个工做的以前我首先对死锁问题和 Linux 内核死锁工具 Lockdep 作简要的介绍。服务器
死锁在平常生活中并不鲜见。生活在大城市的人都或多或少经历过下图所示的场景。在环岛或者十字路口出现的这种状况就是死锁。也许其中有车坏了,可是绝大多数车子是能够运行的。但是由于每辆车都得等着前车走动它才能走动,全部车都走不动,或者更通常地讲它们不能取得进展(Make Forward Progress)。这种状况发生的缘由是车辆的等待构成了循环,在这个循环中每辆车的状态都是等待前车,所以全部车都等不到到它所要等待的。这种车辆死锁状态会持续恶化并产生严重的后果:首先形成路口交通堵塞,而堵塞若是进一步扩大会致使大面积交通瘫痪。车辆死锁很难自愈,经过自身走出死锁状态很是困难或者须要很长时间,通常都只能经过人工(如交通警察)干预才能解决。网络
在多线程或者分布式系统程序中,死锁也会发生。其本质和上述的路口车辆堵塞是同样的,都是由于参与者构成了循环等待,使得全部参与者都等不到想要的结果,从而永远等在那里不能取得进展。Linux 内核固然也会发生死锁,若是核心部分(Core),如调度器和内存管理,或者子系统,如文件系统,发生死锁,都会致使整个系统不可用。多线程
死锁是随机发生的。就像上图中环岛的状况同样,环岛就在那里而死锁并非总在发生。可是环岛自己就是死锁隐患,尤为在交通压力比较大的路口,环岛会比较容易产生死锁。而若是这种路口设计成交通讯号灯就会好不少,若是设计成立交桥则又会好不少。在程序中,咱们把可能产生死锁的场景称做潜在死锁(Potential Deadlock Scenario),而把即将发生或正在发生的死锁称为死锁实例(Concrete Deadlock)。分布式
如何对付死锁一直是学术界和应用领域积极研究和解决的问题。咱们能够将对死锁的解决方案粗略地分为:死锁发现(Detection)、死锁避免(Prevention)和死锁预测(Prediction)。死锁发现是指在在程序运行中发现死锁实例;死锁避免则是在发现死锁实例即将生成时进一步防止这个实例;而死锁预测则是经过静态或者动态方法找出程序中的潜在死锁,从而从根本上预先消除死锁隐患。工具
在死锁中,由于用锁(Lock)不当而致使的死锁是一个重要死锁来源。锁是同步的一种主要手段,用锁是不可避免的。对于复杂的同步关系,锁的使用会比较复杂。若是使用不当很容易形成锁的死锁。从等待的角度来讲,锁的死锁是因为参与线程等待锁的释放,而这种等待构成了等待循环,如 ABBA 死锁:学习
其中,线程中的黑色箭头表明线程当前执行语句,红色箭头表示线程语句之间的等待关系。能够看到,红色箭头构成了一个圆圈(或者循环)。再一次回顾潜在死锁和死锁实例,若是这两个线程执行的时间稍有改变,那么颇有可能不会发生死锁实例,好比若是让 Thread1 执行完这一段代码 Thread2 才开始执行。可是这样的用锁行为(Locking Behavior)毫无疑问是一个潜在死锁。优化
进一步能够看出,若是咱们可以追踪并分析程序的用锁行为就有可能预测死锁或者找出潜在死锁,而不是等死锁发生时才能检测出死锁实例。Linux 内核的 Lockdep 工具就是去刻画内核的用锁行为进而预测潜在死锁并报告出来。spa
Lockdep 可以刻画出一类锁(Lock Class)的行为,主要是经过记录一类锁中全部锁实例的加锁顺序(Locking Order),即若是一个线程拿着锁A,在没有释放前又去拿锁B,那么锁A和锁B就有一个 A->B 的加锁顺序,在 Lockdep 中这个加锁顺序被称为:锁依赖 (Lock Dependency)。一样的,对于 ABBA 类型的死锁,咱们并不须要 Thread1 和 Thread2 刚好产生一个死锁实例,只要有线程产生了 A->B 加锁顺序行为,又有线程产生了一个 B->A 的加锁顺序行为,那么这就构成了一个潜在死锁,以下图所示:
由此推广开来,咱们能够把全部的加锁顺序(即锁依赖)记录和保存下来,构成一个加锁顺序图(Graph)。其中,若是有锁依赖 A->B ,又有锁依赖 B->C ,那么因为锁依赖的关系(Relation)是传递的(Transitive),所以咱们还能够获得锁依赖 A->C 。 A->B 和 B->C 称为直接依赖(Direct Dependency),而 A->C 称为间接依赖(Indirect Dependency)。对于每个新的直接锁依赖,咱们去检查这个依赖是否和图中已经存在的锁依赖构成一个循环,若是是的话,那么咱们就能够预测产生了一个潜在死锁。
刚才咱们所指的锁都是互斥锁(Exclusive Lock)。读写锁是一种更复杂的锁,或者说一种通用的锁(General Lock),咱们能够认为互斥锁是只用写锁的读写锁。只要没有写锁或者写锁的争抢,读锁容许读者(Reader)同时持有。 Linux 内核中有多种读写锁,主要包括: rwsem 、 rwlock 和 qrwlock 等。问题是,读写锁会让死锁预测变得异常复杂, Lockdep 就不能支持这几种读写锁,所以 Lockdep 在使用过程当中会产生一些相关的错误假阳性(False Positive)死锁预测和错误假阴性(False Negative)死锁预测。这个问题已经存在超过10年以上,咱们提出一个通用的锁的死锁预测算法,并证实这个算法解决了读写锁的死锁预测问题。
在描述这个算法的过程当中,咱们经过提出几个引理(Lemma)来解释或者证实咱们所提出的死锁预测的有效性。
基于引理1,解决死锁预测问题就是在最后一个拿锁顺序(即锁依赖)造成等待圆环(循环)时,经过某种方法计算出这个等待圆环是否构成潜在死锁,而咱们的任务就是找到这个方法(算法)。
对于任何一个死锁实例来讲,假定有 n 个线程参与到这个死锁实例中,这 n 个线程表示为:
T1,T2,…,Tn
复制代码
考虑 n 的状况:
若是 n=1:这种死锁即线程本身等待本身,在 Lockdep 中被称为递归死锁(Recursion Deadlock)。因为检查这种死锁较为简单,所以在下面的算法中忽略这种特殊状况。 若是 n>1:这种死锁在 Lockdep 中被称为翻转死锁(Inversion Deadlock)。对于这种状况,咱们将这 n 个线程分红两组,即 T1,T2,…,Tn-1 和 Tn ,而后把前一组中的全部锁依赖合并在一块儿并假想全部这些依赖存在于一个虚拟的线程中,因而获得两个虚拟线程 T1 和 T2 。
这就是引理2中所述的两个虚拟线程。基于引理2,咱们提出一个死锁检查双线程模型(Two-Thread Model)来表示内核的加锁行为:
T1 :当前检查锁依赖以前的全部锁依赖,这些依赖造成了一个锁依赖图。 T2 :当前的待检查的直接锁依赖。
基于引理2和死锁检查双线程模型,咱们能够获得以下引理:
基于上述3个引理,咱们能够进一步将死锁预测问题描述为,当咱们获得一个新的直接锁依赖 B->A 时,咱们将这个新依赖设想为 T2 ,而以前的全部锁依赖都存在于一个设想的 T1 产生的一个锁依赖图中,因而死锁预测就是检查 T1 中是否存在 A->B 的锁依赖,若是存在即存在死锁,不然就没有死锁并将 T2 合并到 T1 中。以下图所示:
在引入了读写锁以后,锁依赖还取决于其中锁的类型,即读或者写类型。咱们根据 Linux 内核中互斥锁和读写锁的设计特性,引入一个锁互斥表来表示锁之间的互斥关系:
其中,递归读锁(Recursive-read Lock)是一种特殊的读锁,它可以被同一个线程递归地拿。下面咱们首先提出一个简单算法(Simple Algorithm)。基于双线程模型,给定 T1 和 T2 ,和 ABBA 锁:
简单算法的步骤以下:
若是 X1.A 和 X1.B 是互斥的且 X2.A 和 X2.B 是互斥的,那么 T1 和 T2 构成潜在死锁。
不然, T1 和 T2 不构成潜在死锁。
从简单算法中能够看出,锁类型决定了锁之间的互斥关系,而互斥关系是检查死锁的关键信息。对于读写锁来讲,锁类型可能在程序执行过程当中变化,那么如何记录全部的锁类型呢?咱们基于锁类型的互斥性,即锁类型的互斥性由低到高:递归读锁 < 读锁 < 写锁(互斥锁),提出了锁类型的升级(Lock Type Promotion)。在程序执行过程当中,若是碰到了高互斥性的锁类型,那么咱们将锁依赖中的锁类型升级到高互斥性的锁依赖。锁类型升级如图所示:
其中 RRn 表示递归读锁n(Recursive-read Lock n) ,Rn表示读锁n(Read Lock n),Wn表明写锁或者互斥锁n(Write Lock n)。下面 Xn 则表示任意锁n (即递归读、读或者写锁)。
可是,如上简单算法并不能处理全部的死锁预测状况,好比下面这个案例就会躲过简单算法,但事实上它是一个潜在死锁:
在这个案例中, X1 和 X3 是互斥的从而这个案例构成了潜在死锁。可是简单算法在检查 RR2->X1 时(即 T2 为 RR2->X1 ),根据简单算法可以找到 T1 中有 X1->RR2 ,可是因为 RR 和 RR 不具备互斥性,于是错误认定这个案例不是死锁。分析这个案例为何得出错误结论,是由于真正的死锁 X1X3X3X1 中的 X3->X1 是间接锁依赖,而间接依赖被简单算法漏掉了。
这个问题的更深层次缘由是由于互斥锁之间只有互斥性,所以只要有 ABBA 就是潜在死锁,并不须要检查 T2 的间接锁依赖。而在有读锁的状况下,这一条件不复存在,所以就要去考虑 T2 中的间接锁依赖。
引理4是引理1的引伸,根据引理1,这个直接锁依赖必定是造成锁循环的那个最后锁依赖,而引理4说明经过这个锁依赖必定能够经过某种方法判断出锁循环是不是潜在死锁。换句话说,经过修改和增强以前提出的简单算法,新的算法必定可以解决这个问题。可是问题是,原先 T2 中直接锁依赖可能进一步生成了不少间接锁依赖,咱们如何才能找到那个最终产生潜在死锁的间接锁依赖呢?更进一步,咱们首先须要从新定义 T2 ,再在这个 T2 中找出全部的间接锁依赖,那么 T2 的边界是什么?若是把 T2 扩展到整个锁依赖图,那么算法复杂度会提升很是多,甚至可能超出 Lockdep 的处理能力,让 Lockdep 变得实际上不可用。
根据引理5,咱们首先修改以前提出的双线程模型为:
T1:当前检查直接锁依赖以前的全部锁依赖,这些依赖造成了一个图。 T2:当前的待检查的线程的锁栈。
根据引理5和新的双线程模型,咱们在简单算法的基础上提出以下最终算法(Final Algorithm):
继续搜索锁依赖图即 T1 寻找一个新的锁依赖循环。 在这个新的循环中,若是有 T2 中的其余锁存在,那么这个锁和 T2 中的直接锁依赖构成一个间接锁依赖,检查这个间接锁依赖是否构成潜在死锁。 若是找到潜在死锁,那么算法结束,若是没有到算法转到1直到搜索完整个锁依赖图为止。
这个最终算法能解决以前出现漏洞的案例吗?答案是能够的,具体检查过程如图所示:
然而,对于全部其余状况,引理5是正确的吗?为何最终算法可以工做呢?咱们经过以下两个引理来证实最终算法中的间接锁依赖是必要且充分的。
引理6说明因为读写锁的存在,不能只检查直接锁依赖。
根据引理2和引理3,任何死锁均可以转化成双线程 ABBA 死锁,而且 T1 只能贡献 AB,T2 必须贡献 BA 。在这里,T2 不只仅是一个虚拟线程,也是一个实际存在的物理线程,所以 T2 须要且只须要检查当前线程。
到这里,一个通用的读写锁死锁预测算法就描述并不是正式证实完毕。这个算法已经实如今 Lockdep 中并提交给 Linux 内存社区去审阅(当前最新版本见https://lkml.org/lkml/2019/8/29/167)。鉴于相关性和篇幅所限,算法当中的一些关键细节并无所有展示在这里,有兴趣的读者能够去上面的连接查找,同时欢迎提出评审意见和建议。
回顾从最初处理滴滴基础平台大集群集中爆发的几个严重系统故障,到学习研究内核死锁预测工具,再到设计和实现新的通用的读写锁死锁预测算法。其中充满了不肯定性甚至戏剧性,但整个过程以及最后的结果都让我收获满满。我想,这个经历正像电影《阿甘正传》里的阿甘跑步同样:跑到了一个目的地,就想再多跑一点,到了下一个目的地,又去设定一个新的更远的目标。我也想,普通的工做和世界级的工做的区别并不在于起点,而在于终点,在因而否多跑了几个更远的目标吧。
同时也欢迎你们关注滴滴技术公众号,咱们会及时发布最新的开源信息和技术资讯!