1.非死锁缺陷
违反原子性缺陷
定义: 违反了多次内存访问中预期的可串行性(即代码段本意是原子的,但在执行中并没有强制实现原子性)。
这个例子中,两个线程都要访问 thd 结构中的成员 proc_info。第一个线程检查 proc_info 非空,然后打印出值;第二个线程设置其为空。显然,当第一个线程检查之后,在 fputs() 调用之前被中断,第二个线程把指针置为空;当第一个线程恢复执行时,由于引用空指针, 导致程序奔溃。
解决方案:加锁。我们只要给共享变量的访问加锁,确保每个线程访问 proc_info 字段时, 都持有锁(proc_info_lock)。当然,访问这个结构的所有其他代码,也应该先获取锁。
违反顺序缺陷
定义: 两个内存访问的预期顺序被打破了(即 A 应该在 B 之前执 行,但是实际运行中却不是这个顺序)。
线程 2 的代码中似乎假定变量 mThread 已经被初始化了(不为空)。 然而,如果线程 1 并没有首先执行,线程 2 就可能因为引用空指针奔溃(假设 mThread 初始值为空;否则,可能会产生更加奇怪的问题,因为线程 2 中会读到任意的内存位置并引用)。
解决方案:条件变量。
在这段修复的代码中,我们增加了一个锁(mtLock)、一个条件变量(mtCond)以及状态的变量(mtInit)。初始化代码运行时,会将 mtInit 设置为 1,并发出信号表明它已做了这件事。如果线程 2 先运行,就会一直等待信号和对应的状态变化;如果后运行,线程 2 会检查是否初始化(即 mtInit 被设置为 1),然后正常运行。请注意,我们可以用 mThread 本身作为状态变量,但为了简洁,我们没有这样做。当线程之间的顺序很重要时,条件变量 (或信号量)能够解决问题。
2.死锁缺陷
当线程 1 占有锁 L1,上下文切换到线程 2。 线程 2 锁住 L2,试图锁住 L1。这时才产生了死锁,两个线程互相等待。如图所示,其中的圈(cycle)表明了死锁。
产生死锁的条件(4个都必须同时满足)
- 互斥:线程对于需要的资源进行互斥的访问(例如一个线程抢到锁)。
- 持有并等待:线程持有了资源(例如已将持有的锁),同时又在等待其他资源(例如,需要获得的锁)。
- 非抢占:线程获得的资源(例如锁),不能被抢占。
- 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的。
预防
循环等待
让代码不会产生循环等待。最直接的方法就是获取锁时供一个全序(total ordering)。假如系统共有两个锁(L1 和 L2), 那么我们每次都先申请 L1 然后申请 L2,就可以避免死锁。这样严格的顺序避免了循环等待, 也就不会产生死锁。
持有并等待
死锁的持有并等待条件,可以通过原子地抢锁来避免。
先抢到 prevention 这个锁之后,代码保证了在抢锁的过程中,不会有不合时宜的线程切换,从而避免了死锁。当然,这需要任何线程在任何时候抢占锁时,先抢到全局的 prevention 锁。例如,如果另一个线程用不同的顺序抢锁 L1 和 L2,也不会有问题,因为此时,线程已经抢到了 prevention 锁。
非抢占
在调用 unlock 之前,都认为锁是被占有的,多个抢锁操作通常会带来麻烦,因为我们等待一个锁时,同时持有另一个锁。很多线程库供更为灵活的接口来避免这种情况。具 体来说,trylock()函数会尝试获得锁,或者返回−1,表示锁已经被占有。
互斥
通过强大的硬件指令,我们可以构造出不需要锁的数据结构。 从而完全避免互斥。
通过调度避免死锁
例如,假设我们需要在两个处理器上调度 4 个线程。更进一步,假设我们知道线程 1 (T1)需要用锁 L1 和 L2,T2 也需要抢 L1 和 L2,T3 只需要 L2,T4 不需要锁。我们用表 32.2 来表示线程对锁的需求。
一种比较聪明的调度方式是,只要 T1 和 T2 不同时运行,就不会产生死锁。下面就是这种方式:
请注意,T3 和 T1 重叠,或者和 T2 重叠都是可以的。虽然 T3 会抢占锁 L2,但是由于它只用到一把锁,和其他线程并发执行都不会产生死锁。
我们再来看另一个竞争更多的例子。在这个例子中,对同样的资源(又是锁 L1 和 L2) 有更多的竞争。锁和线程的竞争如表 32.3 所示:
特别是,线程 T1、T2 和 T3 执行过程中,都需要持有锁 L1 和 L2。下面是一种不会产生死锁的可行方案:
你可以看到,T1、T2 和 T3 运行在同一个处理器上,这种保守的静态方案会明显增加 完成任务的总时间。尽管有可能并发运行这些任务,但为了避免死锁,我们没有这样做, 付出了性能的代价。
3.小结
在本章中,我们学习了并发编程中出现的缺陷的类型。第一种是非常常见的,非死锁参考资料 289 缺陷,通常也很容易修复。这种问题包括:违法原子性,即应该一起执行的指令序列没有 一起执行;违反顺序,即两个线程所需的顺序没有强制保证。 同时,我们简要地讨论了死锁:为何会发生,以及如何处理。这个问题几乎和并发一 样古老,已经有成百上千的相关论文了。实践中是自行设计抢锁的顺序,从而避免死锁发 生。无等待的方案也很有希望,在一些通用库和系统中,包括 Linux,都已经有了一些无等 待的实现。然而,这种方案不够通用,并且设计一个新的无等待的数据结构极其复杂,以至于不够实用。也许,最好的解决方案是开发一种新的并发编程模型:在类似 MapReduce (来自 Google)[GD02]这样的系统中,程序员可以完成一些类型的并行计算,无须任何锁。 锁必然带来各种困难,也许我们应该尽可能地避免使用锁,除非确信必须使用。