通过使用信号量,来让多个进程合理有序的推进工作。
我们的目标是让多个进程合理有序的共同完成一个任务,而不是各干各的。因此,对他们进行约束,就需要确保他们合作的有序性,即谁先做谁后做。
信号就是用于双方互相发出对方可执行或对方可等待信息,来实现多个进程合作推进。等待
是进程共同合作的核心。
生产者-消费者是多进程合作的经典示例。
BUFFER_SIZE
是一个共享缓冲空间buffer[n]
的空间容量,生产者不断地往buffer[n]
里放东西,不断地加一;消费者不断地往buffer[n]
里取东西,不断地减一。
需要让“进程走走停停”来保证多进程合作的合理有序。分析时,什么时候停是重点,知道什么时候停了,就能顺着推出什么时候走了。通过信号来控制,当对方之后有收到对方**“可以继续走”**的信息后,进程才可以继续执行。
但只有信号还不能解决全部问题。
(1)当缓冲区满以后生产者P1生产一个item放入后,会sleep。
(2)又一个生产者P2生产一个item放入,发现counter==BUFFER_SIZE后,也会sleep。
(3)然后,消费者C执行一次循环,首先判断counter不等于0,则不sleep,而去执行后续内容。消费了buffer[n]中的一个东西后,如果判断到counter==BUFFER_SIZE-1
(意味着刚才缓冲区满了,可能有生产者被阻塞),则就给生产者P1发出wakeup
信号,唤醒P1。
(4)当消费者C再执行1次循环时,此时counter
在之前已被上一个消费者减1。当再遇到counter==BUFFER_SIZE-1
时,counter=BUFFER_SIZE-2
不满足执行wakeup
条件,那么就会认为刚才没有缓冲区没有满,则没有进程被阻塞,没有进程在缓冲区。 就会导致不会发出wakeup
信号,P2不会被唤醒。
原因:counter
仅反映缓冲区空余的个数,但还需要一个变量可以反应被阻塞进程的个数。因此,当进程发送信号时,不能仅通过判断counter
就决定是否法信号,应该引入另外一个跟 睡眠和唤醒 有关的量来判断是否法可以发信号。这个量就是信号量
。
(1)用sem
作为信号量。缓冲区第一次满时,因为是1个进程等待,看起来像是缺少一个东西,所以记录为-1
。
(2)当P2再执行时,由于没有消费者进行释放,因此继续被阻塞,sem
减一,为-2。
(3)当消费者执行1次循环时,发现sem
小于0,则从阻塞队列的队头使用wakeup
唤醒一个进程,即为唤醒P1。然后,将sem
加一,就变为了-1。
(4)当消费者再执行1次循环时,发现sem
还是小于0,则再唤醒一个进程,即唤醒进程P2,sem
加一,变为0。
(5)当消费者再执行1次循环时,发现sem
大于0,没有进程处于睡眠状态。sem
加一后,变成1,意味着当前还有一个空闲位置可供生产者使用,来存放东西。
(6)P3再执行生产出一个东西,放入缓冲区中,sem
会被减1,此时为0,意味着当前没有空余位置可存放生产者生产的东西。
使用信号量作为记录:
(1)负值: 有多少(生产者)进程因为缺少资源而被阻塞
(2)正值: 当前还有多少资源可被(生产者)进程使用
因为信号量很好的关联了睡眠和唤醒动作,故使用信号量而不是信号来控制是否发送信号,根据 信号量的值 来决定进程什么时候等,什么时候走,从而实现进程的同步和等待。
这里也发现,信号量是对一个角色(生产者)因使用某一资源所产生的状态变化的控制。
所以,一个信号量应该对应着一个资源或一个进程所使用的资源。
每个信号量要关联一个进程队列,用于记录阻塞进程。
P操作
P(semaphore s) {
s.value--;
if(s.value < 0)
sleep(s.queue)
}
V操作
V(semaphore s) {
s.value++;
if(s.value <= 0) // 加完后还为0或负数,则说明之前有进程在阻塞队列里
sleep(s.queue);
}
对于先加或先减时,用if
应该考虑加前减前的状态进行判断。
生产者
(1)首先查看一下是否有空闲位置可以使用P(empty)
,如果没有则阻塞进程,如果有就使用则往文件里写内容。
(2)但因为文件内容为临界资源,所以此时消费者的读和生产者的写是互斥的访问,因此需要对文件内容再使用信号量进行控制。先使用P(mutex)
来判断是否有消费者正在读内容,如果有的话,生产者会被阻塞,等待消费者发出V
信号释放后,可写入。如果没有的话,生产者上锁,此时消费者不能读取文件内容。然后,生产者将内容写到item
的in
位置上。完成后,再V(mutex)
发出解锁信号。
(3)生产完后,生产者已经往空闲缓冲区里添加了内容,就给消费者释放出信号V(full)
。
消费者
(1)首先上来先测试一下,看缓冲区中是否有内容P(full)
,如果没有则阻塞进程
(2)但因为文件内容为临界资源,所以此时消费者的读和生产者的写是互斥的访问,因此需要对文件内容再使用信号量进行控制。对mutext
的操作同理生产者此位置处的操作,然后,消费者从out
位置上读出item
并打印出来。完成后,再V(mutex)
发出解锁信号。
(3)消费完后,消费者空闲缓冲区里增加了一个空位,就给生产者释放出信号V(full)
。
当我们共同修改信号量时,会引发出问题。当两个生产者刚好共同要修改缓冲区内容时,其中一个生产者得到的可能不是需要的原信号量的值,而是被另一个生产者刚修改完的值。
这不是因为编程而出现的错误,而是因竞争条件而出现的错误。
注:通过加一些空循环来使时间片到达预期的点,而保证不出现上述错误,也许仅能解决本次启动过程中的问题,但下一次开机可能会因为进程启动次序不一样导致又会出错,所以这种方法不能解决根本错误。
所以,我们必须要引入保护机制。
想法就是生产者在修改empty
时,需要上锁来保证此时该资源仅能被一个生产者所使用。只有当除以开锁状态时,其余生产者才能对empty
进行访问修改。最终实现,一个进程在修改empty
这段代码时,其他进程不能参与修改。对于empty
的修改“要么直接做完,要么一点都不做”,即为原子操作
。
临界区就是一次只允许一个进程进入的该进程里的那段代码,其中读写信号量的代码
一定是临界区。
互斥进入
是临界区代码保护的基本原则。而好的临界区保护原则是有空让进
和
有限等待
(某一进程不能一直处于等待其他进程的状态)。
一种方式是轮换法,一人进去一次,轮流挨个的执行。其中有一个问题,就是当该P1执行时,可能会因为一些情况P1去执行别的事了,P1就没有执行,但此时P0也不能进入,这就不符合有空让进原则。
上面的轮换法类似于值日
,更好的方法是立即去买,留一个便条
。这就引出了标记法
。
标记法是给每个进程在标记数组中保留一个标记位,当准备使用临界资源时,就把自己对应的标记位置置为true
,当进入访问时首先查看对方是否也标记为true
,如果对方也已标记为true
,则进入循环等待中,等待对方访问完变为false
后自己才可访问,并于访问完后将自己的标记位置为false
。
但问题是仅满足互斥访问要求,当两个进程一起把自己的标记位置为true
时,会造成双方都无法进入临界区,临界区此时空闲,但两个进程都无法进入,不满足空闲让进的要求。
由此引入非对称标志,让两个进程中其中一个更加勤劳一些,已知循环等待,而让另一个先进入。
进程P0想要访问临界区时,首先将标记为置为true
,然后每次也要保持“谦让”,让trun=1
使其发现对方进程想访问是让自己陷入循环等待。当进行到while()
时,发现对方进程已置true
标记且此时的turn为1
,意味着对方进程想要访问临界区且该自己循环等待(值日)了,便让自己处于循环状态而让对方进入访问。当对方访问完临界区后,又将它自己的标记置为false
,从而让进程P0可以访问临界区,访问完后将自己的标记置为false
。进程P1访问时也同理。
此方法主要由关键两步: 标记和轮转。用标记控制是否想要访问邻接资源,用标记控制自己是否此可以访问邻接资源,“每次都监控对方意图且保持谦让
”(while(flag[1] && turn == 1))。
Peterson
算法满足互斥进入、有空让进和有限等待。
借鉴生活中取号排队的思想,取得的号不为0,则表示想要进去,按号码从小到大授予访问权限。
首先,标记正在取号(choosing[i]=true
),然后取号并且取得是当前进程的最大号再加一(max(num[0], ... , num[n-1]) + 1
),不再取号(choosing[i]=false
)。
然后,对其余进程进行遍历,如果有进程正在取号,则等待该进程取完号本进程再操作(while(choosing[j])
)。当有其余进程想进入临界区(num[j]!=0
)并且该进程的号比本进程号小((num[j],j) < (num[i], i])
),那么本进程就陷入循环等待,让对方更小的号进程内部再去以此方式执行判断。当每个进程都以此方式工作时,最终会有一个号最小且想要访问临界区的进程没有陷入循环等待,进入临界区。
总结来说,每次当进程想要访问临界区时,先取号,然后再判断其余进程的情况,查看其余进程是否想要访问临界区?是否比自己更小? 对于想要临界区的进程,如果其余进程更小,则自己循环等待。如果与所有进程比较完后,发现自己进程的号最小,那么自己就可以去访问临界区了。
回想一下我们要解决的问题是一个进程在临界区时,另一个进程也进入临界区。那么,为什么另一个进程也会进入临界区呢?原因就是调度
。另一个进程只有被调度才能执行,才可能进入临界区。那么我们就会想,是否可以再一个进程进入临界区时,阻止另一个进程的调度。
最直观的想法就是阻止中断,因为去调度另一个进程的前提是使用中断,所以只要我们在进入临界区前关闭中断(cli()
)就可以阻止调度。当使用完后,再开中断(sti()
)。
这种情况在但CPU里好使,但在多CPU里不好使。
对临界资源上锁,但如果使用信号量作为锁的话,我们就还需要再对锁进行保护。为了实现上锁,修改empty
,开锁成为原子操作,那么就把这三个操作合成一条指令即可。
TestAndSet()
实现的就是判断x
之前是否为true
并且也将本次的x
置为true
。如果之前x
就为true
,则说明已经有进程在访问临界区,需要循环等待。如果为false
说明之前没有进程在访问临界区,则自己进入临界区,其他进程再访问时,由于已经被本进程改为了true
,所以其他进程也进不来。直到访问完毕后,将lock
置为false
,其他进程可进入。在执行过程中TestAndSet()为原子操作,要么一下子执行完,要么一下都不执行,中途不可被打断。
用临界区去保护信号量,用信号量来实现同步(访问共享资源)。
对于多进程同步中,单CPU可用关中断保护临界区,多CPU可用硬件原子指令法保护临界区。
if
中是把阻塞队列中的一个唤醒。
while
是把阻塞队列中的全部都唤醒。
多个进程 互相等待对方持有的资源
而造成的谁都无法执行的情况叫 死锁
。
(1)在进程执行前,一次性申请所有需要的资源,不会占有了一部分资源后,再去申请其他资源
。
(2)对资源类型进行排序,资源申请必须按序排序,不会出现环路等待
。
判断系统中按一个预定的执行序列P1P2…Pn执行后,提前判断是否可以正确完成且不会出现死锁情况。如果可以正确完成,则认为系统处于安全状态
。
在图上判定是否为安全序列
。其中,Allocation是该进程可分配的资源、Need是该进程需要的资源、Available是系统中现有的资源。
每次判断该任务是否没有执行完
和当前所需资源量系统是否满足
,如果其中有一个不满足就判断下一个任务,如有一个任务满足时,就将资源分配后释放使用过的资源给系统,同时置该任务已完成,再进行下一次循环。
请求出现时,先假装分配,然后查看后续分配是否可以满足其他任务。如果不满足,则此次申请被拒绝。
由于每次都执行死锁避免的银行家算法时,时间复杂度为 O ( m n 2 ) O(mn^2) O(mn2),效率太低。所以只有当发现问题时,再处理。
定时检测或者是发现资源利用率低是检测,将死锁的进程都存放到deadlock
死锁进程组。
对于死锁的进程,首先是挑选一个进程进行回滚。但应该选择哪种方式回滚,按优先级或者占用资源多的等等便是一个问题。另一个是如何实现回滚,对于已经修改的文件如何处理,例如银行的客户信息已经被写入,不能随意修改。处理起来比较复杂。
所以,对于许多通用的操作系统,都会采用死锁忽略方法。因为死锁在PC机上出现的概率比较小、死锁忽略的处理代价最小、可以通过重启来解决且重启对PC机的影响较小。
参考:
操作系统实验六 信号量的实现和应用(哈工大李治军)
Linux 0.11下信号量的实现和应用