进程与线程:10 信号量临界区保护
课程引入:信号量保护的必要性
这一节我们要探讨信号量的保护问题,即如何保护信号量。上一课我们学习了信号量可实现同步,多个进程通过信号量的值来决定是否继续执行,从而实现有序推进。但如果没有这节课所讲的信号量保护机制,信号量将无法正常工作。完整的逻辑是靠临界区来保护信号量,靠信号量来实现进程之间的同步。接下来我们将围绕两个主题展开:一是为什么要保护信号量以及临界区概念的引出;二是实际中保护信号量、实现临界区的方法。
一、为什么要保护信号量
信号量本质上是一个整形变量,通过对它的访问和修改,能够让多个进程有序推进。例如上节课提到的empty
信号量,生产者和消费者都会查看它的值:当生产者看到empty
的值为-1
时,就知道缺少空闲单元,自己也会进入睡眠状态,并将其值改为-2
;消费者看到empty
的值为-1
时,会唤醒睡眠的进程。
然而,这里存在一个关键问题:信号量的值必须正确,其表达的含义必须准确。因为多个进程不仅会读取信号量的值,还会对其进行修改。当多个进程共享操作、共同修改同一个信号量时,由于进程调度的不确定性,很容易导致信号量的值出错。
比如,生产者要对empty
信号量进行减1操作,正常情况下是从内存中取出变量值到寄存器,减1后再存回内存。但如果在执行这个操作的过程中,发生进程调度切换,例如时间片到时,切换到另一个生产者进程继续执行相同的操作,就会出现语义错误。假设初始empty
值为-1
,第一个生产者执行到将值取到寄存器并减1后,还未将结果存回内存就被切换出去,此时寄存器中的值为-2
,但内存中的empty
值仍为-1
。第二个生产者进程执行同样操作,最终内存中的empty
值会被错误地修改为-2
,而实际上应该是-3
,这就导致信号量的值与实际表达的含义不符,后续必然会引发问题。
这种因共享数据未被保护,多个进程竞争修改而产生的错误,与调度顺序有关,并非编程错误,被称为竞争条件。试图通过添加空循环来改变调度方案,从而避免错误的方法是不可行的,因为进程调度具有不确定性,每次运行时的调度情况都可能不同,无法保证这种方法始终有效。
所以,必须引入保护机制来确保信号量的正确性。
二、临界区概念的引出
为了解决信号量共享修改时的竞争问题,我们引入了临界区的概念。
对于修改信号量的代码段,我们需要保证其执行的原子性,即要么一个进程完整地执行完这段代码,要么其他进程完全不执行,不允许在执行过程中被其他进程打断或干扰。
例如,当一个生产者进程进入修改empty
信号量的代码段时,其他生产者或消费者进程不能同时进入它们对应的修改empty
信号量的代码段。临界区就是这样一段代码,在一个进程执行它时,其他进程不能进入与之对应的临界区代码段。修改信号量的代码必须在临界区内执行,否则由于进程调度的不确定性,就会造成竞争错误,导致信号量语义不正确。
三、保护信号量的方法
在明确了保护信号量的必要性和临界区概念后,接下来我们探讨如何实现临界区,即进入和退出临界区的代码应该如何编写。实现临界区的代码需要满足三个条件:
- 互斥进入:这是最基本的条件,同一时刻只能有一个进程进入临界区,确保共享资源不会被多个进程同时修改。
- 有空让进:当临界区空闲时,等待进入的进程应该能够及时进入,提高资源利用率。
- 有限等待:进程不能无限期地等待进入临界区,避免出现饥饿现象。
(一)软件方法
- 轮换法
轮换法类似于值日,通过一个全局变量turn
来控制进程进入临界区的顺序。当turn
的值等于某个进程标识时,该进程可以进入临界区,否则就在循环中等待。从互斥进入的角度来看,通过反证法可以证明它满足这一条件,因为turn
不可能同时等于两个不同的值,所以不会出现两个进程同时进入临界区的情况。
但它不满足有空让进的条件。例如,一个进程进入临界区后,turn
的值改变,如果该进程因某种原因阻塞,不再执行,即使此时临界区空闲,其他进程也无法进入,因为turn
的值还未轮到它们。
- 标记法
标记法通过设置标记变量来表示进程是否想进入临界区。每个进程在尝试进入临界区前,先将自己的标记变量设为true
,然后检查其他进程的标记变量。如果发现其他进程也想进入(其标记变量为true
),则自己等待;当进程退出临界区时,将自己的标记变量设为false
。
同样用反证法可以证明它满足互斥进入条件。
但它存在问题,可能出现两个进程同时设置标记,然后都发现对方也想进入,从而导致谁都无法进入临界区的情况,不满足有空让进条件,还可能造成无限等待。
- Peterson算法(值日加标记法)
Peterson算法结合了轮换法和标记法的思想,是一种非对称的方法。
每个进程在进入临界区前,先设置自己的标记表示想进入,然后检查另一个进程的标记以及turn
变量的值。如果另一个进程也想进入且turn
的值表明轮到对方,自己就等待;否则可以进入临界区。进程退出临界区时,修改turn
的值,让另一个进程有机会进入。
经过分析可以发现,Peterson算法满足互斥进入、有空让进和有限等待三个条件,是一种正确的保护临界区的方法。但它只适用于两个进程的情况,对于多个进程,需要采用其他算法。
- 面包店算法
面包店算法是将Peterson算法推广到多个进程的情况。
它借鉴现实中取号的方法,每个进程想进入临界区时先取一个号,号不为零表示想进入。每次让号最小的进程进入临界区,进程离开时将号设为零。在取号过程中,每次取得的号是当前最大号加一,保证了公平性和有序性。
面包店算法也满足互斥进入、有空让进和有限等待条件,能够有效保护多个进程情况下的临界区。但它也存在一些问题,例如取号过程中可能会出现号溢出的情况,需要进行额外处理,而且实现相对复杂。
(二)硬件方法
-
关中断法
我们知道,进程调度是通过中断触发的,例如时钟中断会导致时间片变化,进而引发进程调度。关中断法就是利用这一原理,在进程进入临界区前,通过硬件提供的指令(如cli
)关闭中断,这样在临界区内执行代码时,就不会因为中断而发生进程调度,从而保证了临界区代码执行的原子性。进程退出临界区时,再通过指令(如sti
)打开中断。
然而,关中断法在多CPU系统中不好用。因为每个CPU都有自己的中断寄存器,在一个CPU上关闭中断,无法阻止其他CPU上的进程调度,其他CPU仍然可以执行修改共享资源的代码,导致临界区保护失效。但在单CPU系统(如实验用的Linux 0.1)中,关中断法可以使用,并且在做相关实验时鼓励采用这种方法。 -
原子指令法
原子指令法的核心思想是利用硬件提供的原子指令,将对信号量(类似于锁)的修改操作变成一个不可分割的指令执行过程。前面提到的锁本质上是一个变量,用于表示资源是否被占用,其概念与信号量类似,但如果用普通信号量去实现锁,会陷入无限循环的保护问题(修改信号量还需要其他信号量保护)。
硬件原子指令能够保证对这个锁变量的修改要么完整执行,要么不执行,不会在执行中途被打断。例如test set
原子指令,它可以检查锁是否被锁上,如果已被锁上,返回true
,进程就在循环中等待;如果没锁上,返回false
,同时将锁锁上,进程可以直接进入临界区。进程退出临界区时,再将锁打开。
原子指令法满足互斥进入条件,因为同一时刻只有一个进程能够成功执行原子指令获取锁,其他进程只能等待。
- 有空让进:临界区空闲(
lock
为false
)时,进程可通过TestAndSet
指令获锁进入,原理上满足。 - 有限等待:多进程竞争锁,高并发下可能有进程长时间无法获锁,不满足。
- 多CPU适用性:
- 优势:原子指令保证锁操作一致性,维持互斥性。
- 劣势:进程轮询浪费CPU资源,易致进程饥饿,调度开销大。
课程总结
这节课我们深入探讨了信号量的保护问题。总结起来,需要用临界区来保护信号量,而实现临界区的方法有面包店算法(软件方法)、开关中断法和硬件原子指令法(硬件方法),它们分别适用于不同的场合。当临界区成功保护信号量后,信号量在执行过程中语义就会正确,基于正确语义的信号量,就能实现进程在合适的时候阻塞、唤醒和推进,从而实现多个进程的合理同步与合作,这就是进程同步和合作的完整故事。