持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情
常见并发问题
我们已经学习了并发问题的解决工具,锁、条件变量、信号量。但是如果对并发问题没有一个准确认知的话是无法编写出合格的并发程序的。尤其是将一些常见的Bug记在脑子里是编写合理的并发程序的前提。
如果从实际应用中总结并发程序的bug,可以发现在MySQL、Apache、Mozilla、OpenOffice这样的成熟的软件里面也会有很多并发Bug,如果将并发Bug分为非死锁和死锁两种,可以得到如下的图片。
对于非死锁问题,我们使用研究中的示例来推动我们的讨论。而对于死锁问题,前人已经探索了许多方法去预防、避免和处理死锁。
非死锁问题可以分为两种,原子性冲突和顺序冲突。原子性冲突就是我们常见的中断引发的critical section问题。 如下图所示,只要在冲突部分加上锁就可以解决这个问题。
顺序冲突则是由于子线程比父线程执行更快造成的代码顺序问题,如下图所示,应该用锁和条件变量来规定线程之间的执行顺序,这样就可以解决问题。
死锁问题是在许多具有复杂锁定协议的并发系统中出现的一个经典问题,死锁发生的概率比非死锁小很多,但是一旦发生就是系统崩溃级别的问题,因此对死锁的处理一直以来都有很多人研究。我们首先弄明白为什么死锁会发生,一个简单的死锁如下图所示,线程1和2互相调用锁,又互相持有锁不放,所以发生了死锁。
那么如果线程1和2都以相同的顺序获取锁,那么死锁就不会发生了。原理很简单,但是在实际的软件开发中,我们所调用的各种封装函数和包大部分都是对我们透明的,因此我们无法确切了解程序中各种锁的调用情况。例如,假如有两个依赖互相调用,则很有可能出现死锁,这称为循环依赖(circular dependencies) 问题,例如在python中包不能在全局相互调用。封装好的接口隐蔽性强,所以不能和我们自己定义的锁很好的配合,例如在Java Vector类中
Vector v1, v2;
v1.AddAll(v2); //这种相互的资源调用有可能出现(循环)死锁
v2.AddAll(v1);
对于死锁的原因严谨定义可以归结为如下四点:
- Mutual exclusion:线程要求独占地控制它们所需要的资源(例如,一个线程抓取一个锁)。
- Hold-and-wait:线程持有分配给它们的资源(例如,它们已经获得的锁),同时等待额外的资源(例如,它们希望获得的锁)。
- No preemption:资源(例如,锁)不能从持有它们的线程中强制移除。
- Circular wait:存在一个循环的线程链,每个线程持有一个或多个资源(例如,锁),这些资源正在被链中的下一个线程请求。
如果以上四个条件中任意一个不满足则不会发生死锁,因此,当我们遇到死锁问题时,首先可以从上面四个方向去思考解决方案。
第一个是Circular wait,这也是我们最有可能遇到的问题,预防循环的线程链产生的最直接方法是定义锁获取的总顺序,如下图所示,定义锁的物理地址作为锁的获取顺序,假如m1的物理地址高于m2,则m1总是优先于m2被获得,这样就可以防止循环链。
if (m1 > m2) { // grab in high-to-low address order
pthread_mutex_lock(m1);
pthread_mutex_lock(m2);
} else {
pthread_mutex_lock(m2);
pthread_mutex_lock(m1);
}
这种方法全部顺序和部分顺序都需要仔细设计锁策略,并且必须非常小心地构造。因此并不是很高效,并且有时实施起来很复杂。
第二个是Hold-and-wait,我们可以对程序中所有锁的获取加上一个全局锁,这样保证了在获取锁的过程中不会发生不适时的线程切换,因此可以再次避免死锁。
pthread_mutex_lock(prevention); // begin acquisition
pthread_mutex_lock(L1);
pthread_mutex_lock(L2);
...
pthread_mutex_unlock(prevention); // end
和前面一样,这种方法要求我们确切地知道哪些锁必须被持有,并提前获取它们,因此实际实施有困难。这种技术还可能降低并发性,因为所有锁都必须在早期(同时)获得,而不是在真正需要时获得。
第三个是No Preemption,锁在被调用时是无法被其它线程再次调用的,这是锁的基本性质,但是我们可以更改调用锁的方式,例如构建可释放的锁:
top:
pthread_mutex_lock(L1);
if (pthread_mutex_trylock(L2) != 0) {
pthread_mutex_unlock(L1);
goto top;
}
上面的调用方式,当发现锁已经被调用,程序可以再次返回等待锁,也可以做一些其它事情。有人可能已经发现了,当锁的调用顺序相反时,按照上面的程序,依然可能出现类似“死锁”的现象,这种我们称之为活锁,因为程序并没有进入调用陷阱,而是依然不断运行着。虽然有一些问题,但是这种方法并没有真正添加抢占(从拥有锁的线程强制拿走锁的行为),而是使用trylock方法,允许开发人员以一种优雅的方式退出锁的所有权(即抢占自己的所有权)。因此还是比较实用的。
第四个是Mutual Exclusion,最后一个比较根本,完全避免互斥的需要。通常,我们知道这是困难的,因为我们希望运行的代码确实有临界区。那么我们能做什么呢?Herlihy有这样一个想法,即人们可以设计完全没有锁的各种数据结构,具体的说,使用强大的硬件指令,我们确实已经构建了一些不需要锁的并发程序。
这种方法最大问题是复杂,构建一个有用的列表需要的不仅仅是一个列表插入,毫无疑问,构建一个可以以无锁的方式插入、删除和执行查询的列表并不简单。
当然也有一些其它方向的思路,有时候这些想法对解决问题有意外的效果。
我们可以通过调度的方式来避免死锁,假如我们知道各种线程在执行时可能会抓住哪些锁,那么就可以以一种保证不会发生死锁的方式调度这些线程。
具体的方式我们不讨论,但是可以发现,假如能够实现的话,这种方法虽然能够避免死锁,但是会付出很大性能开销的代价。
最后一种通用的策略是允许偶尔发生死锁,然后在检测到死锁后采取一些行动。例如,如果一个操作系统每年冻结一次,你只需重启它,然后继续运行即可。如果死锁很少发生,那么这种非解决方案确实是非常实用的。
许多数据库系统采用死锁检测和恢复技术。死锁检测器定期运行,构建资源图(事务等待图)并检查是否有闭环。在出现闭环(死锁)的情况下,则需要重新启动系统。
对于死锁问题,在实践中,最好的解决方案是谨慎地开发获取锁的顺序,从而从一开始就防止死锁的发生。无阻塞的方法也很有前途,因为一些无阻塞的数据结构现在正被应用到常用的库和关键系统中,包括Linux。然而,它们缺乏通用性。
也许最好的解决方案是开发新的并发编程模型:在像MapReduce(来自google)这样的系统中,程序员可以描述某些类型的并行计算,而不需要任何锁。锁本身就有问题;也许我们应该避免使用它们,除非我们真的必须这样做。