进程同步
本文最后更新于:2025年10月16日 下午
进程同步是多道程序环境下协调进程并发执行的核心机制。引入进程后,虽能通过并发提升资源利用率和系统吞吐量,但进程对共享资源的无序争夺会导致执行结果不可再现。进程同步的本质是通过特定规则协调进程执行次序,确保共享资源有序访问与进程间高效协作,使程序执行具备可再现性。
一、进程同步的基本概念与制约关系
1. 进程同步的核心任务
对多个相关进程的执行次序进行协调,解决“并发导致的资源争夺与协作混乱”问题,具体包括:
- 确保进程按规则共享系统资源(如打印机、共享变量);
- 实现进程间的有效合作(如“司机-售票员”进程的协同操作);
- 消除并发执行的不可再现性,保证每次执行结果一致。
2. 进程间的两种制约关系
进程并发执行时,因资源共享或任务协作产生两种典型制约关系,均需同步机制协调:
(1)间接相互制约关系(资源共享导致)
- 产生原因:多个进程共享同一临界资源(如打印机、共享内存),需通过互斥方式访问,避免资源状态被无序修改。
- 示例:进程A和进程B均需使用打印机,若A正在打印(占用打印机),B必须等待A释放后才能使用。
- 本质:进程间无直接通信,仅因共享资源而间接相互限制。
(2)直接相互制约关系(任务协作导致)
- 产生原因:多个进程为完成同一任务需协同工作,一个进程的执行依赖另一个进程的结果(如“生产者-消费者”进程,生产者生产产品后消费者才能消费)。
- 示例:司机进程(P1)需在售票员进程(P2)关门后才能启动车辆,P2需在P1到站停车后才能开门,二者执行顺序直接关联。
- 本质:进程间存在明确的依赖关系,需通过信号或消息传递同步执行节奏。
二、临界资源与临界区
1. 临界资源(Critical Resource)
- 定义:一次仅允许一个进程访问的资源,包括硬件资源(如打印机、磁带机)和软件资源(如共享变量、数据库记录)。
- 核心特性:排他性——若多个进程同时访问,会导致资源状态混乱或执行结果错误。
- 管理原则:进程对临界资源的访问必须互斥,即一个进程访问时,其他进程需等待。
2. 临界区(Critical Section)
- 定义:每个进程中直接访问临界资源的那段代码,是进程同步的核心控制对象。
- 进程访问临界区的标准结构:为确保互斥,进程访问临界资源需遵循“进入区→临界区→退出区→剩余区”的流程:
1
2
3
4
5
6while(TRUE){
进入区(Entry Section):检查临界资源是否可用,若不可用则阻塞,避免“忙等”;
临界区(Critical Section):访问临界资源的核心代码,执行时独占资源;
退出区(Exit Section):释放临界资源,唤醒等待的进程;
剩余区(Remainder Section):与临界资源无关的其他代码;
} - 关键问题:若多个进程并发执行时,临界区代码未加控制,会导致共享资源操作异常(如共享变量计算错误)。
示例:临界区代码并发执行的问题
以“生产者-消费者”问题中共享变量counter(记录缓冲池产品数量)为例:
- 生产者对
counter的加1操作(counter++)对应机器指令:register1=counter; register1=register1+1; counter=register1; - 消费者对
counter的减1操作(counter--)对应机器指令:register2=counter; register2=register2-1; counter=register2; - 若二者并发执行,可能出现指令交错(如生产者读取
counter=5后,消费者也读取counter=5,最终counter可能被改为4而非5),导致counter值错误,进而引发缓冲池溢出或空取问题。
三、同步机制的四大规则
为确保临界区的安全访问,任何同步机制都需遵循以下4条核心规则,缺一不可:
| 规则名称 | 核心要求 | 目的 |
|---|---|---|
| 空闲让进 | 若临界区无进程执行,需访问临界区的进程应立即进入 | 避免资源空闲,提高资源利用率 |
| 忙则等待 | 若临界区已有进程执行,其他请求访问的进程必须等待 | 保证临界资源的互斥访问,防止冲突 |
| 有限等待 | 请求访问临界区的进程需在有限时间内进入,不得“永久等待” | 避免“死等”(饥饿),确保进程公平性 |
| 让权等待 | 进程无法进入临界区时,需立即释放CPU(转为阻塞状态),而非“忙等” | 避免CPU资源浪费,提高系统吞吐量 |
四、进程同步的实现机制
根据实现层面的不同,同步机制分为硬件同步机制和软件同步机制(信号量、管程),其中硬件机制是基础,软件机制是主流应用方式。
1. 硬件同步机制
利用硬件指令的原子性(不可中断)实现互斥,无需操作系统内核干预,但存在局限性,适用于简单场景。
(1)关中断(Disable Interrupts)
- 实现逻辑:进程进入临界区前关闭CPU中断,执行完临界区代码后再打开中断。关闭中断期间,CPU不响应任何中断请求,不会发生进程切换,确保临界区代码连续执行。
- 优点:实现最简单,无需复杂数据结构。
- 缺点:
- 滥用关中断可能导致系统异常(如忽略硬件故障中断);
- 关中断时间过长会降低CPU利用率,限制并发能力;
- 不适用于多CPU系统(一个CPU关中断无法阻止其他CPU访问临界区)。
(2)Test-and-Set(TS)指令
- 指令功能:原子性完成“测试锁状态”和“设置锁为占用”操作,返回锁的原始状态。指令定义如下:
1
2
3
4
5boolean TS(boolean *lock){
boolean old = *lock; // 保存锁的原始状态
*lock = true; // 将锁设为“占用”
return old; // 返回原始状态(true表示锁已占用,false表示空闲)
} - 实现互斥的逻辑:
1
2
3
4
5
6do{
while(TS(&lock)); // 循环测试锁,直到锁空闲(TS返回false)
临界区; // 锁空闲,进入临界区
lock = false; // 退出临界区,释放锁
剩余区;
}while(TRUE); - 优点:支持多CPU系统,实现简单。
- 缺点:未遵循“让权等待”规则,进程等待时处于“忙等”状态,浪费CPU资源。
(3)Swap指令(Exchange指令)
- 指令功能:原子性交换两个变量的值,确保交换过程不被中断。指令定义如下:
1
2
3
4
5void Swap(boolean *a, boolean *b){
boolean temp = *a;
*a = *b;
*b = temp;
} - 实现互斥的逻辑:
1
2
3
4
5
6
7
8
9do{
boolean key = true;
do{
Swap(&lock, &key); // 交换lock和key的值,测试锁状态
}while(key != false); // key为false表示锁空闲,退出循环
临界区; // 进入临界区
lock = false; // 释放锁
剩余区;
}while(TRUE); - 优缺点:与TS指令类似,支持多CPU但存在“忙等”问题。
2. 信号量机制(软件同步机制)
1965年由荷兰学者Dijkstra提出,是目前应用最广泛的同步机制之一。信号量是一个抽象数据结构,通过P(wait)、V(signal)两个原子操作控制资源访问,可实现互斥与同步。
(1)信号量的核心属性
- 本质:一个记录型数据结构,包含“资源数量”和“等待进程队列”(仅记录型信号量有队列);
- 值的含义:信号量的值与资源使用状态相关,仅能通过
P、V操作修改; - 初始化:初值需在创建时设置一次,且初值非负(资源数量或同步标记)。
(2)信号量的分类与实现
根据功能和结构,信号量分为整型信号量、记录型信号量、AND型信号量和信号量集,分别适用于不同场景:
① 整型信号量
- 定义:信号量为整型变量
S,仅通过P、V原子操作访问:1
2
3
4
5
6
7
8
9// P操作(申请资源):若资源不足则“忙等”
wait(S){
while(S <= 0); // 资源不足时循环等待(忙等)
S--; // 申请成功,资源数量减1
}
// V操作(释放资源):资源数量加1
signal(S){
S++; // 释放资源,资源数量加1
} - 优点:实现简单,适用于单CPU系统。
- 缺点:未遵循“让权等待”规则,进程等待时“忙等”,浪费CPU资源。
② 记录型信号量(解决“忙等”问题)
- 定义:在整型信号量基础上增加“等待进程队列”,当资源不足时,进程进入队列阻塞(释放CPU),遵循“让权等待”规则。结构定义如下:
1
2
3
4typedef struct{
int value; // 资源数量(value>=0:可用资源数;value<0:等待进程数的绝对值)
struct PCB *list; // 等待该信号量的进程队列(PCB链表)
}semaphore; - P、V操作实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14// P操作(申请资源)
wait(semaphore *S){
S->value--; // 申请资源,资源数减1
if(S->value < 0){ // 资源不足,进程阻塞
block(S->list); // 将当前进程插入等待队列,释放CPU
}
}
// V操作(释放资源)
signal(semaphore *S){
S->value++; // 释放资源,资源数加1
if(S->value <= 0){ // 存在等待进程
wakeup(S->list); // 唤醒等待队列的队首进程,插入就绪队列
}
} - 核心优势:无“忙等”,兼顾资源利用率与公平性,是目前主流的信号量形式。
- 典型应用:
- 互斥信号量:初值为1,用于实现临界资源互斥访问(如
mutex=1,P(mutex)进入临界区,V(mutex)退出); - 资源信号量:初值为资源总数(如缓冲池空缓冲区数
empty=n),用于同步资源访问; - 同步信号量:初值为0,用于实现进程间前趋关系(如进程A执行完
S1后,通过V(S)唤醒进程B执行S2)。
- 互斥信号量:初值为1,用于实现临界资源互斥访问(如
③ AND型信号量(解决多资源申请死锁)
- 背景:当进程需同时申请多个临界资源(如“哲学家进餐”问题中需同时拿两根筷子)时,若按顺序申请可能导致死锁(如每个哲学家都拿左筷子,再等右筷子)。
- 核心思想:采用“原子操作”一次性申请所有所需资源,要么全部分配,要么一个不分配,避免部分资源占用导致的死锁。
- 操作定义:
Swait(S1, S2, ..., Sn):同时申请S1~Sn资源,所有资源可用时才分配;Ssignal(S1, S2, ..., Sn):同时释放S1~Sn资源,唤醒等待进程。
- 示例:哲学家申请筷子时,通过
Swait(chopstick[i], chopstick[(i+1)%5])同时申请左右筷子,避免死锁。
④ 信号量集(批量资源申请)
- 背景:记录型信号量每次仅能申请1个资源,若需批量申请(如一次申请3个缓冲区),需多次执行
P操作,效率低且可能引发死锁。 - 核心思想:支持一次申请多个资源,并可设置“资源分配下限”(不足下限不分配),操作定义如下:
1
2
3
4
5Swait(S1, t1, d1, ..., Sn, tn, dn):
- 对每个信号量Si,若Si >= ti(ti为分配下限),则分配di个资源(Si = Si - di);
- 否则,进程阻塞。
Ssignal(S1, d1, ..., Sn, dn):
- 对每个信号量Si,释放di个资源(Si = Si + di),唤醒等待进程。 - 特殊场景:
Swait(S, d, d):一次申请d个资源,不足d个则等待;Swait(S, 1, 1):等同于记录型信号量(互斥或单个资源申请);Swait(S, 1, 0):仅测试资源状态(S>=1时允许进入,S=0时禁止),可作为“可控开关”。
(3)信号量的典型应用
信号量可灵活实现进程互斥、进程同步(前趋关系) 和复杂协作场景,以下为常见应用案例:
① 实现进程互斥
- 逻辑:为临界资源设置互斥信号量
mutex(初值1),进程进入临界区前执行P(mutex)(申请资源),退出时执行V(mutex)(释放资源)。 - 示例:两个进程PA、PB互斥访问打印机:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17semaphore mutex = 1; // 互斥信号量,初值1
void PA(){
while(1){
P(mutex); // 申请打印机
临界区(使用打印机);
V(mutex); // 释放打印机
剩余区;
}
}
void PB(){
while(1){
P(mutex); // 申请打印机
临界区(使用打印机);
V(mutex); // 释放打印机
剩余区;
}
} - 注意:
P、V操作必须成对出现,缺P会导致互斥失效,缺V会导致资源永久占用。
② 实现进程前趋关系
- 逻辑:为每个前趋关系设置同步信号量(初值0),前趋操作执行完后
V操作(唤醒后续进程),后续操作执行前P操作(等待前趋完成)。 - 示例:进程P1的S1执行后,P2的S2和P3的S3才能执行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14semaphore a = 0, b = 0; // 同步信号量,初值0
void P1(){
S1;
V(a); // S1完成,唤醒P2
V(b); // S1完成,唤醒P3
}
void P2(){
P(a); // 等待S1完成
S2;
}
void P3(){
P(b); // 等待S1完成
S3;
}
③ 案例1:过独木桥问题
- 需求:同一方向行人可连续过桥,反向行人需等待当前方向无人后才能过桥。
- 实现:设置互斥信号量
mutex(控制桥的独占)、方向计数信号量leftm/rightm(保护方向计数器),通过计数器判断是否需申请桥资源:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19semaphore mutex = 1, leftm = 1, rightm = 1;
int countleft = 0, countright = 0; // 左右方向过桥人数
// 左→右过桥
void leftToRight(){
P(leftm); // 保护countleft
if(countleft == 0) // 第一个左→右行人,申请桥
P(mutex);
countleft++; // 左→右人数加1
V(leftm); // 释放countleft
过桥; // 临界区
P(leftm); // 保护countleft
countleft--; // 左→右人数减1
if(countleft == 0) // 最后一个左→右行人,释放桥
V(mutex);
V(leftm); // 释放countleft
}
// 右→左过桥逻辑类似,操作countright和rightm
④ 案例2:父子四人水果问题
- 需求:父亲放苹果、母亲放桔子,儿子吃桔子、女儿吃苹果,盘子仅能放1个水果。
- 实现:设置信号量
empty(盘子空,初值1)、apple(苹果数量,初值0)、orange(桔子数量,初值0)、mutex(保护盘子操作):1
2
3
4
5
6
7
8
9
10
11
12semaphore mutex = 1, apple = 0, orange = 0, empty = 1;
// 父亲放苹果
void father(){
P(empty); // 申请空盘子
P(mutex); // 保护盘子操作
放苹果;
V(mutex); // 释放盘子
V(apple); // 通知女儿有苹果
}
// 母亲放桔子(类似父亲,操作empty和orange)
// 女儿吃苹果(P(apple)申请苹果,V(empty)释放盘子)
// 儿子吃桔子(类似女儿,操作orange和empty)
3. 管程机制(封装式同步机制)
信号量机制需在每个进程中手动编写P、V操作,同步逻辑分散且易出错(如P、V顺序颠倒)。管程机制通过“封装共享资源和操作”,实现同步逻辑的集中管理,确保每次仅一个进程进入管程,简化同步实现。
(1)管程的定义与结构
- 定义:管程是一个抽象数据类型,包含“共享数据结构”、“操作共享数据的过程”、“条件变量”和“初始化代码”,进程需通过管程提供的过程访问共享资源。
- 核心特性:互斥性——管程确保每次仅一个进程执行其内部过程,无需进程手动实现互斥;同步性——通过条件变量实现进程间的协作等待与唤醒。
- 结构组成:
1
2
3
4
5
6
7
8
9Monitor 管程名{
共享变量声明; // 管程管理的共享资源(如缓冲池、计数器)
条件变量声明; // 用于进程同步的条件变量(如notfull、notempty)
public:
过程1(参数){ ... } // 访问共享资源的过程(如put、get)
过程2(参数){ ... } // 访问共享资源的过程
...
初始化代码; // 初始化共享变量和条件变量
}
(2)条件变量(管程中的同步工具)
管程通过条件变量实现进程间的同步等待与唤醒,条件变量仅能在管程内部使用,提供wait和signal两个操作:
- 条件变量的作用:当进程通过管程申请资源失败时,通过
wait进入条件队列阻塞(释放管程);当资源可用时,其他进程通过signal唤醒阻塞进程。 - 核心操作:
x.wait():当前进程因x条件阻塞,插入x的等待队列,释放管程,允许其他进程进入;x.signal():当前进程触发x条件,唤醒x等待队列的一个进程(若队列非空);若唤醒后存在进程竞争,需按规则处理(如唤醒进程优先执行或当前进程继续执行)。
(3)管程的优势
- 封装性:同步逻辑集中在管程内部,进程无需关注细节,降低编程复杂度;
- 安全性:管程自动实现互斥,避免
P、V操作遗漏或顺序错误导致的同步问题; - 可维护性:同步逻辑修改仅需调整管程内部,无需修改所有相关进程。
五、核心总结
进程同步的本质是解决“并发导致的资源争夺与协作混乱”,其核心逻辑可归纳为:
- 识别临界资源与临界区:明确需互斥访问的资源及对应代码段,是同步设计的基础;
- 选择合适的同步机制:硬件机制适用于简单场景,信号量机制灵活通用(互斥、同步、多资源),管程机制适用于复杂共享资源管理;
- 遵循同步规则:确保同步机制满足“空闲让进、忙则等待、有限等待、让权等待”,兼顾效率与公平性;
- 典型应用场景:通过信号量或管程可解决“互斥访问”“前趋关系”“多资源协作”等问题,需结合具体需求设计同步逻辑(如“生产者-消费者”“哲学家进餐”等经典问题)。