操作系统导论学习:第32章 常见并发问题

文章探讨了并发编程中的两种主要缺陷:非死锁缺陷,包括违反原子性和违反顺序,以及死锁问题。非死锁缺陷可通过加锁和使用条件变量解决,而死锁则需要避免循环等待和使用特定的锁获取顺序或无锁数据结构来预防。文章还提出了通过调度策略避免死锁的可能性。
摘要由CSDN通过智能技术生成

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]这样的系统中,程序员可以完成一些类型的并行计算,无须任何锁。 锁必然带来各种困难,也许我们应该尽可能地避免使用锁,除非确信必须使用。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值