2. 同步

2026 旧版 · 非 2027 主源 · 原书检索 · 文字用于定位,公式与图表请回原页核验 · 本次导入快照

## 2. 同步

同步亦称直接制约关系,是指为完成某种任务而建立的两个或多个进程,这些进程因为需要协调它们的运行次序而等待、传递信息所产生的制约关系。同步关系源于进程之间的相互合作。

例如,输入进程 A 通过单缓冲向进程 B 提供数据。当该缓冲区空时,进程 B 不能获得所需数据而阻塞,一旦进程 A 将数据送入缓冲区,进程 B 就被唤醒。反之,当缓冲区满时,进程 A 被阻塞,仅当进程 B 取走缓冲数据时,才唤醒进程 A。

## 3. 互斥

互斥也称间接制约关系。当一个进程进入临界区使用临界资源时,另一个进程必须等待,当占用临界资源的进程退出临界区后,另一进程才允许去访问此临界资源。

例如,在仅有一台打印机的系统中,有两个进程 A 和进程 B,若当进程 A 需要打印时,系统已将打印机分配给进程 B,则进程 A 必须阻塞。一旦进程 B 将打印机释放,系统便将进程 A 唤醒,并将其由阻塞态变为就绪态。


<!-- page: 110 -->

## 附属说明
▶ 实现临界区互斥必须遵循的准则(2020)

为禁止两个进程同时进入临界区,同步机制应遵循以下准则:

1)空闲让进。临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区。
2)忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待。
3)有限等待。对请求访问的进程,应保证能在有限时间内进入临界区,防止进程无限等待。
4)让权等待(原则上应该遵循,但非必须)。当进程不能进入临界区时,应立即释放处理器,防止进程忙等待。

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

### 附属说明
▶ 实现互斥的软/硬件方法的特点(2018)

1. 软件实现方法 公众号:小兔网盘 免费分享无水印PDF

在进入区设置并检查一些标志来标明是否有进程在临界区中,若已有进程在临界区,则在进入区通过循环检查进行等待,进程离开临界区后则在退出区修改标志。

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

该算法设置一个公用整型变量 turn,指示允许进入临界区的进程编号,当 turn = 0 时,表示允许 P₀ 进入临界区;当 turn = 1 时,表示允许 P₁ 进入临界区。进程退出临界区时将临界区的使用权赋予给另一个进程,当 Pᵢ 退出临界区时,将 turn 置为 j(i = 0、j = 1 或 i = 1、j = 0)。

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

该算法可以实现每次只允许一个进程进入临界区。但两个进程必须交替进入临界区,若某个进程不再进入临界区,则另一个进程也将无法进入临界区(违背“空闲让进”准则)。这样很容易造成资源利用不充分。若 P₀ 顺利进入临界区并从临界区离开,则此时临界区是空闲的,但 P₁ 并没有进入临界区的打算,而 turn = 1 一直成立,则 P₀ 就无法再次进入临界区。

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

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

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

优点:不用交替进入,可连续使用。缺点:P₀ 和 P₁ 可能同时进入临界区。按序列①②③④执行时,即检查对方标志后和设置自己的标志前可能发生进程切换,结果双方都检查通过,会同时进入临界区(违背“忙则等待”准则)。原因在于检查和设置操作不是一气呵成的。

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

算法二先检查对方的标志,再设置自己的标志,但这两个操作又无法一气呵成,于是导致两个进程出现同时进入临界区的问题。因此,想到先设置后检查的方法,以避免上述问题。算法三先设置自己的标志,再检查对方的标志,若对方的标志为 true,则等待;否则,进入临界区。


<!-- page: 111 -->

[tbl-28.md](tbl-28.md)

按序列①②③④执行时,即两个进程依次设置自己的标志,并依次检查对方的标志,发现对方也想进入临界区,双方都争着进入临界区,结果谁也进不了(违背“空闲让进”准则),于是因各个进程都长期无法访问临界区而导致“饥饿”现象(违背“有限等待”准则)。

## (4)算法四:Peterson算法

### 衡量方法

Peterson算法实现互斥的分析(2010)

Peterson算法结合了算法一和算法三的思想,利用flag[]解决互斥访问问题,而利用turn解决“饥饿”问题。若双方都争着进入临界区,则可让进程将进入临界区的机会谦让给对方。即每个进程进入临界区之前,先设置自己的flag标志,再设置允许进入turn标志;之后,再同时检测对方的flag和turn标志,以保证双方同时要求进入临界区时,只允许一个进程进入。

[tbl-29.md](tbl-29.md)

为进入临界区,Pᵢ先将flag[i]置为true,并将turn置为j,表示优先让对方Pⱼ进入临界区(i=0、j=1或i=1、j=0)。若双方试图同时进入,则turn几乎同时被置为i和j,但只有一个赋值语句的结果会保持,另一个也会执行,但会被立即重写。变量turn的最终值决定了哪个进程被允许先进入临界区,若turn的值为i,则Pᵢ进入临界区。当Pᵢ退出临界区时,将flag[i]置为false,以允许Pⱼ进入临界区,则Pⱼ在Pᵢ退出临界区后很快就能进入临界区。若Pᵢ不想进入临界区,即flag[i] = false(Pⱼ不会陷入while循环),则Pⱼ就可进入临界区。

由此可见,Peterson算法很好地遵循了“空闲让进”“忙则等待”“有限等待”三个准则,但依然未遵循“让权等待”准则。相比于前三种算法,该算法是最好的,但依然不够好。

## 2. 硬件实现方法

理解本节介绍的硬件实现,对学习后面的信号量很有帮助。计算机提供了特殊的硬件指令,允许对一个字的内容进行检测和修正,或对两个字的内容进行交换等。

## (1)中断屏蔽方法

### 衡量方法

关中断指令实现互斥的分析(2021)

当一个进程正在执行它的临界区代码时,防止其他进程进入其临界区的最简单方法是关中断。因为CPU只在发生中断时引起进程切换,因此屏蔽中断能够保证当前运行的进程让临界区代码顺利地执行完,进而保证互斥的正确实现,然后执行开中断。其典型模式为

...
关中断;
临界区;
开中断;
这种方法的缺点:①限制了CPU交替执行程序的能力,因此系统效率会明显降低。②对内 <!-- page: 112 --> 101 核来说,在它执行更新变量的几条指令期间,关中断是很方便的,但将关中断的权限交给用户则很不明智,若一个进程关中断后不再开中断,则系统可能因此终止。③不适用于多处理器系统,因为在一个CPU上关中断并不能防止进程在其他CPU上执行相同的临界区代码。 (2)硬件指令方法——TestAndSet 指令 借助一条硬件指令——TestAndSet 指令(简称 TS 指令)实现互斥,这条指令是原子操作。其功能是读出指定标志后将该标志设置为真。指令的功能描述如下:
boolean TestAndSet(boolean *lock) {
boolean old;
old = *lock;  //old用来存放lock的旧值
*lock = true;   //无论之前是否已加锁,都将lock置为true
return old;   //返回lock的旧值
}
**创建速度** ▶ TestAndSet 指令实现互斥的分析(2016) 用TS指令管理临界区时,为每个临界资源设置一个共享布尔变量lock,表示该资源的两种状态:true表示正被占用(已加锁);false表示空闲(未加锁),初值为false,因此可将lock视为一把锁。进程在进入临界区之前,先用TS指令检查lock值:①若为false,则表示没有进程在临界区,可以进入,并将lock置为true,这意味着关闭了临界资源(加锁),使任何进程都不能进入临界区;②若为true,则表示有进程在临界区,进入循环等待,直到当前访问临界区的进程退出时解锁(将lock置为false)。利用TS指令实现互斥的过程描述如下:
while TestAndSet(&amp;amp;lock);   //加锁并检查
进程的临界区代码段;
lock=false;             //解锁
进程的其他代码;
相比于软件实现方法,TS指令将“加锁”和“检查”操作用硬件的方式变成了一气呵成的原子操作。相比于关中断方法,由于“锁”是共享的,这种方法适用于多处理器系统。缺点是,暂时无法进入临界区的进程会占用CPU循环执行TS指令,因此不能实现“让权等待”。 (3)硬件指令方法——Swap 指令 Swap 指令的功能是交换两个字(字节)的内容。其功能描述如下:
Swap(boolean *a, boolean *b) {
boolean temp = *a;
*a = *b;
*b = temp;
}
**注意** 以上对TS和Swap 指令的描述仅为功能描述,它们由硬件逻辑实现,不会被中断。 **创建速度** ▶ Swap 指令与函数实现的分析(2023) 用Swap 指令管理临界区时,为每个临界资源设置一个共享布尔变量 lock,初值为 false;在每个进程中再设置一个局部布尔变量 key,初值为 true,用于与 lock 交换信息。从逻辑上看,Swap 指令和 TS 指令实现互斥的方法并无太大区别,都是先记录此时临界区是否已加锁(记录在变量 key 中),再将锁标志 lock 置为 true,最后检查 key,若 key 为 false,则说明之前没有其他进程对临界区加锁,于是跳出循环,进入临界区。其处理过程描述如下:
boolean key = true;
while (key != false)


&lt;!-- page: 113 --&gt;

```txt
Swap(&amp;amp;lock, &amp;amp;key);
进程的临界区代码段;
lock=false;
进程的其他代码;
用硬件指令方法实现互斥的优点:①简单、容易验证其正确性;②适用于任意数量的进程,支持多处理器系统;③支持系统中有多个临界区,只需为每个临界区设立一个布尔变量。缺点:①等待进入临界区的进程会占用 CPU 执行 while 插环,不能实现“让权等待”;②从等待进程中随机选择一个进程进入临界区,有的进程可能一直选不上,从而导致“饥饿”现象。 无论是软件实现方法还是硬件实现方法,读者都需要理解它的执行过程,特别是软件实现方法。以上的代码实现与我们平时在编译器上写的代码意义不同,以上的代码实现是为了描述进程实现同步和互斥的过程,并不是说计算机内部实现同步互斥的就是这些代码。 ## 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(),或者简称 P 操作和 V 操作。 原语是指完成某种功能且不被分割、不被中断执行的操作序列,通常可由硬件来实现。例如,前述的 TS 指令和 Swap 指令就是由硬件实现的原子操作。原语功能的不被中断执行特性在单处理机上可由软件通过屏蔽中断方法实现。原语之所以不能被中断执行,是因为原语对变量的操作过程若被打断,可能去运行另一个对同一变量的操作过程,从而出现临界段问题。 **备注/参考书** 信号量的含义(2010) ### 1. 整型信号量 整型信号量被定义为一个用于表示资源数量的整型量 S,相比于普通整型变量,对整型信号量的操作只有三种:初始化、wait 操作和 signal 操作。wait 操作和 signal 操作可描述为 <!-- page: 114 -->
wait(S) { //相当于进入区
while (S &amp;lt;= 0); //若资源数不够,则一直循环等待
S = S - 1; //若资源数够,则占用一个资源
}
signal(S) { //相当于退出区
S = S + 1; //使用完后,就释放一个资源
}
在整型信号量机制中的 wait 操作,只要信号量 $S \leqslant 0$,就会不断循环测试。因此,该机制并未遵循“让权等待”的准则,而是使进程处于“忙等”的状态。 ## 2. 记录型信号量 记录型信号量机制是一种不存在“忙等”现象的进程同步机制。除了需要一个用于代表资源数量的整型变量 value 外,再增加一个进程链表 L,用于链接所有等待该资源的进程。记录型信号量得名于采用了记录型的数据结构。记录型信号量可描述为
typedef struct{
int value;
struct process *L;
} semaphore;
**数据追踪** ▶ wait()操作导致线程状态的变化(2023) **数据追踪** ▶ 遵循“让权等待”的互斥方法(2018) 相应的 wait(S) 和 signal(S) 的操作如下。
void wait(semaphore S) { //相当于申请资源
S.value--;
if (S.value &amp;lt; 0) {
add this process to S.L;
block (S.L);
}
}
对信号量 S 的一次 P 操作,表示进程请求一个该类资源,因此执行 S.value--,使系统中可供分配的该类资源数减 1。当 S.value &lt; 0 时,表示该类资源已分配完毕,因此应调用 block 原语进行自我阻塞(当前运行的进程:运行态 → 阻塞态),主动放弃 CPU,并插入该类资源的等待队列 S.L,可见该机制遵循了“让权等待”准则。
void signal(semaphore S) { //相当于释放资源
S.value++;
if (S.value &amp;lt;= 0) {
remove a process P from S.L;
wakeup(P);
}
}
对信号量 S 的一次 V 操作,表示进程释放一个该类资源,因此执行 S.value++,使系统中可供分配的该类资源数加 1。若加 1 后仍是 S.value ≤ 0,则表示仍有进程在等待该类资源,因此应调用 wakeup 原语将 S.L 中的第一个进程唤醒(被唤醒进程:阻塞态 → 就绪态)。 ## 3. 利用信号量实现进程互斥 **数据追踪** ▶ 利用信号量实现互斥的实现(2024) 为了使多个进程能互斥地访问某个临界资源,需要为该资源设置一个互斥信号量 S,其初值为 1(可用资源数为 1),然后将各个进程访问该资源的临界区置于 P(S) 和 V(S) 之间。这样,每个要访问该资源的进程在进入临界区之前,都要先对 S 执行 P 操作,若该资源此刻未被访问,则本 <!-- page: 115 --> 次P操作必然成功,进程便可进入自己的临界区。这时,若再有其他进程也要进入自己的临界区,对S执行P操作必然失败,因此主动阻塞,从而保证了该资源能被互斥访问。当访问该资源的进程退出临界区后,要对S执行V操作,以便释放该临界资源。其实现如下:
semaphore S=1; //初始化信号量,初值为1
P1(){
...
P(S); //准备访问临界资源,加锁
进程P1的临界区;
V(S); //访问结束,解锁
...
}
P2(){
...
P(S); //准备访问临界资源,加锁
进程P2的临界区;
V(S); //访问结束,解锁
...
}
S的取值范围为(-1,0,1)。当 S = 1 时,表示两个进程都未进入临界区;当 S = 0 时,表示有一个进程已进入临界区;当 S = -1 时,表示有一个进程正在临界区,另一个进程因等待而阻塞在阻塞队列中,需要被当前已在临界区运行的进程退出时唤醒。 ## 注意 ① 对不同的临界资源需要设置不同的互斥信号量。② P(S)和 V(S)必须成对出现,缺少 P(S)就不能保证对临界资源的互斥访问;缺少 V(S)会使临界资源永远不被释放,从而使因等待该资源而阻塞的进程永远不能被唤醒。③ 考试还可能考查多个资源的问题,有多少资源就将信号量初值设为多少,申请资源时执行 P 操作,释放资源时执行 V 操作。 ## 4. 利用信号量实现同步 ### 的原始链 $\triangleright$ 利用信号量实现同步(2024) 同步源于进程之间的相互合作,要让本来异步的并发进程相互配合,有序推进。例如,进程 $\mathrm{P_1}$ 和 $\mathrm{P_2}$ 并发执行,存在异步性,因此二者交替推进的次序是不确定的,若 $\mathrm{P_2}$ 的语句 $y$ 要使用 $\mathrm{P_1}$ 的语句 $x$ 的运行结果,则必须保证语句 $y$ 一定在语句 $x$ 之后执行。为了实现这种同步关系,需要设置一个同步信号量 $S$,其初值为 0(可以这么理解:刚开始是没有这种资源的,$\mathrm{P_2}$ 需要使用这种资源,而又只能由 $\mathrm{P_1}$ 产生这种资源)。其实现如下:
semaphore S=0; //初始化信号量,初值为0
P1(){
x; //执行语句x
V(S); //告诉进程P2,语句x已经完成
...
}
P2(){
...
P(S); //检查语句x是否运行完成
y; //获得x的运行结果,执行语句y
...
}
若先执行到 $\mathrm{V(S)}$,则执行 $\mathrm{S++}$ 后 $\mathrm{S} = 1$。之后 $\mathrm{P}_2$ 执行到 $\mathrm{P(S)}$ 时,由于 $\mathrm{S} = 1$,表示此时有可用 <!-- page: 116 --> 资源,执行 S--后 S = 0,P 操作中不会执行 block 原语,而继续往下执行语句 y。 若先执行到 P(S),执行 S--后 S = -1,表示此时没有可用资源,因此 P 操作中会执行 block 原语,P₂ 请求阻塞。P₁ 的语句 x 执行完后,执行 V(S),执行 S++后 S = 0,因此 V 操作中会执行 wakeup 原语,唤醒在该信号量对应的阻塞队列中的 P₂,这样 P₂ 就可以继续执行语句 y。 PV 操作实现同步互斥的简单总结:在同步问题中,若某个行为会提供某种资源,则在这个行为之后 V 这种资源;若某个行为要用到这种资源,则在这个行为之前 P 这种资源。在互斥问题中,P, V 操作要紧夹使用临界资源的那个行为,中间不能有其他冗余代码。 ## 5. 利用信号量实现前驱关系 **前驱类型** 信号量实现前驱关系的应用题(2020、2022) 信号量也可用来描述程序或语句之间的前驱关系。图 2.10 给出了一个前驱关系举例,其中 S₁, S₂, S₃, …, S₆ 是简单的程序段(只有一条语句)。 图 2.10 前驱关系举例 其实,每对前驱关系都是一个同步问题,因此要为每对前驱关系设置一个同步信号量,其初值均为 0。在“前驱操作”之后,对相应的同步信号量执行 V 操作,在“后继操作”之前,对相应的同步信号量执行 P 操作。以图 2.10 为例,S₂ 是 S₁ 的后继,要用到 S₁ 的资源,前面总结过,在同步问题中,要用到某种资源,就要在行为之前 P 这种资源:S₂ 是 S₄, S₅ 的前驱,给 S₄, S₅ 提供资源,因此要在 S₂ 之后 V 由 S₄ 和 S₅ 所产生的资源。为保证 S₁→S₂,S₁→S₃,S₂→S₄,S₂→S₅,S₃→S₆,S₄→S₆,S₅→S₆ 的前驱关系,需分别设置同步信号量 a12, a13, a24, a25, a36, a46, a56。其实现如下:
semaphore a12=a13=a24=a25=a36=a46=a56=0; //初始化信号量
S1(){
…;
V(a12); V(a13); //S1 已经运行完成
}
S2(){
P(a12); //检查 S1 是否运行完成
…;
V(a24); V(a25); //S2 已经运行完成
}
S3(){
P(a13); //检查 S1 是否已经运行完成
…;
V(a36); //S3 已经运行完成
}
S4(){
P(a24); //检查 S2 是否已经运行完成
…;
V(a46); //S4 已经运行完成
}
S5(){
P(a25); //检查 S2 是否已经运行完成
关注公众号【考研小舟】 免费考研资料&amp;无水印PDF <!-- page: 117 --> ```txt V(a56); //S5已经运行完成 } S6() { P(a36); //检查S3是否已经运行完成 P(a46); //检查S4是否已经运行完成 P(a56); //检查S5是否已经运行完成 ...; } ```