操作系统:进程同步问题(一)
引入
在操作系统引入进程以后,可以让系统中的多道程序并发执行,显著提高了系统的吞吐量,但是进程具有异步性的特征,即在单CPU或多CPU系统中,多个进程以各自独立的、不可预知的速度向前推进,所以当它们需要共享资源或协作完成一项任务时,这种不确定的推进速度就会带来问题(如数据竞争、死锁)。
为了保证多个进程能够有条不紊的运行,在多道程序系统中,必须引入进程同步机制。
上面的说法可能对于初学者来说有点难懂,我们可以举个例子来说明:
异步性 就像城市道路上的所有车辆。
每辆车都有自己的目的地、速度和行驶路线(独立、不可预知)。
进程同步 就像道路上的交通信号灯和交通规则。
它们不会让所有车辆排成一列以固定速度前进(否则就不异步了)。
但是,它们在十字路口(共享资源)等关键点,规定了谁先走、谁后走,从而防止撞车(数据错误)和拥堵(死锁)。
进程同步机制的主要任务就是对多个相关进程在执行次序上协调进行,使并发执行的进程之间可以按照一定的规则或时序共享系统资源,并能更好的合作。
进程同步的基本概念
两种形式的制约关系
间接制约关系(互斥关系):
-
核心问题:多个进程竞争同一个独占性资源。
-
目标:保证排他性访问,即在一个时间段内,只允许一个访问者。
-
同步机制的作用:实现互斥。
-
例子:
-
多个进程打印文件,必须互斥使用打印机。
-
多个进程更新同一个共享变量,必须互斥执行
counter++
操作。
-
关键:竞争、独占、排他、互斥访问。
直接制约关系:
-
核心问题:多个进程协作完成一项任务。
-
目标:协调它们之间的执行顺序,确保某些操作在另一些操作之前或之后发生。
-
同步机制的作用:实现条件协调。
-
例子:
-
生产者-消费者:消费者必须在生产者之后消费(顺序)。
-
读者-写者:写者必须等所有读者读完才能写(条件)。
-
关键:协作、顺序、条件、等待/通知。
临界资源和临界区
1. 临界资源
是什么:
一个一次只允许一个进程使用的共享资源。
-
它是个“名词”,是一个实体或对象。
-
为什么“临界”(关键)? 因为如果多个进程同时使用它,会出乱子。
例子:
-
硬件资源:打印机(两个人同时发送打印任务,内容会混杂)、扫描仪。
-
软件资源:
-
一个共享变量(比如
counter
,两个人同时counter++
,结果会错)。 -
一个共享数据结构(比如一个链表,两个人同时插入节点,链表可能会损坏)。
-
一个数据库表中的某条记录。
-
核心: 它是被保护的对象。
2. 临界区
不管是硬件临界资源还是软件临界资源,多个进程都需要互斥的对它进行访问,人们把每个进程中访问临界资源的那段代码称为临界区。
核心: 它是需要被控制的执行流程。
显然,若能保证诸进程互斥地进入自己的临界区,便可实现诸进程对临界资源的互斥访问。为此,每个进程在进入临界区之前,应先对欲访问的临界资源进行检查,看它是否正被访问。如果此刻临界资源未被访问,进程便可进入临界区对该资源进行访问,并设置它正被访问的标志;如果此刻该临界资源正被某进程访问,则本进程不能进入临界区。因此,必须在临界区前面增加一段用于进行上述检查的代码,把这段代码称为进入区(entry section)。相应地,在临界区后面也要加上一段称为退出区(exit section)的代码,用于将临界区正被访问的标志恢复为未被访问的标志。进程中除上述进入区、临界区及退出区之外的其它部分的代码在这里都称为剩余区。这样,可把一个访问临界资源的循环进程描述如下:
while (TRUE)
{
进入区
临界区
退出区
剩余区
}
进程同步机制应遵循的规则
为实现进程互斥地进入自己的临界区,可用软件方法,更多的是在系统中设置专门的同步机构来协调各进程间的运行。所有同步机制都应遵循下述四条准则:
- 空闲让进。当无进程处于临界区时,表明临界资源处于空闲状态,应允许一个请求进入临界区的进程立即进入自己的临界区,以有效地利用临界资源。
- 忙则等待。当已有进程进入临界区时,表明临界资源正在被访问,因而其它试图进入临界区的进程必须等待,以保证对临界资源的互斥访问。
- 有限等待。对要求访问临界资源的进程,应保证在有限时间内能进入自己的临界区,以免陷入 “死等” 状态。
- 让权等待。当进程不能进入自己的临界区时,应立即释放处理机,以免进程陷入 “忙等” 状态。
信号量机制
信号量的概念
-
核心思想:信号量是一个特殊的变量,用于管理多个进程(或线程)对共享资源的访问。它就像一个交通信号灯,告诉进程是“停止”等待还是“前进”执行。
-
主要解决两种问题:
-
互斥:确保在同一时刻,只有一个进程可以访问临界资源(如打印机、共享变量等)。
-
同步:控制进程之间的执行顺序,确保某些操作在另一些操作之后发生(如生产者-消费者问题)。
-
整型信号量
定义
整型信号量是最简单的信号量形式,它被定义为一个整型变量,通常用 S
来表示。
-
物理意义:
-
S >= 0
:表示当前可用资源的数量。 -
S < 0
:其绝对值表示正在等待该资源的进程数量。 -
但请注意:在经典的、最基础的整型信号量实现中,我们只关心
S
是否大于0,而不严格区分其值的具体含义。等待机制是通过“忙等待”实现的。
-
对整型信号量的操作
对整型信号量的所有操作都必须是原子操作,即在整个操作执行期间,不会被中断,也不会被其他进程干扰。这是实现正确同步互斥的关键。
主要包含两个原子操作:
-
wait(S)
操作
这个操作也被称为 P操作(来自荷兰语 Proberen,意为“测试”)。当一个进程需要申请一个资源时,它会执行 wait
操作。
伪代码实现:
wait(S) {while (S <= 0) {; // 忙等待,什么也不做,循环检查}S--;
}
逻辑解释:
-
检查信号量
S
的值。 -
如果
S <= 0
,说明没有可用资源,进程会不停地循环检查(忙等待),直到S
变为大于0。 -
一旦
S > 0
,进程会跳出循环,并将S
的值减1,表示它成功占用了一个资源。
-
signal(S)
操作这个操作也被称为 V操作(来自荷兰语 Verhogen,意为“增加”)。当一个进程释放一个资源时,它会执行
signal
操作。
伪代码实现:
signal(S) {S++;
}
逻辑解释:
-
将信号量
S
的值加1,表示释放了一个资源。 -
这个操作可能会唤醒一个正在
wait
中忙等待的进程(因为S
的值变大了)。
值得注意的是,整型信号量不会对没有获得资源的进程进行阻塞。它会让该进程继续运行,在一个循环中“忙等待”,直到时间片被操作系统强制用完。
整型信号量的应用
实现进程互斥
目标:确保多个进程不能同时进入临界区。
方法:
初始化一个互斥信号量
mutex = 1
。每个进程在进入临界区之前执行
wait(mutex)
。在离开临界区之后执行
signal(mutex)
。
示例:
假设有两个进程 P1 和 P2 共享一个变量,我们应该如何实现他们之间对临界资源的互斥访问?
int mutex=1;//初始化互斥变量,表示只有一个进程能访问临界资源Process P1(){while(true){//进入区wait(mutex);//表示想要访问临界区的意愿:如果mutex=1,则减为0,进入临界区;如果mutex=0,则忙等待//临界区// ... 访问共享资源 ...//退出区signal(mutex);/// 将mutex加回1,释放资源//剩余区//……}Process P2(){while(true){//进入区wait(mutex);//表示想要访问临界区的意愿:如果mutex=1,则减为0,进入临界区;如果mutex=0,则忙等待//临界区// ... 访问共享资源 ...//退出区signal(mutex);/// 将mutex加回1,释放资源//剩余区//……}
执行过程分析:
-
初始状态:
mutex = 1
。 -
先调度P1,P1先执行
wait(mutex)
:mutex
从1减为0,P1进入临界区。 -
如果此时调度P2,P2执行
wait(mutex)
:发现mutex = 0
,于是P2在while
循环中忙等待。 -
再次调度P1,P1退出临界区,执行
signal(mutex)
:mutex
从0加回1。 -
P2立即检测到
mutex > 0
:跳出循环,将mutex
减为0,然后进入临界区。 -
如此循环,保证了任一时刻只有一个进程在临界区内。
实现进程同步
目标:控制进程的执行顺序,例如,要求 P2 的语句 B
必须在 P1 的语句 A
之后执行。
这个过程还是比较好想的,我们只需要明白,P2执行B所需要的资源一定要在P1执行完A以后才能获得。
方法:
初始化一个同步信号量
synch = 0
。在“先执行”的进程(P1)中,在关键操作(A)之后执行
signal(synch)
。在“后执行”的进程(P2)中,在关键操作(B)之前执行
wait(synch)
。
示例:
// 初始化同步信号量
int synch = 0;// 进程 P1
Process P1() {// ...A; // 执行语句Asignal(synch); // 通知P2,A已经完成// ...
}// 进程 P2
Process P2() {// ...wait(synch); // 等待P1完成A的信号,如果synch=0,则忙等待B; // 执行语句B// ...
}
执行过程分析:
-
初始状态:
synch = 0
。 -
如果 P2 先执行到
wait(synch)
,由于synch = 0
,它会进入忙等待。 -
当 P1 执行完
A
后,执行signal(synch)
,将synch
增加到1。 -
此时,正在忙等待的 P2 会立即检测到
synch > 0
,于是执行wait
操作(synch
减为0),并继续执行语句B
。 -
这样就保证了
B
一定在A
之后执行。
记录型信号量
为什么需要记录型信号量?
整型信号量的"忙等待"缺陷催生了记录型信号量。记录型信号量核心解决了以下问题:
CPU资源浪费:消除忙等待,让等待进程主动释放CPU。
公平性:通过等待队列,可以实现先进先出(FIFO)或其他调度策略。
通用性:能够处理任意数量的资源和复杂的同步场景。
记录型信号量的数据结构
记录型信号量不再是一个简单的整数,而是一个结构体,包含两个核心组成部分:
// 记录型信号量的数据结构定义
typedef struct {int value; // 可用资源数量struct process *list; // 等待队列(阻塞在该信号量上的进程队列)
} semaphore;
各字段含义:
value:整型值
value > 0
:表示有value个资源实例可用,因此又被称为资源信号量
value = 0
:表示资源刚好被分配完,无进程等待
value < 0
:其绝对值表示等待队列中的进程数量list:进程队列指针,指向一个等待(阻塞)进程的链表
核心操作:原子性的wait和signal
-
wait操作(P操作)
void wait(semaphore *S) {S->value--; // 表示申请一个资源if (S->value < 0) { // 如果没有资源可用// 将当前进程添加到S的等待队列中add_current_process_to(S->list);block(); // 阻塞当前进程}
}
逻辑解释:
-
减少资源计数:
S->value--
表示进程申请一个资源单位 -
检查资源可用性:
-
如果
S->value >= 0
:资源申请成功,进程继续执行 -
如果
S->value < 0
:资源不足,需要阻塞
-
-
阻塞进程:
-
将当前进程加入到信号量的等待队列
-
调用
block()
将进程状态从"运行"改为"阻塞" -
触发进程调度,切换到其他就绪进程
-
-
signal操作(V操作)
void signal(semaphore *S) {S->value++; // 释放一个资源if (S->value <= 0) { // 如果有进程在等待// 从等待队列中移除一个进程Process *p = remove_process_from(S->list);wakeup(p); // 唤醒该进程}
}
逻辑解释:
-
增加资源计数:
S->value++
表示释放一个资源单位 -
检查等待进程:
-
如果
S->value > 0
:没有进程在等待,直接返回 -
如果
S->value <= 0
:说明有进程在等待队列中
-
-
唤醒进程:
-
从等待队列中移除一个进程(通常是队首)
-
调用
wakeup()
将该进程状态从"阻塞"改为"就绪" -
该进程现在可以竞争CPU执行了
-
整型信号量的应用
互斥应用——保护临界区:
semaphore mutex = {1, NULL}; // 初始值1,表示互斥锁void process() {while (true) {// 进入区wait(&mutex); // 临界区代码//相关操作…………// 退出区signal(&mutex); // 剩余区代码}
}
执行分析:
-
初始:
mutex.value = 1
,mutex.list = NULL
-
P1执行
wait(&mutex)
:value=0
,继续执行 -
P2执行
wait(&mutex)
:value=-1
,P2阻塞加入队列 -
P1执行
signal(&mutex)
:value=0
,唤醒P2 -
P2被唤醒,从wait中返回,进入临界区
对比
特性 | 整型信号量 | 记录型信号量 |
---|---|---|
数据结构 | 整数 | 结构体{value, list} |
等待机制 | 忙等待循环 | 阻塞+等待队列 |
CPU使用 | 等待时100%占用CPU | 等待时0%占用CPU |
进程状态 | 保持运行态 | 运行态↔阻塞态 |
开销 | 浪费CPU周期,切换开销小 | 节省CPU,上下文切换开销大 |
适用场景 | 等待极短的多处理器 | 通用的同步互斥 |