并发程序中缺陷的类型
- 非死锁缺陷
- 死锁缺陷
在 2008 年发表的 Learning from Mistakes — A Comprehensive Study on Real
World Concurrency Bug Characteristics 论文中,进行了详细的说明
通过知名的开源软件已知的并发问题统计,发现出错最多的是非死锁缺陷
非死锁缺陷
主要有两种常见的:违反原子性、错误顺序缺陷
违反原子性缺陷
第一个线程检查 proc_info 非空后打印,第二个线程将 proc_info 指针置为 NULL, 现在当第一个线程进入非空判断后(line:2),发生中断,时间片轮转到第二个线程将 proc_info 指针置为NUll, 显然这样会照成引用空指针,最后程序崩溃,显然这不是我们像要的。
什么是原子性?要么一致,要么不一致。
定义引出:由上面的问题,我们可以定义这类问题,即 if 判断应该和代码块内部保持一致。 即代码段本意是原子的(整块执行),但在执行中并没有强制实现原子性。
如何修复?我们只需要给共享变量的访问加上锁,确保每个线程获取 proc_info 时,只有一个,就像下面这样
违反顺序缺陷
观察上面的代码,如果线程 1 并没有首先执行,线程 2 就会引用空指针异常
我们希望的是线程 1 创建完毕后,线程 2 读取到 mThread 的 State 值
因此违反顺序缺陷的定义是 “ 两个内存访问的预期顺序 被打破了(即 A 应该在 B 之前执 行,但是实际运行中却不是这个顺序)”
如何修复呢?我们可以通过 条件变量 来强制保持顺序访问
在这段代码中,我们添加的 mtLock 锁来控制访问 mtInit 变量(line:11)以及条件变量 mtCond ,通过 mtInit 来判断是否需要放入(line:23) 条件变量队列中, 关于条件变量(这里你可以简单的理解为一个队列),wait 是等待并入队,signal 则是唤醒并出队。 这样我们的代码也就保证的顺序性
在论文的研究中发现,97% 非死锁问题 是违反原子性与违反顺序,因此我们应该着重关注这些问题。
死锁缺陷
一个经典的死锁案例
线程 1 Lock(L1) Lock(L2),线程 2 Lock(L2) Lock(L1), 当线程 1 占有锁 L1,发生上下文切换到 线程 2, 线程 2 锁住了 L2,当试图锁住 L1时,发现 线程 1 已经持有 L1,而线程 1 并没有释放锁 L1,所以发生了死锁
为什么会发生死锁? 我们发现上述情况是互相依赖对方的锁,当对方互相占有锁时,双方都没办法释放,很重要的原因是锁的顺序
另外一种情况是封装,例如在Java Vector类中的 AddAll() 方法
Vector v1, v2;
v1.AddAll(v2);
因为在内部需要保证线程安全,v1 v2的锁都需要获取,假设 AddAll 方法,先给v1 上锁,再给 v2 上锁,同时此刻有另外一个线程在调用 v2.AddAll(v1), 那么此时就有可能照成死锁,所以封装会屏蔽一些信息,我们要确保小心的使用封装的方法,并且要考虑会出现的情况。
产生死锁的 4 个条件(如果这 4 个条件的任何一个没有满足,死锁就不会产生)
- 互斥:线程对于需要的资源进行互斥的访问(例如一个线程抢到锁)。
- 持有并等待:线程持有了资源(例如已将持有的锁),同时又在等待其他资源(例如,需要获得的锁)。
- 非抢占:线程获得的资源(例如锁),不能被抢占
- 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这 个资源又是下一个线程要申请的。
循环等待
预防此问题,最实用的方法则是获取锁时是全序(total ordering)的,假设有两个锁 L1、L2,线程 1 获取 L1、L2 锁,线程 2 获取 L1、L2 锁, 按照顺序的获取锁,就不会在产生此问题了
当然顺序获取在复杂应用中(很多锁的情况),就不在这么适用了,另外一种则是偏序(partial ordering),偏序也是全序只不过用了比较大小的方式进行先后顺序的获取
这里使用到了地址高低位的技巧来保证锁的顺序,从而避免了死锁,Linux 中的内存映射就是一个偏序锁的好例子
持有并等待
死锁的持有并等待条件,可以通过原子地抢锁来避免。
由于在拿到 prevention 锁之后,线程不会产生切换,从而避免的死锁, 任何其他的线程想要访问 L1、L2 都必须通过 拿到 prevention(全局锁)后才能执行。 其实就是在获取锁时,保证只有一个线程进入,但也降低了并发
非抢占
在调用 unlock 之前,锁是被占有的,在获取 L1 锁后,我们尝试获取 L2,如果获取失败我们就会释放 L1 锁, 并重新执行,确保一次性拿到 L1 和 L2锁。其实也是避免依赖,在获取 L2 锁时,失败就重来。
这种情况下会导致活锁,两个线程有可能一直重复,又同时都抢锁失败,这种情况下,系统一直在运行这段代码
互斥
最后的方法是,避免互斥的存在,如果没有互斥,依赖也就不复存在了,那么怎么样才能保证程序的正常,又避免互斥呢?
想法很简单:通过强大的硬件指令,我们可以构造出不需要锁的数据结构,例如比较并交换(compare-and-swap)CAS(网上很多资料不在赘述), 虽然避免了死锁,但有可能产生活锁
通过调度避免死锁
除了死锁预防,某些场景更适合死锁避免(avoidance)。在使用之前我们需要了解全局的信息,包 括不同线程在运行中对锁的需求情况,从而使得后续的调度能够避免产生死锁
只要避免 T1 T2 重叠就能避免死锁
检查和恢复
最后一种也就是重启大法,如果一个操作系统一年死机一次,你会重启系统,然后继续愉快的使用。
提示:不要总是完美( TOM WEST 定律),不是所有值得做的事情都值得做好,如果坏事很少发生,并且造成的影响很小,那么我们不应该去花费大 量的精力去预防它
总结
并发程序中缺陷的类型,大致分为两类:非死锁缺陷、死锁缺陷
产生死锁的 4 个条件:互斥(CAS避免)、持有并等待(全局锁保证原子执行)、非抢占(trylock)、循环等待(顺序的获取锁)
通过在实际项目中统计总结出并发的缺陷,进行分类,以及产生的原因,进而避免这类问题的发生。
通过高质量的书籍是获取计算机论文中压缩的信息很好的途径,作者既保持了高浓度,也毫无保留的给出了信息源,这对我的学习方式有很大改变。
为什么读论文?因为一切都有起因,一切都是演化而来,问题不是凭空产生的。比起某些不负责任的博客、书籍,我觉得论文是真正的研究了问题,搞清楚了前因后果,真正去实践、验证过的,这样留下的才是有价值的。