进程同步
进程具有异步性。异步性是指,各个并发执行的进程以各自独立的、不可预知的速度向前推进
读进程和写进程并发地执行,由于并发必然导致异步性,因此,“写进程”与“读进程”两个操作执行的先后顺序是不确定的。而实际应用中,又必须按照“写数据→读数据”的顺序来执行
如何解决这种一步问题,就是“进程同步”所讨论的内容
- 进程同步: 也称为直接制约关系,它是指为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调他们的工作次序而产生的制约关系
- 进程间的直接制约关系就源于它们之间的相互合作
进程互斥
资源的两种共享方式:
- 互斥共享:系统中的某些资源,虽然可以提供给多个进程使用,但是一个时间段内只允许一个进程访问该资源
- 同时共享:系统中的某些资源,允许一个时间段内由多个进程“同时”对他进行访问(交替访问)
- 把一个时间段内只允许一个进程使用的资源称为临界资源。许多物理设备(如摄像头、打印机)都属于临界资源。此外还有许多变量、数据、内存缓冲区等都属于 临界资源
- 对临界资源的访问,必须是互斥的。互斥,即间接制约关系。
- 进程互斥:指当一个进程访问某临界资源时,另一个想要访问该临界资源的进程必须等待。当前访问临界资源的进程访问结束,释放资源后,另一个进程才能区访问临界资源
- 对临界资源的互斥访问,可以在逻辑上分为四个部分
- 进入区:负责检查是否可以进入临界区,如可以进入,则应设置正在访问临界资源的标志(上锁),以阻止其他进程同时进入临界区(其他进程想要访问临界资源时,在其进入区检查会发现,此时有一个进程正在访问临界资源)
- 临界区:访问临界资源的那段代码
- 退出区:负责解除正在访问临界资源的标志(解锁)
- 剩余区:做其他处理
- 注意:
- 临界区是进程中访问临界资源的代码段
- 进入区和退出区是负责实现互斥的代码段
- 临界区也称为临界段
思考:
如果一个进程暂时不能进入临界区,那么该进程是否可以一直占着处理机?该进程是否可能永远进入不了临界区?
- 实现临界资源的互斥访问,需要遵循的原则:
- 空闲让进:临界区空闲时,可以让一个请求进入临界区的进程立即进入临界区
- 忙则等待:当已有进程进入了临界区时,其他试图进入临界区的进程必须等待
- 有限等待:对请求访问的进程,应保证能在有限的时间内进入临界区(保证不会饥饿)
- 让权等待: 当进程不能进入临界区时,应立即释放处理机,防止进程忙等待(该进程没办法执行下去,但还一直占用着处理机)
进程互斥的软件实现方法
-
单标志法:
- 在进入区只做检查不上锁
- 在退出区把临界资源的使用权交给另一个进程
- 主要问题:不遵循空闲让进的原则
-
双标志先检查法:
- 在进入区先检查后上锁,退出区解锁
- 主要问题:不遵循忙则等待的原则
-
双标志后检查法:
- 在进入区先上锁后检查,退出区解锁
- 主要问题:不遵循空闲让进、有限等待原则
-
Peterson算法:
- 在进入区主动争取——主动谦让——检查对方是否想进、己方是否时最后一次谦让
- 主要问题:不遵循让权等待原则,会出现忙等待
- 可以利用进程的异步性 检查这些算法的缺点,检查各种执行顺序,可能造成的问题
进程互斥的硬件实现方法
-
中断屏蔽方法:
- 原理:利用“开/关中断指令”实现,与原语的实现思想相同,即在某进程开始访问临界区到结束位置都不允许被中断,也就不可能发生进程调度,因此不可能发生两个进程同时访问临界资源
- 缺点:
- 不适合多处理机系统,因为在一个处理机上的关中断并不能防止进程在其他处理机上执行相同的临界区代码
- 只适用于操作系统的内核程序,不适合用户进程,因为开关中断指令只能允许在内核态,这组指令若让用户随意使用会很危险
-
TestAndSet(TS指令或称TSL指令)
- TSL指令是用硬件实现的,执行的过程中不允许被中断,只能一气呵成
- 优点:
- 在之前的双标志先检查法与双标志后检查法中,进入区的检查与上锁并不是一气呵成的,这就造成P1刚检查到资源未上锁可以访问,但还未来得及上锁,如此时发生了进程调度,执行P2,此时P2也发现资源还没上锁,它也认为可以访问临界资源,这就造成了两个进程同时访问临界资源的现象。(后检查法是先上锁,后检查,这样会出现两个资源都没办法访问临界资源的现象)。但用硬件实现检查与上锁的操作,可以避免软件实现的逻辑漏洞
- 适用于多处理机操作系统
- 缺点:不满足让权等待的原则,暂时无法进入临界区的进程会占用处理机,并循环执行TSL指令,从而导致忙等待
-
Swap指令(XCHG指令)(各项原理、特性同TSL指令)
⭐信号量机制
思考:之前方法存在的问题
- 在双标志检查法中,进入区的检查与上锁并不是一气呵成的,存在逻辑漏洞
- 之前的所有算法都没有遵循“让权等待”的原则,(在检查到不能访问临界资源时,会一直执行while语句循环等待)
- 用户进程可以使用操作系统的一对原语来对信号量进行操作,从而很方便的实现进程互斥、进程同步
- 信号量 其实就是一个变量(可以是整数,也可以是更复杂的记录型变量),可以用一个信号量来表示系统中某种资源的数量,比如:系统只有一台打印机,就可以设置一个初值为1的信号量
- 原语 是一种特殊的程序段,其执行只能一气呵成,不可被中断。原语是由关中断/开中断指令实现。之前的软件解决方案的问题是由于“进入区的各操作无法一气呵成”,因此,如果能把进入区、退出区的操作都用“原语”实现,这些操作都能一气呵成完成就能避免问题
- 一对原语:wait(s)原语与signal(s)原语,可以把原语理解为我们自己写的函数,函数名分别为wait和signal,括号里的信号量s就是函数调用时传入的参数
- wait、signal原语简称为P、V操作,故一对原语可写为P(s)、V(s)
- 信号量机制分类
-
整型信号量: 用一个整数型的变量作为信号量,用来表示系统中某种资源的数量
//整型信号量定义 int S = 1; //初始化整型信号量,表示当前系统中可以使用的打印机资源数 void wait(int S){ //wait原语,相当于进入区 while(S<=0); //检查,如果资源不够就一直循环等待(一直在while中循环)导致会发生忙等 S=S-1; //上锁,如果资源够,则占用一个资源 } void signal(int S){ //signal原语,相当于退出区 S=S+1; //解锁,使用完资源后,在退出区释放资源 }
//进程P0: ... wait(S); //进入区,申请资源 使用打印机资源... //临界区,访问资源 signal(S); //退出区,释放资源 ...
- 与普通整数型变量的区别:对信号量的操作只有三种,即初始化、P操作、V操作
- 优点:检查和上锁一气呵成,避免的并发、异步导致的问题
- 缺点:不满足让权等待原则,会发生忙等
-
⭐记录型信号量: 整型信号量的缺点是忙等问题,因此,又提出了用记录型数据结构表示的信号量
//记录型信号量的定义 typedef struct{ int value; //资源数 struct process *L; //指向阻塞队列的指针 } semaphore; void wait(semaphore S){ S.value--; //使用一个资源,将资源数减一 if (S.value<0){ //如果资源数减一后发现其小于0,说明当前资源不够用 block(S.L); //使用block原语使进程从运行状态进入阻塞态,并把它挂到信号量S的等待队列中 } } void signal(semaphore S){ S.value++; //释放一个资源,将资源数加一 if (S.value <= 0){ //若释放后,资源数小于等于0,则说明在释放之前,有进程正在等待刚被释放的资源 wakeup(S.L); //故使用wakeup原语从等待队列中唤醒一个进程,该进程从阻塞态变为了就绪态 } }
举例:
某计算机中有两台打印机,则在初始化信号量S时将 S.value的值设为2,队列S.L设置为null
- 首先P0申请打印机,执行wait原语后,资源数value变为1,再P1申请打印机,value变为0
- P2申请打印机,再P2的wait操作中,先将value值减一为 -1(表示:有一个进程在等待),接着判断value<0,故使用block原语将P3进程挂到阻塞队列上
- P3申请打印机,再P3的wait操作中,先将value值减一为 -2(表示:有两个进程在等待),接着判断value<0,故使用block原语将P3进程挂到阻塞队列上
- 当P0进程使用完打印机后,执行signal原语,资源数value值加一为 -1(表示有进程在等待打印机),接着判断到value<=0,会执行wakeup原语,从阻塞队列中唤醒排在队头的P2进程,并将打印机分配给P2
- P2直接使用打印机资源(不用再执行wait原语申请了),接着执行signal原语释放打印机,value加一为0,判断到value<=0(说明阻塞队列还有进程等待打印机),接着执行wakeup原语唤醒P3进程,P3从阻塞态变为就绪态,P2释放的打印机配分配给P3,然后P3直接使用打印机。。。
- 若此时P1进程使用完打印机,signal原语释放资源,将value加一为1,此时判断到value>0,说明此时没有进程再阻塞队列上等待打印机,也就不会执行wakeup原语
小结:
- wait(S)、signal(S)也可以记为P(S)、V(S),这对原语可用于实现系统资源的 “申请” 和 “释放”
- S.value的初值表示系统中某种资源的数目
- 对信号量S 的一次P操作意味着进程请求一个单位的该类资源,因此需要执行S.value - - ,表示资源数减一,当 value<0 时,表示该资源已经分配完毕,因此进程应调用block原语进行自我阻塞(当前运行的进程从运行态进入阻塞态),主动放弃处理机,并挂到该资源的等待队列S.L的队尾,这样实现了让权等待的原则,不会出现忙等的问题(不会像之前一样一直执行while循环,等待资源)
- 对信号量S 的一次V操作意味着进程释放一个单位的该类资源,因此需要执行S.value + +,表示资源数加一,当 value<=0 时,表示当前依然有资源再等待该资源,因此调用wakeup原语,从阻塞队列中唤醒队头进程(被唤醒的进程从阻塞态进入就绪态),并将打印机分配给该进程,此时该进程已经获得打印机,若轮到其使用处理机时,它可以不用再执行wait原语申请打印机,而是可以直接使用打印机工作
-
信号量机制实现进程互斥
- 分析并发进程的关键活动,划定临界区(如:对临界资源打印机的访问就应该放在临界区)
- 设置互斥信号量mutex,初值为1(这里将临界区看成了一种特殊的资源,而这种资源只有一个)
- 在 临界区之前 执行P(mutex)
- 在 临界区之后 执行V(mutex)
- 注意:
- 对不同的临界资源需要设置不同的互斥信号量(打印机:mutex1;摄像头:mutex2;…)
- P、V操作必须成对出现(缺少 P操作就不能保证临界资源的互斥访问;缺少V操作会导致资源永远不会被释放,等待进程永远不会被唤醒)
//信号量机制实现互斥
semaphore mutex =1; //初始化信号量
P1(){
...
P(mutex); //上锁
临界区代码段...
V(mutex); //解锁
...
}
P2(){
...
P(mutex); //若P1还为使用完,此时资源数为-1,所以block原语会将P2进程挂到阻塞队列上
临界区代码段...
V(mutex);
...
}
信号量机制实现进程同步
- 进程同步:让各并发进程按照要求有序进行
P1(){ 代码1; 代码2; 代码3; } P2(){ 代码4; 代码5; 代码6; }
比如P1、P2并发执行,由于存在异步性,因此二者交替推进的次序时不确定的
若代码4的执行,需要用到代码2的执行结果,这样就要求代码4必须在代码2之后执行,这就时同步关系,互相配合、有序推进 - 用信号量机制实现进程同步
- 分析什么地方需要实现同步关系,即必须**保证“一前一后”**执行的两个操作
- 设置同步信号量S,初始值为0
- 在 “前操作”执行后 执行V(S)
- 在 “后操作”执行前 执行P(S)
//信号量机制实现进程同步 semaphore S=0; P1(){ 代码1; 代码2; V(S); //前操作执行后V 代码3; } P2(){ P(S); //后操作执行前P 代码4; 代码5; 代码6; }
- 若P1先执行V(S)操作,则S++后S=1,之后P2执行P(S)时,由于S=1,表示有可用的资源,会执行S - -,S的值变为0,不满足<0,故不会执行block原语,而是继续往下执行代码4
- 若P2先执行P(S)操作,则S- -后S= -1,表示此时没有可用资源,因此会调用block原语,进程P2主动阻塞,当P1执行完代码2后,接着执行V(S),此时S++,则S=0,接着就会调用wakeup原语,将在阻塞队列中的P2唤醒,这样P2就能继续执行代码4了
- 这样就实现了代码4必须在代码2之后执行得同步关系
信号量机制实现前驱关系(同步拓展)
实际就是同步关系实现的拓展
- 例如:进程间按照图中的顺序来执行:
- 其实每一对前驱关系都是一个进程同步的问题(需要保证一前一后的操作)
- 要为每一个前驱关系各设置一个同步变量
- 在“前操作”之后对相应的同步变量执行V操作
- 在“后操作”之前对相应的同步变量执行P操作
//信号量机制实现前驱关系
semaphore a=0; //要为每一个前驱关系各设置一个同步变量
semaphore b=0;
semaphore c=0;
semaphore d=0;
semaphore g=0;
P1(){ P2(){ P3(){
... ... ...
S1; P(a); P(a);
V(a); S2; S3;
V(b); V(c); V(g);
... V(d); ...
} ... }
}