进程同步与互斥

要彻底理解进程的同步与互斥,我们需要从 “为什么需要” 到 “是什么”,再到 “如何实现” 和 “经典问题” 逐步拆解。核心逻辑是:进程并发执行会引发资源竞争和执行顺序混乱,同步与互斥就是解决这两个问题的机制

一、先搞懂:为什么需要同步与互斥?—— 并发的 “副作用”

现代操作系统支持进程并发执行(比如同时开浏览器、微信、音乐),但并发会带来两个核心问题,这也是同步与互斥的起源:

1. 问题 1:临界资源的竞争(导致数据不一致)

  • 临界资源:多个进程不能同时访问的资源(比如打印机、共享内存中的变量、数据库表)。

例:打印机同一时间只能打印一个进程的任务;两个进程同时修改同一个共享变量(如count=10,进程 A 做count–,进程 B 也做count–,最终可能是 9 而不是 8)。

  • 临界区:进程中访问临界资源的那段代码(比如 “修改共享变量count的代码”“调用打印机的代码”)。

如果多个进程的临界区 “同时执行”,会导致数据不一致或 “资源滥用”(比如打印机乱码)。这就需要 “互斥” 来解决。

2. 问题 2:进程间的执行顺序混乱(导致逻辑错误)

有些进程之间存在依赖关系,需要按特定顺序执行,否则逻辑会崩溃。

例:生产者进程(生产商品放入缓冲区)和消费者进程(从缓冲区拿商品):

  • 消费者必须等生产者 “生产完” 才能拿(缓冲区空时不能消费);
  • 生产者必须等消费者 “拿完” 才能继续生产(缓冲区满时不能生产)。

如果不协调顺序,消费者可能拿空缓冲区,生产者可能把缓冲区撑爆。这就需要 “同步” 来解决。

二、核心概念:同步 vs 互斥(别搞混!)

同步和互斥都是为了 “协调进程执行”,但解决的问题不同,核心区别如下:

维度 互斥(Mutual Exclusion) 同步(Synchronization)
核心目标 解决 “临界资源竞争”,保证 “同一时间只有一个进程进入临界区” 解决 “执行顺序混乱”,保证 “进程按依赖关系有序执行”
本质 进程间的 “竞争关系”(抢资源) 进程间的 “协作关系”(按顺序配合)
例子 多个进程抢打印机、多个线程改同一个全局变量 生产者 - 消费者(先生产后消费)、父进程 - 子进程(先创建后执行)

三、实现互斥的 “黄金法则”—— 临界区调度原则

无论用什么方法实现互斥,都必须满足以下 4 个原则(否则会出现新问题):

  1. 空闲让进:如果临界区空闲(没有进程在访问),那么请求进入的进程必须立即进入(不能让资源闲置)。
  2. 忙则等待:如果临界区正在被某个进程使用,其他请求的进程必须等待(不能同时进入)。
  3. 有限等待:请求进入临界区的进程,必须在 “有限时间内” 得到允许(不能永远等下去,避免 “饥饿”)。
  4. 让权等待:等待的进程必须 “释放 CPU”(不能一直占用 CPU 循环检查 “临界区是否空闲”,避免 “忙等” 浪费资源)。

四、如何实现同步与互斥?—— 从低级到高级的方案

实现机制分 4 类:软件方法(纯代码逻辑)、硬件方法(依赖 CPU 指令)、信号量(操作系统提供的低级工具)、管程(更高层次的封装)。

1. 低级方案 1:软件方法(纯代码实现互斥)

核心思路:用共享变量标记 “临界区是否被占用”,通过代码逻辑保证只有一个进程进入临界区。

最经典的是Peterson 算法(仅支持 2 个进程互斥)。

(1)Peterson 算法的核心逻辑

假设有两个进程P0和P1,定义两个共享变量:

  • flag[2]:flag[i]=true表示进程i想进入临界区;
  • turn:标记 “当前该哪个进程进入”(turn=0表示该P0,turn=1表示该P1)。

(2)代码实现(以 P0 为例)

1
2
3
4
5
6
7
8
9
// P0想进入临界区的代码
flag[0] = true; // 告诉P1:我要进临界区了
turn = 1; // 谦让:这次让P1先进(如果它也想进)
// 等待条件:P1不想进,或者当前该我进
while (flag[1] && turn == 1);
// ------------------- 临界区 -------------------
访问共享资源(如修改count);
// ---------------------------------------------
flag[0] = false; // 告诉P1:我退出临界区了

(3)为什么能实现互斥?

  • 如果P0和P1同时想进:flag[0]和flag[1]都设为true,turn会被最后一个进程设为对方(比如P0先设turn=1,P1再设turn=0),最终只有turn对应的进程能进入(比如turn=0时P0进入,P1在while循环等待)。
  • 缺点:仅支持 2 个进程,且存在 “忙等”(等待的进程一直在循环检查,占用 CPU),不满足 “让权等待” 原则。

2. 低级方案 2:硬件方法(依赖 CPU 原子指令)

软件方法的问题是 “没有原子操作”(比如flag和turn的修改可能被打断),而 CPU 提供了原子指令(指令执行过程中不能被打断),可以直接实现互斥。常见的有两种:

(1)Test-and-Set(TSL)指令

  • 指令功能:先 “测试” 变量值,再 “设置” 变量值,整个过程原子执行。

逻辑如下(用函数模拟):

1
2
3
4
5
bool TSL(bool *lock) {
bool old = *lock; // 先保存lock的旧值(测试)
*lock = true; // 再把lock设为true(设置)
return old; // 返回旧值:旧值为false表示之前未锁定,现在锁定成功
}
  • 用 TSL 实现互斥
1
2
3
4
5
6
// 全局共享变量:lock=false(未锁定)
while (TSL(&lock)); // 循环测试:如果lock是true(已锁定),就等待;直到返回false(锁定成功)
// ------------------- 临界区 -------------------
访问共享资源;
// ---------------------------------------------
lock = false; // 释放锁:允许其他进程进入
  • 优点:简单,支持多个进程;缺点:存在 “忙等”(等待进程循环调用 TSL,占用 CPU)。

(2)Swap(交换)指令

  • 指令功能:交换两个变量的值,整个过程原子执行。

逻辑如下(用函数模拟):

1
2
3
4
5
void Swap(bool *a, bool *b) {
bool temp = *a;
*a = *b;
*b = temp; // 交换a和b的值,原子执行
}
  • 用 Swap 实现互斥
1
2
3
4
5
6
7
bool key = true;  // 进程本地变量:标记“我要锁定”
while (key); // 等待:直到key变为false(锁定成功)
Swap(&key, &lock); // 交换key和lock:如果lock是false,交换后key=false(锁定成功)
// ------------------- 临界区 -------------------
访问共享资源;
// ---------------------------------------------
lock = false; // 释放锁
  • 原理和 TSL 类似:通过原子交换确保只有一个进程能 “拿到”lock=false,缺点同样是 “忙等”。

3. 高级方案 1:信号量(Semaphore)—— 操作系统的 “万能工具”

信号量是操作系统提供的核心同步互斥机制,由荷兰学者 Dijkstra 提出。它通过 “信号量变量” 和 “P/V 原子操作” 实现,既能解决互斥,也能解决同步,还能避免忙等。

(1)信号量的定义

信号量是一个包含 “资源计数” 和 “阻塞队列” 的结构体,定义如下:

1
2
3
4
typedef struct {
int value; // 资源计数器:表示可用资源的数量
struct process *list; // 阻塞队列:保存因资源不足而等待的进程
} semaphore;

(2)核心操作:P 操作(申请资源)和 V 操作(释放资源)

P/V 操作是原子操作(操作系统保证执行过程不被打断),逻辑如下:

操作 核心逻辑(原子执行)
P(S) 1. S.value–(申请一个资源,计数器减 1);2. 如果S.value < 0:当前进程阻塞,加入S.list队列,释放 CPU(满足 “让权等待”)。
V(S) 1. S.value++(释放一个资源,计数器加 1);2. 如果S.value <= 0:从S.list队列唤醒一个阻塞进程,让它继续执行。
  • 关键理解:S.value的含义
    • 当S.value > 0:表示 “可用资源的数量”;
    • 当S.value < 0:表示 “阻塞队列中等待的进程数量”(绝对值);
    • 当S.value = 0:表示 “没有可用资源,也没有等待进程”。

(3)用信号量实现 “互斥”

核心思路:用一个初始值为 1 的信号量(称为互斥信号量) 保护临界区 —— 进入临界区前执行 P 操作(申请锁),退出后执行 V 操作(释放锁)。

例:两个进程P1和P2互斥访问打印机:

1
2
3
4
5
6
7
8
9
10
11
12
13
semaphore mutex = 1;  // 互斥信号量:初始1(1个打印机资源)
// P1的代码
P(mutex); // 申请打印机:mutex.value=0(无可用资源)
// ------------------- 临界区 -------------------
使用打印机打印;
// ---------------------------------------------
V(mutex); // 释放打印机:mutex.value=1(资源可用)
// P2的代码(和P1完全一样)
P(mutex); // 如果P1在打印,mutex.value=-1,P2阻塞加入队列
// ------------------- 临界区 -------------------
使用打印机打印;
// ---------------------------------------------
V(mutex); // 唤醒队列中的P2(如果P2在等)
  • 为什么互斥?:mutex初始为 1,第一个进程 P 操作后mutex=0,可以进入;第二个进程 P 操作后mutex=-1,阻塞等待;只有第一个进程 V 操作后,mutex=0,才会唤醒第二个进程。

(4)用信号量实现 “同步”

核心思路:用一个初始值为 0 的信号量(称为同步信号量) 标记 “依赖事件是否发生”—— 在 “需要等待事件” 的进程中执行 P 操作,在 “触发事件” 的进程中执行 V 操作。

例:生产者 - 消费者的同步(生产者先生产,消费者再消费):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
semaphore empty = 5;  // 同步信号量:空缓冲区数量(初始5,假设缓冲区大小为5)
semaphore full = 0; // 同步信号量:满缓冲区数量(初始0,刚开始没有商品)
semaphore mutex = 1; // 互斥信号量:保护缓冲区访问
// 生产者进程
while (1) {
生产一个商品;
P(empty); // 申请空缓冲区:如果满了(empty=0),阻塞等待(等消费者拿)
P(mutex); // 申请缓冲区的互斥访问
// ------------------- 临界区 -------------------
把商品放入缓冲区;
// ---------------------------------------------
V(mutex); // 释放缓冲区
V(full); // 通知消费者:有新商品了(full+1)
}
// 消费者进程
while (1) {
P(full); // 申请满缓冲区:如果空了(full=0),阻塞等待(等生产者生产)
P(mutex); // 申请缓冲区的互斥访问
// ------------------- 临界区 -------------------
从缓冲区拿商品;
// ---------------------------------------------
V(mutex); // 释放缓冲区
V(empty); // 通知生产者:有空位了(empty+1)
消费商品;
}
  • 同步逻辑:
    • 生产者必须等empty>0(有空位)才能生产,否则P(empty)阻塞;
    • 消费者必须等full>0(有商品)才能消费,否则P(full)阻塞;
    • mutex保证同一时间只有一个进程修改缓冲区(互斥)。

(5)信号量的缺点

  • 灵活但 “不安全”:P/V 操作的顺序必须严格控制,否则会导致死锁(例:生产者先P(mutex)再P(empty),如果缓冲区满了,生产者阻塞,且拿着mutex,消费者也拿不到mutex,双方永久等待)。
  • 编程复杂:需要程序员手动管理信号量和 P/V 顺序,容易出错。

4. 高级方案 2:管程(Monitor)—— 更安全的 “封装方案”

为了解决信号量的 “易用性问题”,Hoare 和 Brinch Hansen 提出了管程。管程的核心是 “封装”:把 “临界资源” 和 “操作临界资源的代码” 封装成一个模块,进程只能通过管程的 “接口函数” 访问临界资源,管程内部自动保证互斥。

(1)管程的 3 个核心特性

  1. 封装性:临界资源和操作代码被封装在管程内部,外部进程无法直接访问。
  2. 互斥性:管程同一时间只能允许一个进程进入(管程内部有 “隐含锁”,进程进入时自动加锁,退出时自动解锁)。
  3. 同步性:管程内部提供条件变量(Condition),用于解决进程间的同步(避免忙等)。

(2)条件变量(Condition)的作用

条件变量用于管程内部的 “进程等待”,提供两个操作:

  • wait(C):当前进程因 “条件 C 不满足”(如缓冲区满)而阻塞,释放管程的锁(让其他进程进入),并加入条件 C 的等待队列。
  • signal(C):当 “条件 C 满足”(如消费者拿了商品,缓冲区有空位)时,唤醒条件 C 等待队列中的一个进程(如果有的话)。

(3)用管程实现生产者 - 消费者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 定义管程:封装缓冲区、操作函数、条件变量
monitor ProducerConsumer {
int buffer[5]; // 临界资源:缓冲区(大小5)
int in = 0, out = 0; // 缓冲区的入队/出队指针
Condition notFull, notEmpty; // 条件变量:notFull(缓冲区不满)、notEmpty(缓冲区不空)

// 管程接口1:生产者放商品
void put(int item) {
// 如果缓冲区满,等待notFull条件
while ((in + 1) % 5 == out) {
wait(notFull); // 阻塞,释放管程锁
}
// 放商品(管程已保证互斥,无需手动加锁)
buffer[in] = item;
in = (in + 1) % 5;
// 通知消费者:缓冲区不空了
signal(notEmpty);
}

// 管程接口2:消费者拿商品
int get() {
// 如果缓冲区空,等待notEmpty条件
while (in == out) {
wait(notEmpty); // 阻塞,释放管程锁
}
// 拿商品
int item = buffer[out];
out = (out + 1) % 5;
// 通知生产者:缓冲区不满了
signal(notFull);
return item;
}
}
// 生产者进程:调用管程的put接口
void Producer() {
while (1) {
int item = 生产商品;
ProducerConsumer.put(item); // 进入管程放商品(自动互斥)
}
}
// 消费者进程:调用管程的get接口
void Consumer() {
while (1) {
int item = ProducerConsumer.get(); // 进入管程拿商品(自动互斥)
消费商品;
}
}

(4)管程的优点

  • 安全:互斥由管程自动保证(无需手动加锁),P/V 操作的逻辑被封装在条件变量中,避免程序员出错。
  • 易用:进程只需调用管程接口,无需关心内部同步逻辑。
  • 现代语言的实现:Java 的synchronized关键字、C# 的lock语句,本质都是管程的简化实现。

五、经典同步互斥问题(必懂!)

通过经典问题可以深化理解,以下 3 个问题是所有同步互斥场景的 “原型”:

1. 生产者 - 消费者问题(同步 + 互斥)

  • 需求:生产者生产商品放入缓冲区,消费者从缓冲区拿商品;缓冲区满时生产者不能生产,缓冲区空时消费者不能消费;同一时间只有一个进程修改缓冲区。
  • 核心解决:用 2 个同步信号量(empty/full)控制顺序,1 个互斥信号量(mutex)保护缓冲区;或用管程封装。
  • 常见坑:P 操作顺序(必须先 P 同步信号量,再 P 互斥信号量,否则死锁)。

2. 哲学家进餐问题(纯互斥,易死锁)

  • 需求:5 个哲学家围坐餐桌,每人左右各 1 根筷子;哲学家需要同时拿到左右两根筷子才能吃饭;吃完后释放筷子。
  • 原始问题:如果每个哲学家都先拿 “左边筷子”,再等 “右边筷子”,会出现死锁(所有哲学家都拿着左筷子,等右筷子,永久阻塞)。
  • 死锁解决方法(任选一种):
    1. 最多允许 4 个哲学家同时拿左筷子(保证至少 1 个哲学家能拿到右筷子);
    2. 奇数号哲学家先拿左筷子,偶数号先拿右筷子(错开拿筷子顺序);
    3. 哲学家拿不到右筷子时,立即释放左筷子(避免占用资源)。

3. 读者 - 写者问题(互斥 + 优先级)

  • 需求:多个 “读者” 可以同时读共享数据;“写者” 只能独占共享数据(不能和读者 / 其他写者同时访问);需考虑读者和写者的优先级。
  • 分类解决
    1. 读者优先:只要有读者在读书,后续读者可以直接进入;写者必须等所有读者读完才能写(写者可能 “饥饿”)。
    2. 写者优先:写者请求时,后续读者必须等待;写者写完后,唤醒其他写者或读者(避免写者饥饿)。
    3. 公平策略:读者和写者按请求顺序排队,谁先请求谁先执行(无饥饿)。
  • 核心工具:用readcount统计读者数,mutex保护readcount,rw信号量保护读写互斥。

六、常见问题:死锁与饥饿

同步互斥没做好,会导致两个严重问题:

1. 死锁(Deadlock)

  • 定义:多个进程互相等待对方持有的资源,永久阻塞(比如哲学家都拿左筷子等右筷子)。
  • 死锁 4 个必要条件(缺一不可):
    1. 资源互斥(资源只能被一个进程占用);
    2. 持有并等待(进程持有已占资源,同时等待其他资源);
    3. 不可剥夺(资源不能被强制夺走);
    4. 循环等待(进程间形成资源等待环)。
  • 解决死锁:破坏任意一个必要条件(如哲学家问题中破坏 “循环等待”)。

2. 饥饿(Starvation)

  • 定义:某个进程长期得不到所需资源,一直处于等待状态(比如读者优先的读者 - 写者问题中,写者永远等不到读者退出)。
  • 解决饥饿:采用 “公平策略”(如按请求顺序排队),保证每个进程在有限时间内得到资源。

七、总结

  • 核心本质:同步解决 “顺序问题”(协作),互斥解决 “竞争问题”(抢资源),两者共同保证进程并发的正确性。
  • 实现演进:软件方法(复杂,忙等)→ 硬件方法(依赖 CPU,忙等)→ 信号量(灵活,易出错)→ 管程(安全,易用)。
  • 实际应用:现代操作系统和编程语言中,信号量用于底层内核同步,管程(如synchronized)用于应用层并发编程(如 Java 多线程)。

理解同步互斥的关键是:先明确 “哪些是临界资源”“进程间有什么依赖关系”,再选择合适的机制封装逻辑,避免死锁和饥饿


进程同步与互斥
https://darven-cs.github.io/2025/10/06/进程同步与互斥/
作者
Darven
发布于
2025年10月6日
许可协议