常见并发场景

Saturday, November 9, 2019

常见的缺陷

非死锁缺陷

违反原子性缺陷

Thread 1 ::
if(thd->proc_info) {
   ...
   fputs(thd->proc_info);
   ...
}

Thread 2 ::
thd->proc_info = NULL;

这个问题就是典型的违反了原子性的问题了,修复这类问题也很简单,确保每个线程访问 proc_info 字段时,都持有锁即可。

违反顺序缺陷

Thread 1 ::
void init() {
    ...
    mThread = PR_CreateThread(mMain, ...);
}

Thread 2 ::
void mMain(...) {
    ...
    mState = mThread->State;
    ...
}

这个问题很明显,线程2不应该在线程1的前面运行,因为 mThread 还没初始化,如果顺序错了,会引用空指针。

修复这个问题也比较直接,我们要引入一个锁,一个条件变量,一个状态变量,线程2在锁里面循环判断这个状态变量,并发出等待信号。线程1在锁里面改变状态变量的值,并发出信号量。

大部门的非死锁问题都是违反原子性和顺序性这两种,所以我们要多加注意。

死锁缺陷

死锁问题也是个很常见的问题,比如线程1持有 L1锁,等待 L2锁,线程2持有 L2锁,等待 L1锁,就会出现死锁问题了。

为什么会发生死锁

产生死锁的4个条件

  • 互斥: 线程对于需要的资源进行互斥的访问
  • 持有并等待:线程得到了资源(持有了锁),又在等待其他资源(需要获得锁)
  • 非抢占:线程获得的资源(例如锁),不能被抢占
  • 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的。

任意一个条件没满足,死锁都不会产生。

预防

循环等待

最实用的预防技术,就是让代码不要产生循环等待。最直接的方法就是获取锁提供一个全序,每次都用一样的顺序去获取锁。这样就不会产生死锁。

当然复杂的系统,可能不只有几把简单的锁,全序很难做到,我们可以用偏序。 Linux 的内存映射代码就是一个偏序锁的例子。

持有并等待

死锁的持有并等待条件,可以通过原子地抢锁来避免。

lock(prevention);
lock(l1);
lock(l2);
...
unlock(prevention);

这个方法通过加一个锁,避免在过程中,不会不合时宜的切换了线程,从而避免了死锁。

但这个方法不适用于封装,它需要我们精准的知道要抢哪些锁,并且提前抢到这些锁,而且因为要提前抢到锁,可能会降低了并发。

非抢占

在调用 unlock 之前,锁是被占有的,多个抢锁操作会带来麻烦,有线程库会提供一些接口来避免这种问题。

top:
   lock(L1);
   if(trylock(L2) == -1) {
       unlock(L1);
       goto top;
   }

这个方法解决了就算另一个线程用了不同顺序的加锁方式,也能够避免死锁,但会引来一个新的问题–活锁。解决的方法是,循环结束的时候,先随机等待一个时间,再重复整个动作。

互斥

我们要避免互斥,当然很多场景来说,都是有代码临界区的,很难避开互斥,这时我们可以用一些硬件指令,构造出不需要锁的数据结构,比如有比较并交换的原子指令。

通过调度避免死锁

我们可以通过线程的调度,避免出现同时互相占用的情况来解决死锁问题。

比如: OS32.1

我们可以看到,只要避免 T1和 T2同时运行,就不会产生死锁。

OS

RunLoop 介绍

信号量