第三章:进程同步与互斥
3.1 基本概念
想象你和室友共用一个厨房。如果两个人同时用刀切菜,可能会撞到一起受伤——这就好比两个进程同时访问共享资源,需要互斥。如果你在煮面,室友等面煮好后才能用锅炒菜——这就是同步。
3.1.1 临界资源与临界区
临界资源:一次只允许一个进程使用的资源(如打印机、共享变量)。
临界区:访问临界资源的那段代码。每个进程在进入临界区之前需要检查是否可以进入。
do {
entry section; // 进入区(检查是否可以进入临界区)
critical section; // 临界区(访问临界资源)
exit section; // 退出区(释放资源)
remainder section; // 剩余区(其他代码)
} while (true);
临界区访问的四个原则:
- 空闲让进(无进程在临界区时,应允许一个进程进入)
- 忙则等待(有进程在临界区时,其他进程必须等待)
- 有限等待(等待进入临界区的进程不能无限等待)
- 让权等待(不能进入时,应释放CPU——不是必须的,但推荐)
3.1.2 同步与互斥
| 概念 | 含义 | 类比 |
|---|---|---|
| 互斥 | 同一时刻只能有一个进程访问临界资源 | 一个厕所一次只能进一个人 |
| 同步 | 进程间有先后执行顺序关系 | 生产了数据之后才能消费 |
3.2 互斥的实现方法
3.2.1 软件实现方法
单标志法(谦让法)
设置一个公用变量turn,表示允许哪个进程进入临界区。
- 问题:必须交替访问,违反"空闲让进"——若P0访问完后turn=1但P1不在临界区,P0也不能再进入(因为turn=1)。
双标志先检查法
设置flag[]数组表示进程是否想进入临界区。进程先检查对方flag再进入。
- 问题:检查后、进入前可能被调度——导致两个进程同时进入临界区(违反"忙则等待")。
双标志后检查法
先设置自己的flag为true,再检查对方flag。
- 问题:双方同时设置→同时检查→互相谦让→死锁(谁也进不去)。
Peterson算法
结合了单标志和双标志:设置flag[2]表示意愿,turn表示谁谦让。
// Pi进程(i=0或1,j=1-i)
flag[i] = true; // 我想进入
turn = j; // 但我把机会让给你
while (flag[j] && turn == j) // 如果你也想进且轮到你
; // 等待
// 临界区
flag[i] = false; // 退出
Peterson算法正确地实现了互斥,满足了空闲让进、忙则等待、有限等待三个条件。
3.2.2 硬件实现方法
中断屏蔽方法
进入临界区前关中断("关中断"指令),退出时开中断。
- 问题:关中断权力太大(限制CPU切换),不适合多核系统。
TestAndSet(TS指令/TSL指令)
硬件支持的原子操作:读取并设置锁定标志。
boolean TS(boolean *lock) {
boolean old = *lock;
*lock = true; // 加锁
return old; // 返回旧值
}
- 如果返回false(未锁定),进入临界区。
- 问题:不满足"让权等待"(忙等)。
Swap指令(XCHG)
交换两个变量的值(原子操作),原理类似TS。
3.3 信号量机制
信号量是1965年由Dijkstra提出的,是OS中最经典的同步互斥工具。
3.3.1 整型信号量
用一个整型变量表示资源数量,仅通过两个原子操作访问:
- wait(S) / P操作:while(S <= 0); S--;
- signal(S) / V操作:S++;
问题:不满足"让权等待"(P操作忙等)。
3.3.2 记录型信号量(重点)
typedef struct {
int value; // 资源数量
struct process *L; // 等待队列
} semaphore;
void wait(semaphore *S) {
S->value--;
if (S->value < 0) {
// 资源不足,阻塞自己
block(S->L); // 进入等待队列
}
}
void signal(semaphore *S) {
S->value++;
if (S->value <= 0) {
// 等待队列非空,唤醒一个
wakeup(S->L);
}
}
- value为正:表示剩余资源数量。
- value为负:表示等待队列中的进程数量。
- value为0:资源刚好用完,无等待进程。
💡 记忆技巧:P操作=Proberen(检查/减少),V操作=Verhogen(增加)。可以这样记:P是"拿"资源(所以减1),V是"放"资源(所以加1)。
3.3.3 用信号量实现同步与互斥
实现互斥:
semaphore mutex = 1; // 互斥信号量
// 进程Pi:
P(mutex); // 进入临界区前P
// 临界区
V(mutex); // 退出临界区后V
实现同步:比如P2必须在P1的S1语句之后执行。
semaphore sync = 0;
// P1:
S1;
V(sync); // 告诉P2:我完成了
// P2:
P(sync); // 等待P1完成
S2;
实现前驱关系:每对前驱后继设置一个同步信号量。如果有n个前驱,P操作要执行n次检查。
📌 408考点提示:P、V操作实现同步互斥是408大题的必考点。关键步骤:
- 分析同步关系(谁等谁)
- 分析互斥关系(哪些资源必须互斥访问)
- 设置信号量并赋初值
- 在合适位置插入P、V操作
3.4 经典同步问题
3.4.1 生产者-消费者问题(重点中的重点)
问题描述:多个生产者进程生产数据放入缓冲区,多个消费者进程从缓冲区取数据消费。缓冲区满时生产者等待,缓冲区空时消费者等待。
semaphore mutex = 1; // 互斥访问缓冲区
semaphore empty = n; // 空缓冲区数量
semaphore full = 0; // 满缓冲区数量
// 生产者
producer() {
while (true) {
produce_item(); // 生产数据
P(empty); // 申请一个空位
P(mutex); // 加锁
put_item(); // 放入缓冲区
V(mutex); // 解锁
V(full); // 增加一个满位
}
}
// 消费者
consumer() {
while (true) {
P(full); // 申请一个满位
P(mutex); // 加锁
get_item(); // 取出数据
V(mutex); // 解锁
V(empty); // 增加一个空位
consume_item(); // 消费数据
}
}
⚠️ 易错点:P操作的顺序不能错! 先P(empty/full)再P(mutex)——如果反过来,可能出现:缓冲区满时生产者先P(mutex)获得锁,然后P(empty)阻塞,但消费者无法进入临界区取数据,造成死锁。
扩展:多生产者-多消费者问题 如果有多个不同类型的产品(如苹果和橘子),需要设置两个同步信号量分别管理。
3.4.2 读者-写者问题
问题描述:多个读者可同时读共享数据,但写者必须独占访问(写时不能读,读时不能写)。
读者优先方案(读者不释放读锁时写者可能饿死):
semaphore rw = 1; // 用于写者互斥
semaphore mutex = 1; // 保护count
int count = 0; // 当前读者数量
// 写者
writer() {
P(rw); // 申请写
write(); // 写操作
V(rw); // 释放写
}
// 读者
reader() {
P(mutex);
if (count == 0)
P(rw); // 第一个读者锁住写者
count++;
V(mutex);
read(); // 读操作(多个读者可同时)
P(mutex);
count--;
if (count == 0)
V(rw); // 最后一个读者释放写者
V(mutex);
}
写者优先方案:增加一个信号量用于写者优先排队。
📌 408考点提示:读者-写者问题考查信号量设计,注意分析读者共享和写者互斥的细节。
3.4.3 哲学家进餐问题
问题描述:5个哲学家围坐圆桌,每人面前一碗面,每两人之间一支筷子。哲学家思考(不拿筷子)或进餐(需要拿左右两支筷子)。
semaphore chopstick[5] = {1,1,1,1,1};
// 哲学家i(可能死锁的版本)
philosopher(int i) {
while (true) {
P(chopstick[i]); // 拿左筷子
P(chopstick[(i+1)%5]); // 拿右筷子
eat();
V(chopstick[i]); // 放左筷子
V(chopstick[(i+1)%5]); // 放右筷子
think();
}
}
问题:如果所有哲学家同时拿左筷子,会死锁(每人等右筷子)。
解决方案:
- 最多允许4个哲学家同时进餐(设一个count信号量=4)
- 奇数号先拿左、偶数号先拿右(破坏循环等待条件)
- 仅当两支筷子都可用时才拿(用mutex保护拿筷子的过程)
3.4.4 吸烟者问题
问题描述:一个供应商进程和三个吸烟者进程。供应商随机提供两种原料,吸烟者需要三种原料才能卷烟吸(每种吸烟者缺少一种不同的原料)。
解决方案:四个信号量(供应信号量×3 + 互斥信号量),供应商通过信号量通知对应的吸烟者,吸烟者完成后通知供应商。
3.5 管程
3.5.1 管程的基本概念
信号量的P、V操作分布在各个进程中,容易出错。管程(Monitor) 把共享资源和操作封装在一起,所有进程只能通过管程提供的入口访问共享资源。
管程的特性:
- 管程内的共享变量只能被管程内的过程访问。
- 每次只允许一个进程进入管程(编译器保证互斥)。
- 提供条件变量(condition)和wait/signal操作来实现同步。
3.5.2 管程 vs 信号量
| 对比 | 管程 | 信号量 |
|---|---|---|
| 编程难度 | 低(封装性好) | 高(P/V容易错) |
| 编译器支持 | 需要 | 不需要 |
| 互斥保证 | 自动(进入管程互斥) | 手动(设置mutex) |
| 使用场景 | 高级并发编程语言 | 操作系统内核 |
示例题
示例1(生产者-消费者变体):有一个容量为n的缓冲区,有m个生产者和k个消费者。请用P、V操作实现同步与互斥。
解:前面给出的标准解法即可,注意多个生产者之间也需要互斥(buffer是共享资源)。上述代码中的mutex信号量保证了生产者之间的互斥,也保证了消费者之间的互斥,同时保证了生产者和消费者之间的互斥。
示例2(同步问题):设有三个进程P1、P2、P3,执行顺序要求:P1执行完后P2才能执行,P2执行完后P3才能执行。请用信号量实现。
解:
semaphore S12 = 0; // P1→P2的同步
semaphore S23 = 0; // P2→P3的同步
P1() { P2() { P3() {
work1; P(S12); P(S23);
V(S12); work2; work3;
} V(S23); }
}
示例3(复杂同步):有A、B两个进程共享一个缓冲区。A负责从输入设备读数据到缓冲区,B负责从缓冲区取数据进行计算。缓冲区只能存放一个数据。请用信号量实现。
解:这是单缓冲区的生产者-消费者问题。
semaphore empty = 1; // 缓冲区空
semaphore full = 0; // 缓冲区满
A() {
while (true) {
read_data();
P(empty);
buffer = data;
V(full);
}
}
B() {
while (true) {
P(full);
data = buffer;
V(empty);
compute(data);
}
}
本章小结
进程同步与互斥是并发编程的核心。信号量的P、V操作是408大题的必考内容,必须熟练掌握:生产者-消费者问题、读者-写者问题、哲学家进餐问题是三大经典模型。解题的关键是:先分析同步和互斥关系,再设置信号量,最后插入P、V操作。注意P操作的顺序问题——"申请资源在前,加锁在后"避免死锁。