引言
简单总结一下我对进程同步和互斥的理解,用于操作系统的复习;
如果有理解偏差欢迎斧正;
部分资料来源:王道考研
基本概念
进程同步
由于操作系统中的多道程序环境下,进程是并发执行,并发执行的进程无法确定谁先执行,谁后执行,存在异步性(各个并发执行的进程以各自独立的、不可预知的速度向前推进);
而在一些情况下我们需要对这些异步并发的进程的顺序进行一个合理的安排,要求各个并发的进程可以相互配合,有序推进,这时就需要进程同步来解决;
进程同步是一种制约关系,目的是为了解决进程异步的问题;
比如:计算3/(1+2),如果想要计算这个公式,那么就系统需要一个除法进程和一个加法进程,但是只是有这两个进程还不够,因为
该运算式有顺序要求,一定需要先执行完成加法进程后才能执行除法进程,但是因为进程并发执行存在异步性,无法控制这两个进程
执行的先后顺序,所以我们就需要进程同步来制约除法进程和加法进程的执行顺序,让加法进程先执行然后才能执行除法进程
临界资源
在说进程互斥之前需要引入临界资源的概念;
临界资源:一次仅允许一个进程使用的资源成为临界资源;
这种资源很常见,比如:打印机、摄像头等;
也正是因为临界资源只能被一个进程使用,所以需要进程互斥来实现;
进程互斥
互斥是间接制约关系,目的就是为了解决临界资源访问的问题;
临界资源的访问,必须互斥的进行;在每个进程中,访问临界资源的那段代码称为:临界区
因此对临界资源的互斥访问,可以用以下伪代码表示:
do {
entry section; // 进入区(检查该临界资源是否可以访问,如果可以,则设置访问标志,即“上锁”,用来阻止其他进程访问)
critical section; // 临界区(访问临界资源)
exit section; // 退出区(接触访问标志,即“解锁”)
remainder section; // 剩余区(其余处理)
} while(true)
在这个过程中,进入区和退出区是实现互斥的代码段;
为了防止两个进程同时进入临界区,需要遵循以下准则
- 空闲让进:临界区空闲时,可以允许一个请求进入临界区的进程立即进入临界区;
- 忙则等待:当己有进程进入临界区时,其他试图进入临界区的进程必须等待;
- 有限等待:对请求访问的进程,应保证能在有限时间内进入临界区(保证不会饥饿);
- 让权等待:当进程不能进入临界区时,应立即释放处理机,防止进程忙等待。
信号量
1965年,荷兰学者Dijkstra提出了一种卓有成效的实现进程互斥和同步的方法——信号量机制;
所以引入信号量机制的目的主要就是为了解决进程同步和互斥的问题;
进程可以通过操作系统提供的 一对原语wait(S)和signal(S) 实现对信号量的操作,这里wait(S)和signal(S)可以理解为两个不同操作的函数,其中传入的参数S就是信号量;这里的wait(S)和signal(S)也可以称为“P操作”和“V操作”,即P(S)和V(S),这也是后面常提到的PV操作;
注意:PV操作是成对出现的;
信号量其实就是一个变量(可以是一个整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量;
比如:系统中只有一台打印机,就可以设置一个初值为1的信号量;
所以有时信号量不一定为1,一定要根据实际的资源数量进行分析;
整型信号量
整型信号量就是使用一个整数型变量作为信号量,来表示系统中的资源数量;
假设有一台打印机,那么该系统资源打印机数量为1,对应的打印机的信号量为1,那么使用PV操作表示为:
int S = 1; // 初始化整型信号量S,这里表示当前系统中可用的打印机资源数
// 检查并上锁
void wait(S) { // wait原语,相当于“进入区”
while(S <= 0); // 如果系统资源不够分配,则进入循环等待
S = S - 1; // 如果资源数够用,占用一个资源
}
// 解锁
void signal(S) { // signal原语,相当于“退出区”
S = S + 1; // 使用完资源后在退出区释放资源
}
这里存在一个问题:
只要信号量S<=0,就表示已经没有系统资源了,那么就会一直循环等待不断测试;当前进程会占着处理机不走,其他进程也无法进来;
所以该机制并没有遵循“让权等待”的准则,而是使进程处在“忙等”的状态;这时候就需要记录型信号量来解决了;
记录型信号量
记录型信号量是不存在“忙等”现象的进程同步机制;
在记录型信号量中,信号量不再只是用一个整数型变量表示了,而是使用以下结构:
- 整数型变量value:用来表示系统中某种资源数目;
- 进程链表L:用于连接所有等待该资源的进程,就是等待队列;
用代码表示为:
typedef struct {
int value; // 资源数目
struct process *L; // 等待队列
} semaphore;
对应的P操作为:
void wait(semaphore S) { // 某进程需要使用资源时,通过wait原语申请(申请资源)
S.value--; // 当前进程请求一个资源
if (S.value < 0 ) { // 如果当前资源数目小于0,说明已经没有资源了,则当前进程进入等待队列
block(S.L); // 当前进程阻塞,放弃处理机,并进入等待队列S.L(运行态->阻塞态)
}
}
这里当资源数目小于0时对应进程就会进入等待队列,释放处理机(不让你占着),这样就遵循了“让权等待”的准则;
对应的V操作为:
void signal(semaphore S) { // 进程使用完资源后,通过signal原语释放(释放资源)
S.value++; // 进程释放一个资源,资源数目加1
if (S.value <= 0) { // 如果当前资源数目依然小于0,说明当前等待队列中仍存在进程等待使用该资源,则唤醒该进程
wakeup(S.L); // 唤醒等待队列中第一个被阻塞的进程(阻塞态->就绪态)
}
}
再总结一下这里的PV操作:
-
对信号量S的一次P操作意味着进程请求一个单位的该类资源,因此需要执行S.value–,表示资源数减1,当S.value<0时表示该类资源己分配完毕,因此进程应调用block原语进行自我阻塞(当前运行的进程从运行态到阻塞态),主动放弃处理机,并插入该类资源的等待队列S.L中;可见,该机制遵循了“让权等待”原则,不会出现“忙等”现象;
-
对信号量S的一次V操作意味着进程释放一个单位的该类资源,因此需要执行S.value++,表示资源数加1,若加1后仍是S.value<=0,表示依然有进程在等待使用该类资源(因为之前到P操作进入阻塞队列的条件就是S.value小于0,才会有进程进入阻塞队列),因此应调用wakeup原语唤醒等待队列中的第一个进程(被唤醒进程从阻塞态到就绪态);
信号量实现同步和互斥
实现互斥
通过信号量实现互斥需要以下四点:
- 分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就应放在临界区);
- 设置互斥信号量S,初始值为1;
- 在临界区之前执行P(S)
- 在临界区之后执行V(S)
可以简单总结为:
临界区前执行P操作,临界区后执行V操作;
注意:这里互斥信号量设为1,是假设有一个临界资源,所以要根据不同的临界资源要设置不同的互斥信号量;
并且PV操作一定要成对出现,P操作保证资源的互斥访问,V操作保证阻塞队列中阻塞的资源可以被唤醒;
实现进程互斥的伪代码:
semaphore S = 1; // 初始化信号量(由临界资源数目进行判断多少信号量)
P1() {
...
P(S); // 准备访问临界资源,加锁
进程P1的临界区;
V(S); // 临界资源访问结束,解锁
...
}
P2() {
...
P(S); // 准备访问临界资源,加锁
进程P2的临界区;
V(S); // 临界资源访问结束,解锁
...
}
实现进程互斥还是很简单的,再次强调需要注意P操作和V操作一定是成对出现!!!
实现同步
通过信号量实现进程同步需要以下四点:
- 分析什么地方需要实现“同步关系”,即必须保证“一前一后”执行的两个操作,理清进程执行顺序;
- 设置同步信号量S,初始值为0;
- 在“前操作”之后执行V(S);
- 在“后操作”之前执行P(S);
最后两步就是实现同步的关键所在,可以这样说:
如果想要保证两操作“一前一后”执行,那么就需要在前操作后执行V操作,在后操作前执行P操作;
实现进程同步的伪代码:
这里前提要保证代码2在代码4前执行,代码2在P1进程中,代码4在P2进程中:
semaphore S = 0;
P1() {
代码1;
代码2; // 在代码4前执行
V(S); // 告诉P2进程,代码2已经执行结束
代码3;
}
P2() {
P(S); // 检查代码2是否已经执行完
代码4; // 如果执行完了代码2,那么可以执行代码4
代码5;
代码6;
}
简单说一下这个执行步骤:
-
如果先执行到P1进程的V(S)操作(此时代码2已经执行结束),则在V操作中S++后S=1;之后当执行到P2进程的P(S)操作时,由于S=1,表示有可用资源,P操作执行S–后S=0,不会执行block原语,而是继续往下执行代码4;这样就保证了代码2在代码4前执行;
-
若先执行到P2进程的P(S)操作,由于S=0,S–后S=-1,表示此时没有可用资源,因此P操作中会执行block原语,主动请求阻塞;之后当执行完代码2,继而执行P1进程的V(S)操作,S++,使S变回0,由于此时有进程在该信号量对应的阻塞队列中,因此会在V操作中执行wakeup原语,唤醒P2进程,这样p2就可以继续执行代码4了;这样也保证了代码2在代码4前执行;
所以还是记住那句话:
前操作后V,后操作前P;不管多少个进程只要实现同步,那么必然会有先后关系,就跟着这个口诀一步一步走就行了;
经典进程同步和互斥问题
在多道程序环境下,有很多典型的进程同步和互斥问题,下面就来介绍一下;
生产者-消费者问题
问题描述:
一组生产者进程和消费者进程共享一个初始为空、大小为n的缓冲区;
下面会有两种情况:
- 1,缓冲区没满时,生产者可以将生产的消息放入缓冲区,否则必须等待;
- 2,缓冲区不为空时,消费者才可以从缓冲区中取出数据,否则必须等待;
可以对问题进行分析:
- 缓冲区是临界资源,所以对缓冲区的访问必须互斥进行;
- 在第一种情况下,如果缓冲区已满,那么生产者此时不能再生产消息放入缓冲区,则生产者要等待消费者取走后才可以放入,这里的顺序必须按照:消费者消费–>生产者生产,所以这是一个同步过程;
- 第二种情况也一样,如果缓冲区为空,那么消费者此时不能再从缓冲区中取出消息,必须等待生产者生产消息后才可以取出,这里的顺序必须按照:生产者生产–>消费者消费,这也是一个同步过程;
所以生产者-消费者问题可以总结为:
生产者-消费者问题是同步互斥问题,缓冲区🈵️和🈳️时需要同步,从缓冲区取走/放入资源时是互斥;
接下来是代码实现:
首先考虑信号量有几个,在该问题中会有两种情况发生同步,一种互斥情况,所以需要设置三个信号量;
semaphore mutex = 1; // 互斥信号量,实现对缓冲区的互斥访问
semaphore empty = n; // 同步信号量,表示空闲缓冲区的数量
semaphore full = 0; // 同步信号量,表示产品的数量,也即非空缓冲区的数量
下面是生产者和消费者:
producer() {
while(true) {
生产一个产品;
P(empty); // 消耗一个空闲缓冲区
P(mutex); // 进入临界区
把产品放入缓冲区;
V(mutex); // 离开临界区,释放互斥信号量
V(full); // 增加一个产品
}
}
// 这里生产者先是对empty进行了P操作,后对full进行了V操作,其实就是
// 消耗了一个空闲缓冲区empty的同时增加了一个满缓冲区full的资源;即empty-1,full+1
// 反之就是消费者:
consumer() {
while(true) {
P(full); // 消耗一个产品
P(mutex); // 进入临界区
从缓冲区中取出一个产品;
V(mutex); // 离开临界区,释放互斥信号量
V(empty); // 空闲缓冲区数目加一
使用产品;
}
}
这里empty和full的PV操作位置就是在两种极端情况下,分别针对empty和full进行先操作后V,后操作前P的操作;
这两个同步情况可以用一张图表示:
注意生产者和消费者针对不同信号量的PV操作位置,依旧是那句口诀:先操作后V,后操作前P;
- 思考:能否改变互斥和同步的P操作的位置?
可以模拟尝试一下:
还是生产者和消费者的代码,但是交换了互斥和同步的P操作位置:
producer() {
while(true) {
生产一个产品;
P(mutex); // 1
P(empty); // 2
把产品放入缓冲区;
V(mutex);
V(full);
}
}
consumer() {
while(true) {
P(mutex); // 3
P(full); // 4
从缓冲区中取出一个产品;
V(mutex);
V(empty);
使用产品;
}
}
在这里把四个P操作分别编号1,2,3,4;
-
情境一:如果缓冲区没有产品,即full=0,empty=n
如果消费者进程先执行了3操作,mutex则变成0,然后执行4操作,但是此时的full为0,缓冲区没有产品,所以消费者进程被阻塞;随后生产者进程就会开始执行,生产者进程先执行1操作,此时的mutex已经为0,消费者进程还占用着缓存区的临界资源,还没有释放对应临界资源的锁,所以生产者进程也无法向下执行,被阻塞; -
情境二:如果缓冲区产品已满,即full=n,empty=0
如果生产者进程先执行了1操作,mutex则变成0,然后执行2操作,但是此时的empty为0,缓冲区已满,所以生产者进程被阻塞;随后消费者进程就会开始执行,消费者进程先执行3操作,此时的mutex已经为0,生产者进程还占用着缓存区的临界资源,还没有释放对应临界资源的锁,所以消费者进程也无法向下执行,被阻塞;
这两种情况都会造成一个相同的结果,这个结果就是死锁;
所以注意:互斥P操作一定要在同步P操作之后进行,否则会发生死锁现象;
虽然P操作不能交换位置,但是V操作却是可以交换位置的,因为此时就是一个资源释放的问题,所以谁先谁后释放都没有关系;
总结
进程同步和互斥都是针对不同的问题而提出的解决方法,所以要对实际应用情景有所了解,不能只了解概念;
也可以尝试通过C语言实现一下同步和互斥;
在同步和互斥应用方面,我只举了生产者-消费者这个经典问题的例子,同样还有很多:读者-写者问题、哲学家进餐问题、吸烟者问题等;但是分析思路都是一样的,只要确定了临界资源和进程之间的同步互斥关系,就很容易可以分析出来,感兴趣可以了解一下;