2.3 同步与互斥
2.3.1 同步与互斥的基本概念
- 临界资源
- 一次仅允许一个进程使用的资源称为临界资源
- 对临界资源的访问过程可以分为四个部分:
- 进入区:检查能否进入临界资源,若能进入,则应设置正在访问临界区的标志,以阻止其他进程同时进入临界区(上锁)
- 临界区:访问临界资源的代码
- 退出区:将正在访问临界区的标志清楚
- 剩余区:代码中的其余部分
- 公共队列是临界资源,磁盘存储介质是共享资源,不是临界资源
- 同步
- 同步也称直接制约关系,是指为完成某种任务而建立的两个或多个进程,这些进程因为需要协调它们的运行次序而等待、传递信息所产生的制约关系。同步关系源于进程之间的相互合作
- 互斥
- 互斥也称为间接制约关系
- 当一个进程进入临界区使用临界资源时,另一个进程必须等待,当占用临界资源的进程退出临界区后,另一个进程才允许区访问此临界资源
- 不同线程对同一个进程内的共享变量的访问需要互斥的进行;不同进程的线程、代码段或变量不存在互斥访问的问题,同一个线程内部的局部变量也不存在互斥访问的问题
- 准则
- 空闲让进:临界区空闲时,可允许一个进程进入临界区
- 忙则等待:当已有进程进入临界区时,其他试图进入临界区的进程必须等待
- 有限等待:对请求访问的进程,应保证能在有限时间内进入临界区
- 让权等待:当进程不能进入临界区时,应立即释放处理机,防止进程忙等待
2.3.2 实现临界区互斥的基本方法
软件实现方法
-
单标志法
- 算法设置一个公用整型变量turn,指示允许进入临界区的进程编号。turn=0时允许P0进入临界区,turn=1时允许P1进入
- 进程退出临界区时,将临界区的使用权赋予另一个进程
进程P0:
while(turn != 0); //进入区 critical section; //临界区 turn = 1; //退出区 remainder section;//剩余区进程P1:
while(turn != 1); //进入区 critical section; //临界区 turn = 0; //退出区 remainder section;//剩余区- 两个进程必须交替进入临界区 ,若某个进程不再进入临界区,则另一个进程也将无法进入,违背"空闲让进"原则
-
双标志先检查法
- 设置一个布尔型数组flag[2],用来标记各进程想进入临界区的意愿。flag[i]=true表示Pi想进入临界区(i=1, 2)
- Pi进入临界区之前先检查对方是否想进入临界区,若对方想,则等待;若对方不想,则将自己的flag[i]置为true,再进入临界区;当Pi退出临界区时,将flag[i]置为false
P0进程
cwhile(flag[1]); //进入区 flag[0] = true; //进入区 critical section; //临界区 flag[0] = false; //退出区 remainder section;//剩余区P1进程
cwhile(flag[0]); //进入区 flag[1] = true; //进入区 critical section; //临界区 flag[1] = false; //退出区 remainder section;//剩余区- 优点:不用交替进入,可连续使用
- 缺点:两个进程可能同时进入临界区(取决于进程并发执行的顺序),违反"忙则等待"原则
-
双标志后检查法
- 设置一个布尔型数组flag[2],用来标记各进程想进入临界区的意愿。
- 进入临界区之前先对临界区上锁,然后再检查对方是否有意愿进入临界区
P0进程
cflag[0] = true; //进入区 while(flag[1]); //进入区 critical section; //临界区 flag[0] = false; //退出区 remainder section; //剩余区P1进程
cflag[1] = true; //进入区 while(flag[0]); //进入区 critical section; //临界区 flag[1] = false; //退出区 remainder section; //剩余区- 两个进程不会同时进入临界区,但可能两个进程都进不去临界区,违反"空闲让进"和"有限等待"原则,造成死锁
-
Peterson算法
- 结合算法一与算法三,使用turn解决饥饿问题,使用flag解决互斥访问问题
- flag[i]表示Pi想访问临界资源,turn=i表示谦让给Pi进入临界区
P0进程
cflag[0] = true; turn = 1; while(flag[1] && turn==1); critical section; flag[0] = false; remainder section;P1进程
cflag[1] = true; turn = 0; while(flag[0] && turn==0); critical section; flag[1] = false; remainder section;- 先谦让的进程turn值会被后谦让的进程覆盖,不会出现死锁(先谦让的进程就是客气一下,后谦让的才是真谦让)
- 解决了"空闲让进"、"忙则等待"、"有限等待"的问题,但依然未遵循["让权等待"](#2.3.1 同步与互斥的基本概念),不能进入临界区的进程仍然会被分配时间片,但是啥也不干,就忙等
硬件实现方法
-
中断屏蔽方法
-
CPU指在发生中断时引起进程切换,因此屏蔽中断能够保证当前运行在临界区代码顺利的执行完
关中断
临界区
开中断
优点:简单高效
缺点:
- 不适用于多处理机
- 只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态)
-
-
TestAndSet指令(TS/TSL)
- 该指令用硬件实现,不允许中断
- 不需要在关中断的状态下实现
c//使用共享变量lock表示当前临界区是否上锁 bool TsetAndSet (bool *lock){ bool old; old = lock; //保存旧的lock值 *lock = true; //无论之前是否上锁,都给临界区上锁 return old; }cwhile(TestAndSet(&lock)); //对临界区检查并上锁 临界区代码 lock = false; 剩余区代码- 优点:实现简单;适用于多处理机
- 缺点:不满足"让权等待"原则,无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致忙等
-
Swap指令
- 也叫Exchange指令,或简称XCHG指令
- Swap指令也是用硬件实现的,执行过程不允许被中断
cSwap(bool *a, bool *b){ bool temp; temp = *a; *a = *b; *b = temp; }c//lock表示临界区是否上锁 bool old = true; while(old == true) //只有lock为false时,swap后的old为false,才能进入临界区 Swap(&lock, &old); 临界区代码 lock = false; 剩余区代码- 优点:实现简单,适用于多处理机环境
- 缺点:不满足"让权等待"原则,无法进入临界区的进程会占用CPU并循环执行Swap指令,从而导致忙等
2.3.3 互斥锁
c
acquire(){
while(!available);
available = false;
}
releease(){
available = true;
}
- acquire()和release()都是原子操作
- 缺点是忙等待
- 需要连续循环忙等待的互斥锁,都可称为自旋锁
- 优点是等待锁期间不需要切换进程上下文,若上锁时间低,则等待代价低。常用于多处理器系统
- 不太适用单处理器系统
2.3.4 信号量
信号量就是一个变量,可以用一个信号量来表示系统中某种资源的数量
可以用系统提供的一对原语wait/P和signal/V来操作信号量
PV操作由两个不可中断的过程组成
-
整型信号量
- 用一个整型变量作为信号量
- 只能对信号量进行初始化、P操作、V操作
cint S = 1; void wait(int S){ while(S<=0); //如果资源数不够,就一直循环等待 S--; } void signal(int S){ S++; //使用完资源后,退出区释放资源 }进程P
cwait(S); 使用临界资源 signal(S);- 整型信号量未遵循"让权等待"原则
-
记录型信号量
- 使用value代表资源数量,使用进程链表L用于链接所有等待该资源的进程
ctypedef struct{ int value; struct process *L; //等待队列 }semaphore; void wait(semaphore S){ S.value--; if(S.value<0) //如果当前的剩余资源不够 block(S.L); //就阻塞该进程 } void signal(semaphore S){ S.value++; if(S.value<=0) //如果当前还有进程等待使用资源 wakeup(S.L); //就把该进程唤醒 }- 无法进入临界区的进程会被阻塞,不会占用CPU,符合"让权等待"原则
-
利用信号量实现进程互斥
- 使用互斥信号量
mutex,初始值为1,表示可以进入临界区的进程数 - 进入临界区前先对mutex进行P操作,退出临界区后对mutex进行V操作,释放临界区
csemaphore mutex=1; P1(){ ... P(mutex); //上锁 临界区代码 V(mutex); //解锁 ... } P2(){ ... P(mutex); 临界区代码 V(mutex); ... }- 对不用的临界资源需要设置不同的互斥信号量
- P、V操作必须成对出现。缺少P操作不能保证互斥访问,缺少V操作会导致资源永不被释放,等待进程不被唤醒
- 使用互斥信号量
-
利用信号量实现同步
- 进程同步:要让各并发进程按要求有序的推进
- 设置一个同步信号量
S,初始值为0,表示初始没用这种资源,需要由进程产生这种资源 - 在先执行的操作后执行V操作,产生资源S
- 在后执行的操作前执行P操作,消耗资源S
cP1(){ 代码1; V(S); } P2(){ P(S); 代码2; } -
利用信号量实现前驱关系
- 每对前驱关系都是一个同步问题
- 对每一对前驱关系设置一个同步信号量,按照同步关系实现"前V后P"
-
利用信号量实现资源分配问题、
- 设置信号量,由多少个资源就将信号量的初始值设为几
- 申请资源时执行P操作,释放资源后执行V操作
-
分析进程同步和互斥问题的方法步骤
- 关系分析。找出问题中的进程数,分析他们之间的同步和互斥关系
- 整理思路。确定P、V操作流程
- 设置信号量。
2.3.5 经典同步问题
生产者-消费者问题
- 问题描述:系统中有一组生产者和一组消费者进程,生产者进程每次生成一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品;缓冲区满时,生产者进程阻塞等待;缓冲区空时,消费者进程阻塞等待。各进程必须互斥的访问缓冲区
- 问题分析:
- 关系分析。生产者和消费者访问缓冲区是互斥的 ;同时又是相互协作的,只有生产者生产了,消费者才能消费,消费者消费后,生产者才能生产,存在两个同步关系
- 整理思路:使用两个信号量full、empty表示同步关系。生产者生产前消耗(P)一个空缓冲区
empty,生产后产生(V)一个满缓冲区full;消费者消费前消耗(P)一个满缓冲区full,消费后产生(V)一个空缓冲区empty。使用互斥信号量mutex表示互斥关系,需要在进程拿到缓冲区资源后再尝试占用缓冲区❗️顺序不能倒 - 设置信号量。mutex表示互斥,初值为1。full表示满缓冲区数量,初始缓冲区均为空,初值为0;empty表示空缓冲区数量,初始均为空,初值为n。缓冲区的大小仅与empty的初值有关,与互斥信号量mutex无关
c
semaphore mutex=1;
semaphore full=0;
semaphore empty=n; //空缓冲区的数量
producer(){
while(1){
P(empty); //对两个信号量的P操作顺序不能颠倒
P(mutex);
把产品放入缓冲区;
V(mutex);
V(full);
}
}
consumer(){
while(1){
P(full);
P(mutex);
从缓冲区取出一个产品;
V(mutex);
V(empty);
}
}
- 若颠倒了两个P操作的顺序,可能会造成死锁 。比如,生产者进程先占据了缓冲区资源
P(mutex),但在申请空缓冲区时发现缓冲区都满了,进程会阻塞在empty的等待队列中,不释放缓冲区mutex资源,导致其他进程都无法进入临界区。死锁了 - V操作的顺序可以颠倒
- 生产者-消费者问题用于解决多个进程之间的同步和互斥问题
多生产者-多消费者问题
- 问题描述:一个盘子,只能放一个水果,父亲往里面放苹果,母亲往里面放橘子;儿子只吃苹果,女儿只吃橘子
- 问题分析:
- 关系分析。所有进程对缓冲区(盘子)的访问需要互斥的进行。同步关系:父亲放完儿子吃,母亲放完女儿吃,只有盘子空时才能放。
- 思路整理。
- 对于父母进程:设置信号量
plate表示是否有空盘子用。设置互斥信号量mutex,确保互斥访问临界资源(盘子)。如果有空盘子用,就占用申请占用临界资源,然后访问临界区,最后产生相应的资源并释放临界区。 - 对于子女进程:先查看水果资源,然后锁定临界区,取出水果后产生一个空盘子并释放临界区
- 对于父母进程:设置信号量
- 设置信号量。互斥信号量mutex初值为1。plate表示空盘子数量,初始为1。apple、orange表示盘子里水果的数量,初始为0。
c
semaphore mutex=1; //互斥访问临界资源(盘子),可以去掉
semaphore apple=0; //盘子里有几个苹果
semaphore orange=0; //盘子里有几个橘子
semaphore plate=1; //有几个空盘子
dad(){
while(1){
准备一个苹果;
P(plate);//申请一个盘子资源
P(mutex);
把苹果放入盘子;
V(apple);//增加一个苹果资源
V(mutex);//释放临界资源
}
}
mom(){
while(1){
准备一个橘子;
P(plate);//申请一个盘子资源
P(mutex);
把橘子放入盘子;
V(orange);//增加一个橘子资源
V(mutex);//释放临界资源
}
}
son(){
while(1){
P(apple); //消耗一个苹果
P(mutex); //申请临界区
取出苹果;
V(plate); //产生一个空盘子
V(mutex); //释放临界资源
}
}
daughter(){
while(1){
P(orange); //消耗一个橘子
P(mutex); //申请临界区
取出橘子;
V(plate); //产生一个空盘子
V(mutex); //释放临界资源
}
}
- 互斥信号量
mutex可以去掉。因为这里缓冲区的大小为1,plate、orange、apple在同一时刻只能有一个是1,最多只有一个进程不会被P操作阻塞 - 缓冲区大小大于1的时候必须设置互斥信号量
- 两个P操作的顺序不能反
去掉互斥信号量mutex后的进程同步实现:
c
semaphore mutex=1; //互斥访问临界资源(盘子)
semaphore apple=0; //盘子里有几个苹果
semaphore orange=0; //盘子里有几个橘子
semaphore plate=1; //有几个空盘子
dad(){
while(1){
准备一个苹果;
P(plate);//申请一个盘子资源
把苹果放入盘子;
V(apple);//增加一个苹果资源
}
}
mom(){
while(1){
准备一个橘子;
P(plate);//申请一个盘子资源
把橘子放入盘子;
V(orange);//增加一个橘子资源
}
}
son(){
while(1){
P(apple); //消耗一个苹果
取出苹果;
V(plate); //产生一个空盘子
}
}
daughter(){
while(1){
P(orange); //消耗一个橘子
取出橘子;
V(plate); //产生一个空盘子
}
}
读者-写者问题
-
问题描述:有读者和写者两个进程,共享一个文件,允许多个读进程同时访问;写进程只能单独访问;写进程完成前不允许其他进程工作;写操作开始前其他进程必须退出
-
问题分析:
- 关系分析:写进程-写进程、写进程-读进程 互斥关系
- 思路整理。
- 设置互斥信号量
rw实现对文件的互斥访问。 - 使用计数器
count表示当前读进程的数量,第一个读进程进入时要对文件上锁,最后一个读进程退出时要解锁 - 设置互斥信号量
mutex实现对计数器count的互斥访问。若不互斥的访问count,可能导致if条件始终不满足,无法释放资源
- 设置互斥信号量
- 设置信号量。
- rw初始为1,表示有一个文件可以访问
- count初始为0,表示没有读进程正在访问
- mutex初始为1,表示有一个计数器count可供访问
csemaphore rw=1; //实现对文件的互斥访问 int count=0; //记录当前有几个读进程在访问文件 semaphore mutex=1; //互斥访问count变量 writer(){ P(rw); //上锁 写文件; V(rw); //解锁 } reader(){ while(1){ P(mutex); //各读进程互斥访问count if(count==0) P(rw); //第一个读进程对文件上锁 count++; V(mutex); 读文件; P(mutex); //互斥访问count count--; if(count==0) V(rw); //最后一个读进程对文件解锁 V(mutex); } }- 算法中读进程是优先的,只要有读进程在读,写进程就要等待,后到达的读进程也可能比写进程先执行
- 为实现写进程优先,可以额外设置一个信号量w表示写优先:
csemaphore rw=1; //实现对文件的互斥访问 int count=0; //记录当前有几个读进程在访问文件 semaphore mutex=1; //互斥访问count变量 semaphore w=1; //实现写优先 writer(){ P(w); //对w上锁 P(rw); //上锁 写文件; V(rw); //解锁 V(w); //对w解锁 } reader(){ while(1){ P(w); //对w上锁 P(mutex); if(count==0) P(rw); count++; V(mutex); V(w); //对w解锁 读文件; P(mutex); count--; if(count==0) V(rw); V(mutex); } }- 设置了信号量w之后,先到达的读进程会被阻塞在w的队首,读进程释放了w之后,会优先唤醒队首的写进程,写进程进入rw的等待队列队首,在写进程之后到达的读进程仍被阻塞在w的等待队列中,直到写进程执行结束后释放w
哲学家进餐问题
-
问题描述:圆桌上坐着五个哲学家,每两人之间摆一根筷子。哲学家饥饿时,会试图一根一根拿起左、右两根筷子进餐。如果筷子在别人手上,则需等待。只有使用两根筷子进餐完毕后,才会放下筷子继续思考
-
问题分析:
-
关系分析:每相邻的两个哲学家对筷子的访问是互斥关系
-
整理思路。只存在互斥关系,但要注意避免死锁
- 对哲学家和筷子从0~4编号,规定i号哲学家左手边为i号筷子,右手边为(1+1)%5号筷子
有以下几种方法避免死锁:
- 至多允许四名哲学家同时进餐,保证至少有一名哲学家能够拿到两只筷子
- 仅当一名哲学家左右两边的筷子都可以使用时,才允许他拿起筷子
- 要求奇数号哲学家先拿左边的筷子,偶数号哲学家先拿右边筷子
-
设置信号量。
chopstick[5]={1,1,1,1,1}用于实现对五个筷子的互斥访问
第一种方法:
- 使用变量
seat表示座位数量,初始设置为4,表示只允许4个哲学家坐下就餐
csemaphore chopstick[5]={1,1,1,1,1}; semaphore seat=4; //四个座位 Pi(){ P(seat); P(chopstick[i]); P(chopstick[(i+1)%5]); 吃饭; V(chopstick[i]); V(chopstick[(i+1)%5]); V(seat); }第二种方法:
- 设置互斥信号量
mutex,哲学家拿筷子前先对筷子上锁,保证在同一时刻只有一个哲学家能拿筷子,且能顺利拿到一双筷子 - 当多个进程尝试拿筷子时,先拿到mutex资源的进程会阻塞其他进程,并顺利拿起两只筷子。当前进程结束后会释放资源,其他进程可以执行
csemaphore chopstick[5]={1,1,1,1,1}; semaphore mutex=1; //实现对筷子的互斥访问 Pi(){ do{ P(mutex); P(chopstick[i]); //拿左 P(chopstick[(i+1)%5]); //拿右 V(mutex); 吃饭; V(chopstick[i]); //放左 V(chopstick[(i+1)%5]); //放右 }while(1); } -
2.3.6 管程
-
管程的定义
-
利用共享数据结构抽象的表示系统中的共享资源,并将对该数据结构实施的操作定义为一组过程。进程对资源的申请、释放等操作都通过这组过程实现,还可以实现每次只有一个进程使用共享资源。这样一组资源管理程序叫做管程
-
管程由四部分组成:
- 管程的名称
- 局部于管程内部的共享数据结构说明
- 对该数据结构进行操作的一组过程/函数
- 对局部于管程内部的共享数据设置初始值的语句
-
管程的基本特征:
- 管程将对共享资源的操作封装起来
- 每次仅允许一个进程进入管程
管程很像一个类class
-
各进程互斥的进入管程是由编译器实现的
-
管程对外界提供"入口",进程只能通过"入口"(函数)进入管程
-
-
条件变量
- 当一个进程进入管程被阻塞后,直到阻塞原因被解除时,在此期间,若进程不释放管程,则其他进程无法进入管程。为此,将阻塞原因定义为条件变量
- 每个条件变量保存一个等待队列,用于记录因该原因而阻塞的所有进程,对条件变量只能进行两种操作,wait和signal
- x.wait:当对应的条件不满足时,正在调用管程的进程调用x.wait将自己插入x条件的等待队列中,并释放管程
- x.signal:x对应的条件发生了编号,调用x.signal,唤醒一个因x条件而阻塞的进程