7073 字
35 分钟
2.3同步与互斥1

在学习本节时,请读者思考以下问题:

  1. 为什么要引入进程同步的概念?
  2. 不同的进程之间会存在什么关系?
  3. 当单纯用本节介绍的方法解决这些问题时会遇到什么新的问题吗?

用PV操作解决进程之间的同步互斥问题是这一节的重点,统考中频繁考查这一内容,请读者务必多加练习,掌握好求解该类问题的方法。

2.3.1 同步与互斥的基本概念#

在多道程序环境下,进程是并发执行的,不同进程之间存在多种相互制约关系。为了协调这些制约关系,引入了进程同步的概念。下面通过一个简单的例子来帮助理解。例如,计算表达式 1+2×31+2\times3,假设系统为此创建两个进程:一个是乘法进程,一个是加法进程。显然,加法进程必须等待乘法进程完成之后才能正确执行。然而,由于操作系统的异步性,若不加以约束,加法进程完全有可能在乘法进程之前运行,从而导致计算结果错误。因此,需要设计一种机制,确保加法进程只能在乘法进程结束后才开始执行,而这种机制正是本节要讨论的问题。

1. 临界资源#

虽然多个进程可以共享系统中的各种资源,但其中许多资源在同一时刻仅允许一个进程访问,这类资源被称为临界资源。常见的临界资源包括物理设备(如打印机)以及被多个进程共享的变量、数据结构等。

对临界资源的访问必须以互斥方式进行。在每个进程中,访问临界资源的那段代码称为临界区。为了保证临界资源的正确使用,可将对临界资源的访问过程划分为以下四个部分。

  1. 进入区。在进入临界区之前,进程需要检查当前是否允许进入。若允许,则设置一个“正在访问临界区”的标志,以阻止其他进程同时进入临界区。
  2. 临界区。进程中实际访问临界资源的代码段,也称临界段
  3. 退出区。在离开临界区时,清除“正在访问临界区”的标志,表示已释放该资源。
  4. 剩余区。指代码中除上述三部分之外的其余部分。
while (true) {
entry section; // 进入区
critical section; // 临界区
exit section; // 退出区
remainder section;// 剩余区
}

2. 同步(直接制约关系)#

同步是指为完成某种任务而建立的两个或多个进程,由于需要协调彼此的运行次序,而在执行过程中产生等待或传递信息的制约关系。同步关系源于进程之间的相互合作

例如,输入进程A通过单缓冲向进程B提供数据。当缓冲区为空时,进程B因无法获得所需数据而被阻塞;一旦进程A将数据送入缓冲区,进程B就会被唤醒。反之,当缓冲区已满时,进程A无法写入新数据,会被阻塞;只有当进程B从缓冲区取走数据后,才会唤醒进程A。

3. 互斥(间接制约关系)#

互斥是指当一个进程正在临界区中使用临界资源时,其他试图访问该资源的进程必须等待;只有当占用临界资源的进程退出临界区后,其他进程才被允许进入。

例如,在一个只配备一台打印机的系统中,有两个进程A和进程B。如果进程A需要打印,但此时打印机已被分配给进程B,那么进程A必须进入阻塞状态。一旦进程B完成打印并释放打印机,系统便会唤醒进程A,并将其状态由阻塞态转为就绪态。

为防止多个进程同时进入临界区,同步机制应遵循以下准则。

  1. 空闲让进。临界区处于空闲状态时,应允许一个请求进入临界区的进程立即进入。
  2. 忙则等待。已有进程在临界区内执行时,表明临界资源正在被访问,其他试图进入临界区的进程必须等待,以保证对临界资源的互斥访问。
  3. 有限等待。对任何请求进程,应保证其在有限时间内能进入临界区,避免无限期等待。
  4. 让权等待(原则上应该遵循,但非必须)。当进程因无法进入临界区而需要等待时,应主动放弃CPU的使用权,转为阻塞态,而不是在原地循环测试(避免忙等待)。

2.3.2 实现临界区互斥的基本方法#

1. 软件实现方法#

在进入区设置并检查一些标志,用以表明是否有进程正在临界区中。若已有进程在临界区,则其他进程在进入区通过循环检查的方式等待;当进程离开临界区后,在退出区修改相应的标志,以允许其他进程进入。

(1) 算法一:单标志法#

该算法设置一个公用整型变量 turn,用于指示当前允许进入临界区的进程编号,当 turn = 0 时,表示允许 P0P_0 进入临界区;当 turn = 1 时,表示允许 P1P_1 进入临界区。每当一个进程 PiP_i 退出临界区时,就将 turn 置为 jj (i=0,j=1i=0, j=1i=1,j=0i=1, j=0),即将临界区的使用权转让给另一个进程。

进程 P0P_0

while (turn != 0); // 进入区
critical section; // 临界区
turn = 1; // 退出区
remainder section; // 剩余区

进程 P1P_1

while (turn != 1); // 进入区
critical section; // 临界区
turn = 0; // 退出区
remainder section; // 剩余区

该算法可以保证每次只有一个进程进入临界区。但缺点是:两个进程必须交替进入临界区。若某个进程不再请求进入临界区,则另一个进程也将无法再次进入(违背“空闲让进”准则),造成资源利用不充分。例如,假设 P0P_0 顺利进入临界区并执行完毕,此时临界区处于空闲状态,但若 P1P_1 没有进入临界区的打算,而 turn = 1 一直成立,则 P0P_0 就无法再次进入临界区。

(2) 算法二:双标志先检查法#

该算法设置一个布尔型数组 flag[2],用来标记各进程是否希望进入临界区。flag[i] = true 表示 PiP_i 想进入临界区(i=0i=011)。PiP_i 进入临界区前,先检查对方是否想进入;若对方想进入,则等待;否则,将自己的 flag[i] 置为 true,然后进入临界区。当 PiP_i 退出临界区时,将 flag[i] 置为 false

进程 P0P_0

flag[0] = true; // ①
while (flag[1]); // ② 进入区
critical section; // ③ 临界区
flag[0] = false; // 退出区
remainder section; // 剩余区

进程 P1P_1

flag[1] = true; // ④
while (flag[0]); // 进入区
critical section; // 临界区
flag[1] = false; // 退出区
remainder section; // 剩余区

优点:无须交替进入,允许进程连续使用临界区。缺点:在某些执行顺序下,P0P_0P1P_1 可能同时进入临界区。例如,若执行顺序为①②③④,即两个进程都先检查对方的标志(发现为 false),然后才设置自己的标志,结果双方都通过检查,从而同时进入临界区(违背“忙则等待”准则)。原因在于“检查对方标志”和“设置自己标志”两个操作不是原子的,中间可能发生进程切换。

(3) 算法三:双标志后检查法#

算法二的问题在于先检查后设置,而这两次操作无法一气呵成。因此,联想到先设置后检查的方法,以避免上述问题。算法三改为先设置自己的标志,再检查对方的标志:进程 PiP_i 首先将自己的 flag[i] 置为 true,然后检查 flag[j],若 flag[j]true,则等待;否则,进入临界区。

进程 P0P_0

flag[0] = true; // ①
while (flag[1]); // ③ 进入区
critical section; // 临界区
flag[0] = false; // 退出区
remainder section; // 剩余区

进程 P1P_1

flag[1] = true; // ②
while (flag[0]); // ④ 进入区
critical section; // 临界区
flag[1] = false; // 退出区
remainder section; // 剩余区

然而,这种方案也可能出现问题。例如,若执行顺序为①②③④,即两个进程都先设置了各自的标志,然后分别检查对方的标志,会发现对方也想进入临界区,于是彼此等待,谁也无法进入。此时即使临界区空闲,也没有进程能够进入(违背“空闲让进”准则);同时,进程可能会长期甚至永远无法获得访问权(违背“有限等待”准则),导致“饥饿”现象。

(4) 算法四:Peterson算法#

Peterson 算法结合了算法一和算法三的思想:利用 flag[] 数组解决互斥访问问题,利用 turn 变量解决“饥饿”问题。若 flag[i] = true,则表示 PiP_i 准备进入临界区;若 turn = i,则表示当两个进程同时请求时,优先允许 PiP_i 进入临界区。具体做法如下:在 PiP_i 进入临界区之前,先将自己的 flag[i] 置为 true,并将 turn 置为 jj(主动将进入机会“谦让”给对方)。随后,通过循环条件 while (flag[j] && turn == j) 判断是否需要等待,从而确保双方同时请求进入临界区时,只允许一个进程进入。

进程 P0P_0

flag[0] = true; // 进入区
turn = 1; // 进入区
while (flag[1] && turn == 1); // 进入区
critical section; // 临界区
flag[0] = false; // 退出区
remainder section; // 剩余区

进程 P1P_1

flag[1] = true; // 进入区
turn = 0; // 进入区
while (flag[0] && turn == 0); // 进入区
critical section; // 临界区
flag[1] = false; // 退出区
remainder section; // 剩余区

若仅有一个进程请求进入(例如 P0P_0),由于 flag[1]false,其 while 条件不成立,可直接进入临界区。若两个进程同时请求进入,P0P_0turn 置为1,P1P_1turn 置为0。由于这两个赋值在运行时是依次完成的,turn 的最终值由后执行赋值的进程所决定。而 while 条件仅在“对方准备进入”且“turn 指向对方”时成立,因此总有一个进程的等待条件不成立,从而得以进入临界区。

由此可见,Peterson算法很好地遵循了“空闲让进”“忙则等待”和“有限等待”三条准则。但由于它采用忙等待(在进入区中循环测试),没有遵循“让权等待”准则。尽管如此,在纯软件实现的互斥算法中,Peterson 算法是在满足前三条准则方面最完善的方案。

2. 硬件实现方法#

现代计算机提供了特殊的硬件指令,能够以原子方式对一个字进行检测和修改,或是交换两个字的内容。因此,可利用它们实现临界区的互斥访问。

实际上,管理临界区时,可将共享标志视为一把锁:“锁开”表示允许进入,“锁关”表示需等待,初始时锁为打开状态。每个欲进入临界区的进程必须先测试该锁:若锁已关,则等待,直至其被打开;一旦发现锁开,就应立即上锁,以阻止其他进程进入临界区。显然,若多个进程几乎同时检测到“锁开”,就可能同时进入临界区,因此测试与上锁必须构成一个原子操作

(1) 中断屏蔽方法#

关中断是实现互斥最简单的方法之一。其做法是:在执行临界区代码之前关闭中断,待临界区执行完毕后再重新开启中断。由于CPU仅在响应中断时才可能发生进程或线程切换,因此在关中断期间,当前进程不会被抢占,从而确保临界区代码不受干扰地连续执行。其典型模式如下:

关中断;
临界区;
开中断;

这种方法的缺点

  1. 限制了CPU的并发执行能力,显著降低系统效率。
  2. 对内核而言,在更新关键变量的几条指令期间关中断是可行的,但若将关中断权限开放给用户进程,则存在风险:若某进程关中断后未及时开中断,可能导致系统死锁或崩溃。
  3. 不适用于多处理器系统,因为在某一CPU上关中断无法阻止其他CPU上的进程并发访问同一临界区。
(2) 硬件指令方法——TestAndSet指令#

借助一条名为TestAndSet(简称TS)的硬件指令实现互斥,该指令是原子操作。其功能是:读取指定标志的当前值,并立即将其置为 true,然后返回读取到的旧值。功能描述如下:

boolean TestAndSet(boolean *lock) {
boolean old;
old = *lock; // old用来存放lock的旧值
*lock = true; // 将lock置为true
return old; // 返回lock的旧值
}

使用TS指令管理临界区时,需要为每个临界资源设置一个共享布尔变量 lock(可视为一把锁,初值为 false):若 lock = true,表示资源已被占用(已加锁);若 lock = false,表示资源空闲(未加锁)。进程在进入临界区前,通过执行 TestAndSet(&lock) 尝试获取锁:

  1. 若返回值为 false,则说明 lock 原为 false,即无其他进程在临界区,当前进程成功获得锁,并将 lock 置为 true,从而阻止其他进程进入。
  2. 若返回值为 true,则说明 lock 原为 true,即已有进程在临界区,当前进程需循环等待,直至持有锁的进程退出临界区并将 lock 置为 false。典型实现如下:
while (TestAndSet(&lock)); // 尝试获取锁(忙等待)
进程的临界区代码段;
lock = false; // 解锁
进程的其他代码;

相比软件实现方法,TS指令由硬件保证“检查锁”与“加锁”这两个操作的原子性。相比关中断方法,由于“lock”是共享变量,该机制适用于多处理器系统缺点:等待进程会持续执行TS指令,形成忙等待,无法实现“让权等待”,造成CPU资源浪费。

(3) 硬件指令方法——Swap 指令#

Swap 指令的功能是原子地交换两个变量的值。其功能描述如下:

void Swap(boolean *a, boolean *b) {
boolean temp = *a;
*a = *b;
*b = temp;
}
NOTE

以上对 TS和 Swap 指令的描述仅为功能示意,实际由硬件逻辑实现,不会被中断。

使用Swap 指令管理临界区时,需要为每个临界资源设置一个共享布尔变量 lock(初值为 false);并在每个进程中定义一个局部布尔变量 key每次尝试进入临界区前将其初始化为 true。其工作原理与TS指令本质相同:通过一次原子操作,既获取锁的当前状态,又将锁置为占用。具体过程如下:进程执行 Swap(&lock, &key) 后,key 中保存了 lock 的旧值。若 key = false,说明此前无进程持有锁,当前进程成功获得访问权;若 key = true,说明锁已被占用,进程需继续循环等待。因此,进入临界区的条件是 key 变为 false。典型实现如下:

boolean key = true;
while (key != false)
Swap(&lock, &key);
进程的临界区代码段;
lock = false;
进程的其他代码;

硬件指令方法实现互斥的优点

  1. 实现简单,正确性易于验证。
  2. 支持任意数量的进程,并适用于多处理器系统。
  3. 系统中可存在多个临界区,只需为每个临界区分配独立的 lock 变量。

缺点

  1. 等待进程持续执行Swap指令,形成忙等待,无法实现“让权等待”,造成CPU资源浪费。
  2. 锁的获取顺序不确定,可能导致某些进程长期无法获得访问机会,产生“饥饿”现象。

无论是软件还是硬件实现方法,都应重点理解其执行逻辑与同步思想。需注意:上述代码并非实际编程接口,而是描述进程间同步与互斥的抽象模型。在真实操作系统中,这些底层机制已被封装在信号量、互斥锁等高级同步原语中,用户通常无须直接编写忙等待循环。

2.3.3 互斥锁#

解决临界区问题最简单的工具是互斥锁(mutex lock)。一个进程在进入临界区前调用 acquire() 以获得锁;在退出临界区时调用 release() 以释放锁。每个互斥锁包含一个布尔变量 available,用于表示锁是否可用。若锁可用,则调用 acquire() 会成功,并将锁置为不可用。当一个进程试图获取不可用的锁时,它将被阻塞,直到锁被释放。其过程描述如下:

acquire() { // 获得锁的定义
while (!available) // 忙等待
;
available = false; // 获得锁
}
release() { // 释放锁的定义
available = true; // 释放锁
}

acquire()release() 的执行必须是原子操作,因此互斥锁通常采用硬件机制来实现。上述互斥锁也称自旋锁,其主要缺点是忙等待:当有一个进程在临界区中时,任何其他进程在进入临界区前都必须连续循环调用 acquire()。类似的还有前面介绍的单标志法、TS指令和Swap指令。当多个进程共享同一CPU时,这种连续循环显然浪费了CPU周期。因此,自旋锁通常用于多处理器系统,一个线程可以在一个处理器上“旋转”,而不影响其他线程的执行。自旋锁的优点:进程在等待锁期间没有上下文切换;若持有锁的时间较短,则等待代价较低。

本节后面,将研究如何使用互斥锁解决经典同步问题。

2.3.4 信号量#

信号量机制是一种功能强大的同步机制,既能解决互斥问题,也能处理进程间的同步问题。它仅能通过两个标准原语访问:wait()signal()。这两个操作也称P操作V操作

原语是指完成特定功能且在执行过程中不可被中断的操作序列,通常由硬件实现。例如,前述的TS指令和Swap指令均为硬件实现的原子操作。在单CPU系统中,也可通过屏蔽中断的方式确保原语执行的原子性。之所以要求原语不可中断,是因为若其对共享变量的操作被中途打断,可能与其他进程的操作交织,从而破坏临界区的互斥性。

1. 整型信号量#

整型信号量用一个整型变量 SS 表示某类资源的可用数量。与普通整型变量不同,对其操作仅限于三种:初始化、wait 操作和 signal 操作。其行为可描述如下:

wait(S) { // 相当于进入区(原子操作)
while (S <= 0); // 若资源数不够,则一直循环等待
S = S - 1; // 若资源数够,则占用一个资源
}
signal(S) { // 相当于退出区(原子操作)
S = S + 1; // 使用完后,就释放一个资源
}

在整型信号量机制中,当 S0S \le 0 时,进程将持续循环测试,处于忙等待状态。这种实现未遵循“让权等待”准则,会浪费CPU时间,尤其是在单CPU系统中效率低下。

2. 记录型信号量#

为了克服忙等待的缺陷,Dijkstra 提出了记录型信号量,它通过阻塞机制实现了“让权等待”。记录型信号量采用结构化数据表示:除整型字段 value(表示当前可用资源数)外,还包含一个进程链表 LL(等待队列),用于链接所有因请求该资源而被阻塞的进程。其定义如下:

typedef struct {
int value;
struct process *L;
} semaphore;

相应的 wait(S)signal(S) 操作定义如下:

void wait(semaphore S) { // 相当于申请资源
S.value--;
if (S.value < 0) {
add this process to S.L;
block(S.L);
}
}
void signal(semaphore S) { // 相当于释放资源
S.value++;
if (S.value <= 0) {
remove a process P from S.L;
wakeup(P);
}
}

执行 wait(S) 时,进程首先将 S.value 减1,表示请求一个该类资源。若减1后 S.value < 0,则表明该类资源已分配完毕,当前进程应调用 block 原语自我阻塞,主动放弃CPU,并加入等待队列 S.L,从而实现“让权等待”

执行 signal(S) 时,进程将 S.value 加1,表示释放一个该类资源。若加1后 S.value \le 0,则说明仍有进程在等待该类资源,此时应调用 wakeup 原语,从 S.L 中移出一个进程并将其唤醒。

3. 利用信号量实现进程互斥#

为使多个进程能够互斥地访问某一临界资源,可为该资源设置一个互斥信号量 SS,其初值设为1(表示该资源的可用数量为1)。随后,将各进程中访问该资源的临界区置于 P(S)V(S) 操作之间。具体而言,每个进程在进入临界区前必须执行 P(S) 操作:若此时资源空闲(S=1S=1),则 P(S) 成功,SS 被减为0,进程进入临界区;若资源已被占用(S=0S=0),则后续进程执行 P(S) 时会因 S0S \le 0 而被阻塞,并加入该信号量的等待队列。当进程退出临界区后,执行 V(S) 操作,将 SS 加1,表示释放资源,并唤醒等待队列中的一个进程(若存在)。其实现如下:

semaphore S = 1; // 初始化信号量,初值为1
P1() {
P(S); // 申请临界资源,加锁
进程P1的临界区;
V(S); // 释放临界资源,解锁
}
P2() {
P(S); // 申请临界资源,加锁
进程P2的临界区;
V(S); // 释放临界资源,解锁
}

在仅有两个进程竞争该资源的情形下,SS 的取值仅可能为1、0或-1。若 S=1S=1,表示两个进程均未进入临界区;若 S=0S=0,表示有一个进程正在临界区中;若 S=1S=-1,则表示一个进程在临界区,另一个进程因请求资源而阻塞在等待队列中,需等待临界区中的进程执行 V(S) 后方可被唤醒。

NOTE
  1. 不同的临界资源应配置独立的互斥信号量,避免相互干扰。
  2. P(S)V(S) 必须严格成对出现:缺少 P(S) 将无法保证互斥访问,缺少 V(S) 则会导致资源永不释放,使等待进程永久阻塞。
  3. 若系统中存在多个同类资源,应将信号量初值设为资源总数,进程申请资源时执行 P(S),释放时执行 V(S)

4. 利用信号量实现同步#

进程间的同步源于协作需求。在并发执行中,各进程具有异步性,其推进次序不确定。若某一进程的操作依赖于另一进程的执行结果,则必须确保前者在后者之后执行。例如,进程 P1P_1P2P_2 并发执行,其中 P2P_2 的语句 yy 需要使用 P1P_1 的语句 xx 的执行结果,则必须保证语句 yy 在语句 xx 之后执行。为此,可引入一个同步信号量 SS,其初值设为0(可理解为:初始时 P2P_2 所需的某个资源尚未满足,而该资源只能由 P1P_1 的执行结果提供)。其实现如下:

semaphore S = 0; // 初始化信号量,初值为0
P1() {
x; // 执行语句x
V(S); // 通知P2:x已完成
}
P2() {
P(S); // 等待x完成
y; // 使用x的结果,执行语句y
}

该机制的行为取决于两个进程的执行顺序。

  • P1P_1 先执行 V(S):信号量 SS 由0增为1。随后 P2P_2 执行 P(S) 时,由于 S=1S=1,表示此时有可用资源,执行 S--S=0S=0,P操作不会调用 block 原语,而是继续执行语句 yy
  • P2P_2 先执行 P(S):信号量 SS 由0减为-1,表示当前无可用资源,因此P操作会调用 block 原语,将 P2P_2 阻塞。当 P1P_1 完成语句 xx 并执行 V(S) 后,SS 由-1增为0,于是V操作会调用 wakeup 原语,唤醒等待队列中的 P2P_2,使其得以继续执行语句 yy

PV操作的用法总结:在同步问题中,若某个行为会提供某种资源,则应在该行为之后V这种资源;若某个行为需要使用这种资源,则应在该行为之前P这种资源。在互斥问题中,P、V操作必须紧夹使用临界资源的那个行为,中间不得插入任何无关代码。

5. 利用信号量实现前驱关系#

信号量还可用于描述程序段之间的前驱关系。一个前驱关系示例,其中 S1,S2,,S6S_1, S_2, \dots, S_6 均为简单的程序段(仅含一条语句),箭头表示执行顺序的约束。

每对前驱关系都对应一个同步问题:后继段必须等待前驱段完成。因此,可为每条前驱边设置一个同步信号量,初值均为0。其使用规则如下:前驱段执行完毕后,执行V操作,通知所有直接后继;后继段开始执行前,执行P操作,等待所有直接前驱完成。以图2.11中的依赖关系为例,需满足:S1S2S_1 \to S_2S1S3S_1 \to S_3S2S4S_2 \to S_4S2S5S_2 \to S_5S3S6S_3 \to S_6S4S6S_4 \to S_6S5S6S_5 \to S_6,为此,分别定义同步信号量 a12,a13,a24,a25,a36,a46,a56a_{12}, a_{13}, a_{24}, a_{25}, a_{36}, a_{46}, a_{56},其中下标 ijij 表示该信号量用于协调 SiS_iSjS_j 的前驱关系。各程序段的实现如下:

semaphore a12=0, a13=0, a24=0, a25=0, a36=0, a46=0, a56=0;
// 初始化所有信号量,初值均为0
S1() {
...; // 执行S1
V(a12); V(a13); // 通知S2和S3:S1已完成
}
S2() {
P(a12); // 等待S1完成
...; // 执行S2
V(a24); V(a25); // 通知S4和S5:S2已完成
}
S3() {
P(a13); // 等待S1完成
...; // 执行S3
V(a36); // 通知S6:S3已完成
}
S4() {
P(a24); // 等待S2完成
...; // 执行S4
V(a46); // 通知S6:S4已完成
}
S5() {
P(a25); // 等待S2完成
...; // 执行S5
V(a56); // 通知S6:S5已完成
}
S6() {
P(a36); // 等待S3完成
P(a46); // 等待S4完成
P(a56); // 等待S5完成
...; // 执行S6
}

6. 分析进程同步和互斥问题的方法步骤#

  1. 关系分析。首先识别参与并发的进程,并厘清其间的关系:互斥关系(多个进程竞争同一临界资源)、同步关系(某进程需等待另一进程的执行结果)、前驱关系(存在明确的执行顺序约束)。每类关系均可映射到前述经典信号量模型。
  2. 思路梳理。根据各进程的操作流程,确定关键同步点,并初步规划P、V操作的位置。可参考和类比典型场景,如生产者-消费者、读者-写者、前驱图。
  3. 信号量设置。基于上述分析,定义所需的信号量,明确其语义与初值,并将P、V操作嵌入各进程的适当位置,确保逻辑正确,避免死锁与饥饿,且满足所有约束条件。

评论