这章主要介绍并发场景下常见的bug,并发场景下主要常见的bug分2类:非死锁bug和死锁bug。文中列出了之前
Lu研究的目前常见的主流数据库和web server中包含的一些并发bug,并对其做了分类。
非死锁bug
Atomicity-Violation Bugs 原子性违反
bug描述
这类bug的特点就是多线程之间,忽略了对共享变量的原子性问题。也就是最常见的一类并发问题:
Thread 1::
if (thd->proc_info) {
fputs(thd->proc_info, ...);
}
Thread 2::
thd->proc_info = NULL;
线程1判断某个变量为null时执行某逻辑,但是判断通过后,另一个线程将该变量直接初始化为null,此时线程1继续执行会得到空指针异常。
解决方式
这类bug最简单的解决方式就是加锁。因为这本质上是临界代码问题。如果是对数据结构操作,可以使用并发安全的数据结构并且使用setIfAbsent等操作来保证原子性。
Order-Violation Bugs 执行顺序违反
bug描述
这类bug的特点就是多线程之间,忽略了并发时执行顺序的不确定性:
Thread 1::
void init() {
mThread = PR_CreateThread(mMain, ...);
}
Thread 2::
void mMain(...) {
mState = mThread->State;
}
线程1创建了线程2后,并赋值给变量mThread
线程2访问mThread变量的State属性
但是注意此时mThread = PR_CreateThread(mMain, ...);这句赋值语句与线程2执行的顺序是不可控的,线程2创建后执行时,可能mThread并没有赋值为PR_CreateThread的返回值,此时线程2去读取mThread->State,会直接空指针
错误时序如下:
- 线程1执行PR_CreateThread创建并执行线程2的mMain
- 线程2创建
- 调度切换到线程2
- 线程2执行(这里就报错空指针了)
- 线程1继续执行,将PR_CreateThread的返回值赋值给mThread
解决方式
这类bug最本质的解决就是加入条件变量,因为你会发现本质上就是需要在mThread被赋值以后,然后mState = mThread->State;再去执行,是线程之间执行的条件先后依赖问题。
无锁bug总结
据研究,97%的无锁bug都是以上两类问题。
针对这两类问题,需要我们在对共享变量的使用导致产生临界代码时
- 具备前置的识别能力,对这部分代码或者场景需要具备敏锐度
- 在识别后,分析原因并通过并发问题常见解决思路去分析解决问题
要知道,并发问题总是发生在共享变量的时候,在你意识到某个变量被多线程共享访问时,就应该多加思索
死锁bug
发生
我们为什么常常会发生死锁?
- 一些api或者数据结构的封装,里面其实使用到了锁,但是我们在使用时如果没有提前了解,就会触发死锁
- 工程复杂性上来以后,巨大的依赖以及代码量,让我们很难像一开始设计时理清楚多个线程间锁的使用关系以及顺序
发生死锁必须具备一下四个条件:
- 互斥:线程间声称他们对同一个资源的互斥访问(例如,线程间争抢一个锁)
- 持有并且进入等待:线程持有了某个资源,并且因为需要获得额外的资源而进入等待(例如,线程获取了一个锁L1时,需要再次去获取L2,但是获取L2时进入了等待)
- 不允许抢占:当线程持有某个资源时,无法被其他线程强占(例如,线程获取了一个锁,此时所有其他线程无法通过任何方式强行抢到这个锁)
- 循环等待:存在一个线程的循环链,这样每个线程持有一个或多个资源(例如锁),这些资源正在被链中的下一个线程请求。
当出现这些问题以后、如何解决?
避免
循环等待
解决方式是对获取锁的顺序进行精心排序。
假如我们有L1和L2这2个锁,如果我们在获取L2时总是先获取L1,就可以避免这个问题。
这也需要我们总是对多个锁的获取去进行检查并且精心设计。
持有并且进入等待
解决方式很简单,在获取锁以前,获取一个global lock全局锁,获取相关的锁之前都先获取这个锁,来在外围避免里面的临界代码,简而言之就是套娃。
但是这种实现本身就很蠢,降低了并发度,并且对封装不利,因为我们需要在调用前了解到所有需要获取到的所有锁。
不允许抢占
这里就说到一个进阶玩法:tryLock
tryLock可以很有效的避免死锁,在一段时间无法获取到锁后中断,从而退出来释放自己已经持有的锁,以此来避免死锁的发生。
互斥
互斥这里也很好解决,大部分时候,我们可以尝试将代码改造为无锁的或者不需要等待的自旋尝试(本质上是修改为乐观锁),通过之前说的CAS等操作,我们可以很轻松的实现。
补充:时序避免
我们还可以通过CPU调度的时序来避免死锁。如果操作系统一定程度上知道我们获取锁的顺序,那么它就可以通过CPU的调度顺序控制,来避免死锁。这个思路比较像RUST的设计思路,通过预先知道(编译期解决问题),来避免问题。不过成本就是易用性会差很多。
从死锁中如何恢复
对于那些一年发生一次的,我们当然可以发生了就重启下。但是其他情况呢?
我们这里以mysql举例,mysql中执行sql,当sql执行过久时,mysql会触发死锁警告,此时需要你通过命令强制关闭杀死事务并释放资源。
大部分死锁恢复都是如此,通过检测(某事务中wait太久,或判断条件循环依赖(jvm))后,由用户自己来强制关闭出现问题的线程,释放导致死锁的资源。