3.同步与互斥
3.1.进程同步、进程互斥的概念
1.同步(直接制约):两个或者多个进程需要按照某种顺序执行
2.互斥(间接制约):A进程访问某种临界资源时,若进程B也想访问该临界资源,进程B则必须等待进程A访问完成后才能访问该临界资源(一段时间内只能有一个进程访问)
①进入区:检查是否可以进入临界区,若能进入临界区,则设置正在访问临界资源的标志(即上锁),防止其他进程进入该临界区
②临界区:访问临界资源的代码(例如需要打印机执行打印操作,则对打印机进行写输出的代码,就在临界区中)
③退出区:解除正在访问临界资源的标志(即解锁)
④剩余区:做其他处理
进入区和退出区是实现互斥的代码段;临界区是访问临界资源的代码段
do { entry section; //进入区 critical section; //临界区 exit section; //退出区 remainder section; //剩余区 } while(true)
3.互斥应该遵循两个原则:
①空闲让进:当临界区空闲时,有进程请求进入则立即允许该进程访问
②忙则等待:当临界区已有进程正在访问时,其余想要访问该临界区的进程则必须等待
③有限等待:等待访问临界区的进程必须在有限时间内访问该临界区(防止饥饿)
④让权等待:当进程不能访问临界区时,必须让出处理机
3.2.进程互斥的软件实现方法
3.2.1.单标志法
1.算法思想:两个进程在访问完临界区后会把该临界区的使用权转交给另一个进程(临界区的使用权只能由另一个进程赋予)
int turn = 0; //turn = 0表示允许P0进程进入;turn = 1表示允许P1进程进入 //P0进程 while (turn != 0) ; //进入区,判断是否当前允许turn是否为0 critical section; //临界区,访问临界资源 turn = 1; //退出区,将临界区的访问权让给P1 remainder section; //剩余区 //P1进程 while (turn != 1) ; //进入区,判断是否当前允许turn是否为1 critical section; //临界区 turn = 1; //退出区 remainder section; //剩余区
2.执行过程:设初始状态下turn = 0,即刚开始允许访问临界区的是P0进程
①若首先让P1上处理机运行,则P1进程的代码将会卡在while循环处(turn != 1为真,死循环)直到P1的时间片用完
②切换至P0后,P0进程的while循环不满足条件(turn != 0为假),执行临界区代码,开始访问临界资源;若直到P0的时间片用完,P0还未退出临界区,切换回P1进程,P1依然会卡在while循环处直到P1时间片用完(因为turn仍为0,即临界区的访问权还是P0的)
③当P0访问完临界区后,在退出区的代码中将会修改turn = 1(即将临界区的访问权让给P1)
3.缺点:违反空闲让进原则(临界区的使用权只能由另一个进程赋予,即P0若想访问临界区,必须在P1访问完临界区,将turn改为0)
3.2.2.双标志先检查法(先检查后上锁)
1.算法思想:设置一个bool数组,用于标记各个进程是否想要访问临界区;在各个进程访问临界区前,依次检查该bool数组,看是否有其他进程想要访问临界区,若没有,则将自己的标志改为true,并开始访问临界区
bool flag[2]; //标记各个进程是否想要访问临界区 flag[0] = false; flag[1] = false; //初始化P0和P1为不想要访问临界区 //P0进程: while(flag[1]); //进入区,判断P1是否想要进入临界区 flag[0] = true; //将自身flag改为true,表示自己想要进入临界区 critical section; //临界区 flag[0] = false; //退出区,将自身flag改为false remainder section; //P1进程: while(flag[0]); //进入区,判断P0是否想要进入临界区 flag[1] = true; //将自身flag改为true,表示自己想要进入临界区 critical section; //临界区 flag[1] = false; //退出区,将自身flag改为false remainder section;
进入区做了两件事:①需要检查对方是否想要进入临界区 ②如果对方并不想进入临界区,则表达自己想要进入临界区的意愿(即上锁,并在在退出临界区后解锁)
2.缺点:违反忙则等待的原则(原因是检查和上锁是分成两步做的,即不是一气呵成的,而进程是并发执行的,可能发生进程的切换)
设初始状态下两个flag都为false,并且从P0进程开始运行:
①P0的while不满足条件(flag[1] = false),准备执行flag[0] = true
②由于进程是并发执行的,若此时切换到P1程序,P1的while循环也不满足条件(flag[0] = false,P0并未来得及修改自身的标志位)
③切换回P0进程执行flag[0] = true,并且进入临界区
④切换回P1进程执行flag[1] = true,并且进入临界区
此时P0和P1都已进入临界区,即违反忙则等待原则
3.2.3.双标志后检查法(先上锁后检查)
1.算法思想:与双标志先检查法相比,相同点:进入区仍是分两步,即上锁和检查;不同点:双标志先检查法是①检查 ②上锁,而双标志后检查法是①上锁 ②检查
bool flag[2]; //标记各个进程是否想要访问临界区 flag[0] = false; flag[1] = false; //初始化P0和P1为不想要访问临界区 //P0进程: flag[0] = true; //将自身flag改为true,表示自己想要进入临界区 while(flag[1]); //进入区,判断P1是否想要进入临界区 critical section; //临界区 flag[0] = false; //退出区,将自身flag改为false remainder section; //P1进程: flag[1] = true; //将自身flag改为true,表示自己想要进入临界区 while(flag[0]); //进入区,判断P0是否想要进入临界区 critical section; //临界区 flag[1] = false; //退出区,将自身flag改为false remainder section;
2.缺点:违背空闲让进和有限等待(将会导致饥饿)原则
设初始状态下两个flag都为false,并且从P0进程开始运行:
①P0将自己的flag改为true,表示自己想要访问临界区,此时发生进程切换,开始执行P1进程
②P1将自己的flag改为true,表示自己想要访问临界区
③此时P0和P1的flag都为true,即P0和P1都想要访问临界区,而现实情况是两个进程都还没能访问临界区(也意味着无法将自己的flag改为false)
④P0和P1继续执行就会分别卡在自己的循环语句中(对方的flag无法更改,死循环)进而导致饥饿
3.2.4.Peterson算法
1.算法思想:如果双方都想访问临界区,则让对方访问临界区
bool flag[2]; //标记进程是否想要访问临界区,初始值为false int turn = 0; //turn表示优先让哪个进程进入临界区 //P0进程: flag[0] = true; //表示自己想要进入临界区 turn = 1; //表示优先让对方进入临界区 while (flag[1] && turn == 1); //如果对象想要进入,并且是自己让对方进入的,则等待 critical section; flag[0] = false; //退出临界区后,表示自己不想进入临界区 remainder section; //P1进程: flag[1] = true; turn = 0; while (flag[0] && turn == 0); critical section; flag[1] = false; remainder section;
2.缺点:并未遵循让权等待原则(若想要进入临界区的进程无法进入临界区,则会卡在while循环,即不断地查询是否能进入临界区)
3.3.进程互斥的硬件实现方法
3.3.1.中断屏蔽方法
1.实现方法:在访问临界区前关中断,在访问临界区后开中断(不允许中断等于不发生进程切换)
2.缺点:
①不适用于多处理机:关中断指令对一个处理机有效,即若A处理机执行关中断指令使进程a访问临界区C后,B处理机上的进程b此时仍可以访问该临界区C
②只适用于操作系统内核进程,不适用于用户进程:开/关中断指令属于特权指令,需要在内核态下才能运行
3.3.2.TestAndSet指令(TS,也称TSL,即TestAndSetLock)
1.实现方法:①通过硬件实现 ②将上锁和检查“一气呵成的执行”(双标志先后检查的升级版)
//bool类型的共享变量lock表示当前临界区是否被加锁 //true表示已被加锁,false表示未被加锁 bool TestAndSet (bool *lock) { bool old; old = *lock; //用old存放原来lock的值(上锁还是未上锁) *lock = true; //无论之前上锁与否,都将上锁 return old; //返回原来lock的值 } while (TestAndSet (&lock)); critical section; //临界区 lock = false; //退出区,解锁 remainder section; //剩余区
通过old变量记录之前是否上锁,无论上锁与否都将进行上锁操作:若之前上锁,TestAndSet的上锁操作不影响临界区上锁的状态;若之前未上锁,TestAndSet的上锁操作表示自己将要访问临界区
(1)若刚开始lock的值为false(即未上锁):
①TestAndSet函数的返回值为false,即while语句的条件为假,跳出循环进入临界区
②在①执行的过程中,即在TestAndSet函数中通过*lock = true(即上锁)
③退出临界区将lock重新改为false(即解锁)
(2)若刚开始lock的值为true(即上锁):
TestAndSet函数的返回值为True,即while语句的条件为真,进程将会卡在while循环,直到正在访问临界区的进程退出临界区(即等待正在访问临界区的进程执行lock = false)
2.优点:适用多处理机环境
缺点:违反让权等待的原则(若有进程正在访问临界区,则想要访问该临界区的其他进程将会卡在while循环语句一直等待其退出,因为while此时是死循环)
3.3.3.swap指令(也称exchange指令或XCHG指令)
1.实现方法:①通过硬件实现 ②“一气呵成”的交换两个变量的值
//Swap指令的作用是交换两个变量的值 Swap (bool *a, bool *b) { bool temp; temp = *a; *a = *b; *b = temp; } //Swap指令的实现互斥访问的示例 //lock表示当前临界区是否上锁 bool old = true; while (old == true) Swap (&lock, &old); critical section; //临界区代码段 lock = false; //退出区,将lock改为false remainder section; //剩余区代码段
通过old变量记录当前是否上锁(即当前是否由于别的进程访问临界区),并通过lock变量上锁
2.swap指令和TestAndSet指令逻辑上是等价的,因此,它们的优缺点相同
3.4.互斥锁
1.一个进程在进入临界区时通过acquire()获得锁,在退出临界区时通过release()释放锁
2.通过bool类型的available表示当前是否可以获得锁:available = true时,则可通过acquire()获得锁,并且available改为false;available = false时,则acquire()会被阻塞,直到被改为true
acquire() { //获得锁 while (!available); //判断当前锁是否可用 available = false; //将锁的状态修改为不可用 } release() { //释放锁 available = true; //将锁的状态修改为可用 }
3.互斥锁的缺点为忙等(即违反让权等待原则):若已有进程正在访问临界区,则其他想要访问该临界区的进程将会一直卡在while循环中,等待其退出临界区,直到时间片用完前不会让出处理机
4.自旋锁:需要连续忙等的互斥锁(TSL指令、SWAP指令和单标志法)
5.特点:
①违反让权等待原则(有限等待,在该进程时间片用完时,仍会被剥夺处理机的使用权,切换至另一个进程)
②适用多处理机系统:例如进程P1正在访问临界区A,进程P2等待访问临界区A(自旋等待)
P2自旋等待即可以在P1退出临界区A时直接进入该临界区A,而不用切换环境(切换环境需要大量的系统开销
③不适用单处理机系统:单处理机系统只能有一个进程占用处理机,即P1只可能在自己的时间片完成对临界区的访问,也就是说P2不可能在自己时间片等到P1访问完临界区
3.5.信号量机制
1.信号量:一种变量,用来表示系统中某种资源的数量
2.原语:一气呵成的执行,不可中断(通过关/开中断指令实现)
3.wait(S)和signal(S)原语:对信号量进行操作
①wait和signal分别为函数名,信号量S为传入的参数
②wait和signal原语简称为P、V操作,可以分别简写为P(S)和V(S)
3.5.1.整型信号量
1.对信号量的操作只能有三种:①初始化 ②P操作 ③V操作
2.wait原语的作用是:
①循环检查当前资源是否够用(即满足访问需求):若够用,则跳出循环;若不够用,则将会不断检查(卡在循环)直到够用
②将该资源分配给当前进程(执行②时必然该资源够用),即系统资源数 - 1
3.signal原语的作用是将进程占用的系统资源归还,即系统资源数 + 1
4.在P0进程执行wait原语后(即开始使用打印机),若切换到其他进程,其他进程仍然会执行wait原语,但会卡在wait原语中的while循环语句(因为当前S的值为0),即循环等待P0使用完打印机
5.信号量本质上是双标志先检查法,只是执行上能够一气呵成:
①while(S <= 0) 代表检查
②S = S - 1 代表上锁
6.违背让权等待原则(与记录型信号量主要的区别):若有进程正在访问临界区,其它想要该临界区的进程访问将会卡在循环
3.5.2.记录型信号量
1.记录型信号量解决整型信号量中存在忙等现象:
①在执行wait原语时,若剩余资源不够分配,将会把该进程从运行态变为阻塞态,并且把该进程挂到信号量S对应的等待队列中
②执行signal原语时,多了一个当前剩余资源的判断(在归还系统资源后),若当前剩余资源仍为<= 0,说明还有其他进程在等待使用该系统资源,则从信号量S对应的等待队列中唤醒一个进程,并将该进程从阻塞态变为运行态(阻塞态→就绪态→运行态)
2.wait(S)和signal(S)对应P(S)和V(S)
3.S.value的初值表示某种系统资源的数量
4.对信号量S的一次P操作表示有一个进程请求该系统资源:
①P操作要执行S.value--(表示该系统资源的数量 - 1)
②当S.value < 0时,表示该系统资源分配完毕
③当S.value < 0时,需要执行block(阻塞原语,并非每次P操作都要执行)主动放弃处理机的使用权(运行态→阻塞态)
(因此,遵循让权等待原则)
5.对信号量S的一次V操作表示有一个进程释放该系统资源:
②V操作要执行S.value++(表示该系统资源的数量 + 1)
②当S.value <= 0时(在执行S.value--后),说明还有其他进程在等待使用该系统资源,需要执行weak(唤醒原语,并非每次V操作都要执行)将等待队列的第一个进程唤醒,并将其上处理机运行(阻塞态 → 就绪态 → 运行态)
3.6.用信号量实现进程互斥、同步和前驱关系
3.6.1.用信号量实现进程互斥
1.用单独的一个变量mutex实现互斥访问:mutex = 1表示该资源当前没有进程访问;mutex <= 0表示该资源有进程正在访问
2.semaphore代表的是记录型信号量,即带有排队和阻塞功能,不会忙等
3.不同的临界资源需要设置不同的互斥信号量:例如打印机(mutex1)和摄像头(mutex2)
4.PV操作必须成对出现:缺P无法互斥;缺V将会无法唤醒
3.6.2.用信号量实现进程同步
3.6.3.用信号量实现前驱关系
3.7.生产者-消费者
1.缓冲区满:生产者不能生产(阻塞,同步关系),消费者可以消费
2.缓冲区不满(非空):生产者可以生产,消费者可以消费
3.缓冲区空:生产者可以生产,消费者不能消费(阻塞,同步关系)
4.各进程对缓冲区的访问是互斥的:防止对缓冲区的某个相同区域进行操作(缓冲区是临界资源)
5.互斥关系在同一个进程内进行(即PV操作在同一个进程内);同步关系在不同的进程间运行(即一个进程P,另一个进程V)
//互斥信号量,用于消费者和生产者互斥访问缓冲区 semaphore mutex = 1; //同步信号量,表示缓冲区的空闲数量,初始缓冲区为空,即empty = n //生产者每生产一个就-1,即P(empty) //消费者每消费一个就+1,即V(empty) semaphore empty = n; //同步信号量,表示缓冲区的当前数量,初始缓冲区为空,即full = 0 //生产者每生产一个就+1,即V(full) //消费者每消费一个就-1,即P(full) semaphore full = 0; producer() { while(1) { 生产一个产品; P(empty); //检查缓冲区的空闲数量,有空闲则 - 1;没有则阻塞 P(mutex); //开始访问缓冲区(互斥) 把产品放入缓冲区; V(mutex); //结束访问缓冲区 V(full); //缓冲区当前数量 + 1 } } consumer() { while(1) { P(full); //检查缓冲区的当前数量,有产品则 - 1;没有则阻塞 P(mutex); //开始访问缓冲区(互斥) 将产品从缓冲区中取出; V(mutex); //结束访问缓冲区 V(empty); //缓冲区空闲数量 + 1 } }
6.实现互斥的P操作一定要在实现同步的P操作之后:防止死锁
7.生产/使用产品最好在PV操作之前或之后,这样就可以减少临界区的代码数量,降低访问临界区的时间,从而提高整体的实行速度
8.V操作并不会导致进程阻塞
3.8.多生产者-多消费者
1.互斥关系:对盘子(缓冲区)的访问是互斥的
2.同步关系:
①父亲先放苹果,女儿后取苹果
②母亲先放橘子,儿子后取橘子
③女儿/儿子取出水果后(缓冲区we),父亲/母亲才能放水果
semaphore orange = 0; //同步关系,盘子中橘子的数量 semaphore apple = 0; //同步关系,盘子中苹果的数量 semaphore plate = 1; //同步关系,盘子中水果的数量 semaphore mutex = 1; //互斥关系,实现对盘子(缓冲区)的互斥访问 dad() { while(1) { 准备一个苹果; P(plate); //检查盘子是否还能放水果,能则 - 1;不能则阻塞 P(mutex); //开始使用盘子(互斥访问临界资源) 将苹果放入盘子; V(mutex); //结束使用盘子 V(apple); //苹果数量 + 1 } } mom() { while(1) { 准备一个橘子; P(plate); //检查盘子是否还能放水果,能则 - 1;不能则阻塞 P(mutex); //开始使用盘子(互斥访问临界资源) 将橘子放入盘子; V(mutex); //结束使用盘子 V(orange); //橘子数量 + 1 } } son() { while(1) { P(apple); //先检查苹果数量是否够,够则 - 1;不够则阻塞 P(mutex); //开始使用盘子(互斥访问临界资源) 将苹果取出盘子; V(mutex); //结束使用盘子 V(plate); //盘子中水果数量 - 1 吃掉苹果; } } daughter() { while(1) { P(orange); //先检查橘子数量是否够,够则 - 1;不够则阻塞 P(mutex); //开始使用盘子(互斥访问临界资源) 将橘子取出盘子; V(mutex); //结束使用盘子 V(plate); //盘子中水果数量 - 1 吃掉橘子; }
3.当缓冲区(此题为盘子)数量 = 1时,有时可以不需要用mutex实现互斥访问(此题就行);而当缓冲区数量 > 1时,则必须要用mutex实现互斥访问(最好都使用mutex)
4.要用事件角度思考同步关系
3.9.吸烟者问题
1.互斥关系:供应者和吸烟者需要互斥的访问桌子(此题中缓冲区大小为1,因此不需要单独设置mutex实现互斥访问)
2.同步关系:
(1)吸烟者①②③都需要在供应者分别在桌上提供相应的组合才能取走东西(吸烟者①对应offer1;吸烟者②对应offer2;吸烟者③对应offer3。即每个吸烟者对应的offer信号不同)
(2)每个吸烟者完成后需要给一个完成信号告诉供应者可以开始下次供应(即每个吸烟者的完成信号相同)
3.通过对i信号变量的自增和取余操作实现吸烟者①②③的轮流吸烟操作
semaphore offer1 = 0; //同步信号,桌上组合一的数量 semaphore offer2 = 0; //同步信号,桌上组合二的数量 semaphore offer3 = 0; //同步信号,桌上组合三的数量 semaphore finish = 0; //同步信号,当前抽烟者是否完成抽烟 int i = 0; //用于实现吸烟者轮流抽烟 provider() { while(1) { if (i == 0) { //当i = 0时 将组合一放到桌上; V(offer1); //同步信号,提供组合一,即组合一数量 + 1 } else if (i == 1) {//当i = 1时 将组合二放到桌上; V(offer2); //同步信号,提供组合二,即组合二数量 + 1 } else {//当i = 2时 将组合三放到桌上; V(offer3); //同步信号,提供组合三,即组合三数量 + 1 } P(finish); //同步信号,等待某个吸烟者完成吸烟 i = (i + 1) % 3; //每次循环先执行i = i + 1,然后i对3取余 } } smoker1() { while(1) { P(offer1); //同步信号,检查桌上的组合是不是组合一,是,则拿走;不是,则阻塞 拿走桌上的组合一; 卷烟; 抽烟; V(finish); //完成吸烟,给供应者发出完成信号 } } smoker2() { while(1) { P(offer2); //检查桌上的组合是不是组合二,是,则拿走;不是,则阻塞 拿走桌上的组合二; 卷烟; 抽烟; V(finish); //同步信号,完成吸烟,给供应者发出完成信号 } } smoker3() { while(1) { P(offer3); //同步信号,检查桌上的组合是不是组合三,是,则拿走;不是,则阻塞 拿走桌上的组合三; 卷烟; 抽烟; V(finish); //同步信号,完成吸烟,给供应者发出完成信号 } }
3.10.读者写者问题
1.允许多个读者可以同时对文件进行读操作:读操作不会改变数据
2.同一时间只允许写者对文件进行写操作:同时写,发生覆盖
3.任一写者在完成写操作前不允许其他写者或者读者工作:同时写,发生覆盖;同时读写,导致读出的数据不一致
4.写者进行写操作前,已有的写者和读者必须停止工作:同时写,发生覆盖;同时读写,导致读出的数据不一致
5.互斥关系:写-读、写-写(读-读可以同步进行)
6.申明一个count变量用于记录当前有几个读者正在进行读操作:由第一个将要进行读操作的读者负责读/写互斥访问操作,即上锁,并且每个将要进行读操作的读者都需要判断自己是不是第一个将要进行读操作的读者,即判断count是否为0
7.对count变量的判断和加锁需要一气呵成,即需要用mutex实现对count访问的互斥操作:如果不能一气呵成进行,则可能发生当count = 0时,在读者A对count进行判断后,执行上锁操作前,切换成读者B进程,读者B也对count进行判断,然后进行上锁操作,此时就将导致读者A阻塞(因为读者B进行P(rw)操作后,rw = 0,切换回读者A进程后,读者A也要进行P(rw),而此时rw = 0,阻塞,需要等待读者B及后续可能存在的其他读者进程完成读操作才能进行读者A的读操作)
8.读写应该公平,防止写进程饿死:增加一个同步信号w,w初始值为1;其作用就是当有写者被阻塞时,该写者之后出现的读者进程将被阻塞,等待①之前的读者全部读完 ②读者开始写并写完 ,这些才能被唤醒,即执行读操作(即实现按顺序进行读/写操作)
①读者A→读者B:读者B在读者A执行V(W)前会被阻塞在P(W),读者A执行V(W)后读者B被唤醒
②写者A→写者B:写者B在写者A执行V(W)前会被阻塞在P(W),写者A执行V(W)后写者B被唤醒
③写者A→读者B:读者B在写者A执行V(W)前会被阻塞在P(W),写者A执行V(W)后读者B被唤醒
④读者A→写者B→读者C:读者A开始读文件时,也就意味着按顺序执行了P(W)、P(RW)和V(W);写者B可以正常执行P(W),但会被阻塞在P(RW);读者C将会被阻塞在P(W)
读者A在读完文件后(即执行V(RW)后),写者B将会被唤醒;在读者B写完文件后(即执行V(W)后),读者C将会被唤醒
如果没有添加同步信号w,将会写者B将会在读者C后执行
⑤写者A→读者B→写者C:写者A开始写文件时,也就意味着按顺序执行了P(W)和P(RW);读者B将会被阻塞在P(W);写者C也将会阻塞在P(W)
由于信号量具有排队功能,读者B执行P(W)后W = -1,写者C执行P(W)后W = -2(即读者B在写者C之前执行P(W)),写者A在写完文件后(即执行V(W)后),先唤醒读者B,读者B在读完文件后(即执行V(W)后),再唤醒写者C
semaphore w = 1; //互斥信号,实现读/写公平 semaphore rw = 1; //互斥信号,实现读/写不同时进行 semaphore mutex = 1; //互斥信号,实现读者对count变量的互斥访问 int count = 0; //标记当前有几个读者正在进行读操作 writer() { while(1) { P(w); //检查是否有其他读/写者,有则排队 P(rw); //检查是否有读者正在读 写操作; V(rw); V(w); } } reader() { while(1) { P(w); //检查是否有其他读/写者,有则排队 P(mutex); //互斥访问count变量 if (!count) P(rw); //若是第一个读者,则上锁 count++; //读者数量 + 1 V(mutex); V(w); 读操作; P(mutex); //互斥访问count变量 count--; //读者数量 - 1 if (!count) V(rw); //若是最后一个读者,则解锁 V(mutex); } }