操作系统-同步互斥
并发性:互斥和同步
基本概念
原子操作:一个函数或动作由一个或多个指令的序列实现,对外是不可见的;保证指令的序列要么作为一个组执行, 要么都不执行,对系统状态没有可见的影响。保证了并发的隔离。
临界区:一段代码,在这段代码中进程将访问共享资源,当另一个进程已经在这段代码中运行时,这个进程就不能在这段代码中运行。
临界资源:虽然多个进程可以共享系统中的各种资源,但其中许多资源一次只能为一个进程所使用,我们把一次只允许一个进程使用的资源成为临界资源。包括许多的物理设备如打印机,以及许多的变量和数据。
死锁:两个或两个以上的进程因其中的每个进程都在等待其他进程做完某些事情而不能继续执行。
活锁:两个或两个以上进程为了响应其他进程中的变化而持续改变自己的状态但不做有用的工作。
互斥:当一个进程在临界区访问共享资源的时候,其他进程不能进入该临界区访问任何共享资源。
同步:为完成某种任务而建立的两个或多个进程,这些进程因为需要在某些位置上协调它们的工作次序而等待、传递消息所产生的制约关系。进程间的直接制约关系就是源于他们之间的相互合作。
竞争条件:多个线程或者进程在读写一个共享资源时,结果依赖于它们执行的相对时间,这种情形称为竞争条件
饥饿: 一个可运行的进程尽管能继续执行,但被调度程序无限期地忽视,而不能被调度执行的情形。
忙等待/自旋等待: 进程在得到临界区访问权之前,他只能继续执行测试变量的指令来得到访问权,除此之外不能做其他事情。(重复执行一段循环代码以等待一个事件发生)
并发原理
并发的困难
多道程序设计系统的一个基本特性是:进程的相对执行速度不可预测,取决于其他进程的活动、操作系统处理中断的方式以及操作系统的调度策略。因此带来了以下的困难:
- 全局资源的共享充满危险
- 操作系统很难对资源进行最优化分配。
- 定位程序设计错误是非常困难的,结果通常是不确定的和不可再现的。
竞争条件
竞争条件发生在多个进程或者线程读写数据的时候,其最终的结果依赖于多个进程的指令执行顺序。
操作系统关注的问题
- 操作系统必须能够跟踪不同的进程,可以使用PCB来实现
- 操作系统必须为每个活跃的进程分配和释放各种资源,包括处理器时间、存储器、文件、I/O
- 操作系统要保护每个进程的数据和物理资源,避免其他进程的无意干。
- 一个进程的功能和输出结果必须与执行速度无关。
进程的交互
进程之间不知道对方的存在 |
竞争 |
一个进程的结果与另一个进程无关;进程的执行时间可能会收到影响 |
互斥、死锁、饥饿 |
进程间接知道对方的存在 |
通过共享合作 |
一个进程的结果依赖于另一个进程的信息;进程的执行时间可能会收到影响 |
互斥、死锁、饥饿、数据相关性 |
进程直接知道对方的存在 |
通过通信合作 |
同上 |
死锁、饥饿 |
互斥的要求
- 空闲让进: 允许一个请求进入临界区的进程立即进入临界区
- 忙则等待:已有进程进入临界区,其他试图进入的必须等待
- 有限等待:对请求访问的进程,应保证能在有限时间内进入临界区
- 让权等待:当进程不能进入临界区的时候,应立即释放处理器,防止进程忙等待
互斥的实现方法
硬件的支持了解
硬件方法的优点在于:适用于任意数目的进程;简单、容易验证其正确性;可以支持进程内有多个临界区,只需为临界区设立一个变量
硬件方法的缺点在于:进程等待进入临界区需要耗费处理器时间,不能实现让权等待。从等待进程中随机选择一个进入临界区,会导致饥饿现象。
中断禁用
关闭临界区的中断来保证互斥。由于CPU只有在发生中断的时候引起进程切换,关闭中断后就能保证当前运行进程将临界区代码顺利地执行完,从而保证了互斥的实现。典型模式为:
while(true){ /* 禁用中断 */ /* 临界区 */ /* 启用中断*/ /* 其余部分(剩余区)*/ }
这种方法的代价非常高,限制了处理器交替执行程序的能力,处理器执行的效率会被明显降低。此外这种方法将中断控制权移交给用户存在风险。该方法不能用于多处理器体系结构中。
专用机器指令
多处理器的设计者提出了一些机器指令,用来保证两个动作的原子性,如在一个取指令周期中对一个存储器单元的读和写或者读和测试是否唯一。
比较和交换指令
int compare_and_swap(int *word,int testval, int newval){ int oldval; oldval=*word; if(oldval==testval) *word=newval; return oldval; }
该指令使用了一个测试值来检查一个内存单元,如果测试通过则更新内存单元。该指令总是返回旧内存值。这个原子指令由比较和交换两个部分组成,不接受中断。
exchange指令
void exchange(int *register,int *memory){ int temp; temp=*memory; *memory=*register; *register=temp; } key=1; while(key!=0) exchange(&lock,&key); /*临界区*/ lock=0; /*剩余区*/
交换一个寄存器的内容和一个存储单元的内容。为每一个临界资源设置一个共享变量lock,初值为0.在每一个进程中再设置一个局部变量key,初始为1.进入临界区之前先利用exchange指令交换key和lock的内容,然后检查key的状态;由进程在临界区的时候,重复交换和检查过程,直到进程退出。
软件的实现
Dekker算法了解
https://blog.csdn.net/wsw875421872/article/details/17222219
Dekker算法是德国数学家设计的来解决并发互斥问题的一套算法,然而Dekker算法也无法避免软件互斥方法的一个通病,那就是忙等现象。Dekker算法仅能进行两个进程的互斥,对于两个以上的互斥问题,实现起来相当复杂。
boolean flag[2]; int turn; void p0(){ while(true){ flag[0]=true; //首先P0举手示意我要访问 while(flag[1]){ //如果p1也举手了 if(turn==1){ //看轮到了谁 flag[0]=false; //如果轮到了p1,则p0放下手 while(turn==1) //一直等待 flag[0]=true; //当p1结束后,p0举手 } } } /*临界区*/ turn=1; //转让轮次 flag[0]=false; //放下手 /*剩余区*/ } void p1(){ while(true){ flag[1]=true; while(flag[0]){ if(turn==0){ flag[1]=false; while(turn==0) flag[1]=true; } } } /*临界区*/ turn=0; flag[1]=false; /*剩余区*/ } void main(){ flag[0]=false; flag[1]=false; turn=1; parbegin(p0,p1); }
信号量实现非常重要
基本概念
信号量:用于进程间传递信号的一个整数值。在信号量上只有三种操作:初始化、递增和递减,这三种操作都是原子操作。递减操作原来阻塞一个进程,递增操作用来解除阻塞。也称为分为二元信号量和计数信号量(一般信号量)。
二元信号量:只有0和1的信号量
互斥量:类似于二元信号量,关键区别在于为其加锁的进程和为其解锁的进程必须是同一个进程
条件变量:一种数据类型,用于阻塞进程或线程,直到特定的条件为真。
管程:一种编程语言结构,在一个抽象数据类型中封装了变量、访问过程和初始化代码。管程的变量只能由管程自己的访问过程来访问,每次只能有一个进程在其中执行。访问过程即临界区。管程可以有一个等待进程队列。
信箱/消息:两个进程交换信息的一种方法,也可以用于同步。
自旋锁:一种互斥机制,进程在一个无条件循环中执行,等待锁变量的值变为可用。
原语:是由若干条指令组成的,用于完成一定功能的一个过程。
信号量
信号量有两个原语操作SemWait和SemSignal,也记作P V操作,通俗的说就是递减和递增操作。
- 一个信号量可以初始化成非负数。
- semWait(P操作)使信号量减1,如果值为负数,则执行该操作的进程被阻塞,否则继续执行。
- semSignal(V操作)使信号量加1,如果值小于等于0,则被semWait阻塞的进程会被接触阻塞
使用信号量来实现同步
如果某个行为要用到某种资源,就在那个行为面前P一下那种资源,查看是否已经获取到了这个资源。如果某种行为提供某种资源,就在这个行为V一下这个资源
semaphore S=0; p1(){ ... x(); V(s); //告诉p2,x已经执行完毕 } p2(){ ... P(s); //检查x是否已经完成 y(); ... }
使用信号量来实现互斥
不同进程对同一信号量进行PV操作,一个进程成功执行P操作后进入临界区,在退出的时候执行V操作,表示当前没有进入临界区,释放资源,让其他进程进入。
const int n=进程数 semaphore s=1; //这个1表示的是资源数,因为一次只能进一个,所以是1 void P(int i){ while(true){ semWait(S) /**临界区**/ semSignal(s); /*剩余区*/ } }
信号量问题分析步骤
- 关系分析:找出问题中的进程数、分析它们之间的同步互斥关系,按照范式来改写。
- 整理思路:找出解决问题的关键点,根据进程的操作流程来确定PV操作的大致顺序
- 设置信号量: 设置需要的信号量,确定初值,完善整理。
经典同步问题
生产者-消费者问题
一组生产者进程和一组消费者进程共享一个初始为空、大小为n的缓冲区,只有缓冲区没满的时候,生产者才能生产并放入缓冲区,否则必须等待。只有缓冲区不空的时候,消费者才能消费,否则必须等待。缓冲区是临界资源,只允许一个生产者放入产品或一个消费者消费产品。
分析过程:
对缓冲区的访问是互斥关系,生产-消费是一个协作关系
需要互斥信号量,用来控制缓冲池,并且设置为1;一个信号量来记录缓冲池中满的个数,初值为0 empty来记 录空的个数,初值为n。
semaphore full=0; //缓冲区初始化为0 semaphore empty=n; //空闲缓冲区 semaphore mutex=1; void producer(){ while(true){ /*produce*/ //生产数据 P(empty); //获取空缓冲区单元,需要则P P(mutex); //获取访问锁 /* ADD PRODUCT*/ //进入临界区,放入数据 V(mutex); //离开释放锁,互斥夹紧 V(full); //缓冲区加1,提供则V } } void Consumer(){ while(true){ P(full) //顺序是有讲究的,否则会出现死锁 p(mutex) /**获取产品**/ v(mutex) v(empty) /**消费**/ //释放的时候先后顺序无所谓 } }
吃水果问题
桌子上有一个盘子,每次放入一个水果。爸爸专放苹果,妈妈专放橘子,儿子专等橘子,女儿专等苹果。只有盘子为空时,爸爸妈妈可以放水果。只有盘子有自己需要的水果的时候,儿子女儿可以拿水果。
问题分析:
1.儿子与妈妈、女儿与爸爸是同步关系。爸爸与妈妈是互斥关系。
2.一共有4个进程,可以抽象为两个生产者和两个消费者,一个大小为1的缓冲
3.一个盘子的互斥信号量,初始值为1 一个苹果和一个橙子的信号量,初始值为0
semaphore plate=1,apple=0,orange=0; dad(){ while(1){ /*洗苹果*/ p(plate) /*放入苹果*/ v(apple) } } mom(){ while(1){ /*洗橘子*/ p(plate) /*放入橘子*/ v(orange) } } son(){ while(1){ P(orange) v(plate) } } daughter(){ while(1){ P(apple) v(plate) } }
读写者问题
读者和写者两组进程,共享一个文件,当两个或以上的读进程同时访问共享数据时不会产生副作用,但如果某个写进程和其他进程同时访问共享数据时则可能导致数据不一致的错误。因此 1.允许多个读者同时读 2.只允许一个写者写 3.任一写者在完成写操作之前不允许其他读者或写者工作 4.写者执行写操作前,应让已有的读者和写者全部ui出
问题分析:
1.读者和写者互斥 写写也互斥
- 写者是任意互斥的 但是读者是看情况的,同时读者的互斥锁也应该不一样
- cunt记录读者数量 mutex用来保护count变量的互斥 rw保证读者和写者的互斥访问
int count=0; semaphore mutex=1; //保护count变量的更新 semaphore rw=1; writer(){ while(1){ P(rw) writing; V(rw) } } reader(){ while(1){ P(mutex); if(count==0) //该进程是第一个读者 P(rw); //阻止写 count++; V(mutex); reading; P(mutex); count--; if(count==0) V(rw) V(mutex); } }
这套算法中,读进程是优先的,当存在读进程的时候,写操作会被延迟。并且只要有一个读进程活跃,则所有的度进程都被允许读。在这样的情况下,写进程有被饿死的可能
如果希望写进程优先,则读进程在共享文件的时候,有写进程请求,则应该禁止后续进程的请求。只有在无写进程的情况下,才能让读进程继续运行。
int count=0; semaphore mutex=1; //保护count变量的更新 semaphore rw=1; semaphote w=1; //实现写优先,即允许的写者数量 writer(){ while(1){ P(w) //没有写进程请求的时候进入 P(rw) writing; V(rw) V(w) //恢复对共享文件的访问,当写者结束之后才会释放w } } reader(){ while(1){ p(W) //无写进程请求的时候进入 P(mutex); if(count==0) //该进程是第一个读者 P(rw); //阻止写 count++; V(mutex); V(W) //恢复对共享文件的访问 对于每一个读者都要检查一遍是否有写者占用w,如果有则进入失败 reading; P(mutex); count--; if(count==0) V(rw) V(mutex); } }
这里的写进程优先是先对的。在w的阻塞队列上,如果读进程先来,则唤醒的会是读进程,写优先是相对的,即必须这个写进程也来得早。读写者的关键问题在于互斥访问的计数器count,所以当读者遇到一个不太好解决的问题,可以考虑使用计数器count解决。
哲学家进餐问题
一张圆桌上坐着5个哲学家,每两个哲学家之间的桌子上摆着一根筷子。哲学家们只做两件事:思考和进餐。哲学家们在思考结束后,拿起左右的筷子(一根一根的拿),如果筷子在其他人手上则等待。只有拿到了两根筷子的哲学家才可以开始进餐。进餐完毕后,放下筷子继续思考
问题分析:一共是五个进程,每两个进程对筷子的访问都是互斥关系。对筷子设置信号量数组,哲学家编号为0-4 左边的筷子为i,右边的为(i+1)%5
semaphore chopstick[5]={1,1,1,1,1}; semaphore mutex=1; //设置取筷子的信号量来确保每一次取的时候两边都有筷子,防止出现死锁 Pi(){ while(1){ P(mutex) P(chopstick[i]) P(chopstick[(i+1)%5]); V(mutex) eat V(chopstick[i]) V(chopstick[(i+1)%5]); think; } }
吸烟者问题
假设一个系统有三个抽烟者进程和一个供烟者进程。每个抽烟者不断卷烟并抽调,但是要卷烟并抽掉,抽烟者需要有三种材料:烟草、纸和胶水。三个抽烟者中,第一个拥有烟草、第二个拥有纸、第三个拥有胶水。供应成进程无限提供三种材料,供应者每次将两种材料放到桌子上,拥有剩下那种材料的抽烟者就能卷烟、抽烟,并给供应者一个信号,供应者会接着放材料,一直重复。
问题分析: 一共是四个进程,抽烟者之间是互斥关系,供应者与抽烟者之间是互斥关系。需要设置四个信号量来对应三种组合和一个抽烟的互斥动作。
semaphore offer1=0,offer2=0,offer3=0,finish=0; //offer123分别对应烟草、纸;烟草、胶水;纸、胶水的组合 int random; process P1(){ while(1){ random=rand()%3; if(random==0) V(offer1); else if(random==1) V(offer2); else V(offer3); P(finish) } }
process P2(){
while(1){
P(offer1)
V(finish)
}
}
P3 P4同P2