互相协作的进程之间有共享的数据,于是这里就有一个并发情况下,如何确保有序操作这些数据、维护一致性的问题,即进程同步。
从底层到高级应用,同步机制依次有临界区、信号量、管程、原子事务。
多个进程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关,称之为竞争条件(race condition)。
每个进程有一个代码段称为临界区(critical section),在该区中进程可能改变共同变量、更新一个表或写一个文件等。这种系统的重要特征是当一个进程进入临界区,没有其他进程可被允许在临界区内执行,即没有两个进程可同时在临界区内执行。
临界区问题(critical-section problem)是设计一个以便进程协作的协议。每个进程必须请求允许进入其临界区。实现请求的代码段称为进入区(entry section),临界区之后可有退出区(exit section),其他代码段成为剩余区(remainder section)。
一个典型进程Pi的通用结构:
do{
进入区
临界区
退出区
剩余区
}while(TRUE)
临界区问题的解答必须满足三项要求:
(1)互斥(mutual exclusion):
如果进程Pi在其临界区内执行,那么其他进程都不能在其临界区内执行;
(2)前进(progress):
如果没有进程在其临界区内执行且有进程需进入临界区,那么只有那么不在剩余区内执行的进程可参加选择,以确定谁能下一个进入临界区,且这种选择不能无限推迟;
(3)有限等待(bounded waiting):
从一个进程做出进入临界区的请求,直到该请求允许为止,其他进程允许进入其临界区内的次数有上限。
一个操作系统,在某个时刻,可同时存在有多个处于内核模式的活动进程,因此实现操作系统的内核代码,会存在竞争条件。内核开发人员有必要确保其操作系统不会产生竞争条件。
有两种方法用于处理操作系统内的临界区问题:
抢占内核(preemptive kernel)与非抢占内核(nonpreemptive kernel):
抢占内核允许处于内核模式的进程被抢占。
非抢占内核不允许内核模式的进程被抢占。
非抢占内核的内核数据结构从根本上不会导致竞争条件,对于抢占内核需要认真设计以确保其内核数据结构不会导致竞争条件。
但抢占内核更受欢迎,因为抢占内核更适合实时编程,因为它能允许实时进程抢占处于内核模式运行的其他进程。再者,抢占内核的响应更快,因为处于内核模式的进程在释放CPU之前不会运行过久。
Peterson算法是一种经典的基于软件的临界区问题算法,不能确保正确运行。
Peterson算法适用于两个进程在临界区与剩余区间交替执行,为了方便,当使用Pi时,Pj来标示另一个进程,即j == i - 1。Peterson算法需要在两个进程之间共享两个数据项:
int turn;
boolean flag[2];
变量turn表示哪个进程可以进入其临界区,即如果turn==i,那么进程Pi允许在其临界区内执行。
数组flag表示哪个进程想要进入临界区,如果flag[i]为true,即Pi想进入其临界区。
//进程Pi的Peterson算法
do{
flag[i]=TRUE;
turn=j;
while(flag[j]&&turn==j);
临界区
flag[i]=FALSE;
剩余区
}while(TRUE)
可以证明,满足三项要求。
通过要求临界区用锁来防护,就可以避免竞争条件,即一个进程在进入临界区之前必须得到锁,而其退出临界区时释放锁。
do{
请求锁
临界区
释放锁
剩余区
}while(TRUE)
硬件特性能简化编程任务且提高系统效率。
对于单处理器环境,临界区问题可简单地加以解决:在修改共享变量时要禁止中断出现。这样其他指令不可能执行,所以共享变量也不会被意外修改。这种方法通常为抢占式内核所采用。
在多处理器环境下,这种解决方法是不可行的,低效且影响系统时钟。
特殊硬件指令以允许能原子地(不可中断的)检查和修改字的内容或交换两个字的内容。如TestAndSet(),当两个指令同时执行在不同的CPU上,那么它们会按任意顺序来顺序执行。
TestAndSet指令定义:
boolean TestAndSet(boolean *target){
boolean rv=*target;
*target=TRUE;
return rv;
}
使用TestAndSet的互斥实现,声明一个Boolean变量lock,初始化为false
do{
while(TestAndSetLock(&lock))
;//do nothing
//critical section
lock=FALSE;
//remainder section
}while(TRUE);
使用TestAndSet的有限等待互斥:任何等待进入临界区的进程只需要等待n-1次。
boolean waiting[i] = TRUE;
boolean lock;
初始化为false
do{
waiting[i]=TRUE;
key=TRUE;
while(waiting[i]&&key)
key=TestAndSet(&lock);
waiting[i]=FALSE;
//critical section
j=(i+1)%n;
while((j!=i)&&!waiting[j])
j=(j+1)%n;
if(j==i)
lock=FALSE;
else
waiting[j]=FALSE
//remainder section
}while(TRUE);
Swap指令的定义:
void Swap(boolean *a,boolean *b){
booleab temp=*a;
*a=*b;
*b=temp;
}
使用Swap的互斥实现:key为每个进程局部变量,lock为全局变量,初始化为false
do{ key=TRUE;
while(key==TRUE)
Swap(&lock,&key);
//critical section
lock=FALSE;
//remainder section
}while(TRUE);
然而,对于硬件设计人员,在多处理器上实现原子指令TestAndSet并不简单。
应用层面解决临界区问题:信号量
信号量S是个整数变量,除了初始化外,它只能通过两个标准原子操作:wait()和signal()来访问。即P和V。
wait()的定义可表示为:
wait(S){
while(S<=0)
;//no-op
S--;
}
signal()的定义可表示为:
signal(S){
S++;
}
在wait()和signal()操作中,对信号量整型值的修改必须不可分地执行。
通常操作系统区分计数信号量和二进制信号量。计数信号量的值域不受限制,而二进制信号量的值只能为0或1,有的系统,。由于二进制信号量是互斥的,因而可以将其应用于处理多进程的临界区问题。
使用信号量的互斥实现:n个进程共享信号量mutex,初始值1
do{
wait(mutex);
//critical section
signal(mutex);
//remainder section
}while(TRUE);
计数信号量可以用来控制访问具有若干个实例的某种资源。该信号量初始化为可用资源的数量。当每个进程需要使用资源时,需要对该信号量执行wait()操作。当进程释放资源时,需要对该信号执行signal()操作。
可以用信号量来解决各种同步问题。如先执行Pi的S1语句,然后再执行Pj的S2语句,可以通向一个信号量,初始化为0,然后执行S1完,执行signal(),在执行S2前,执行wait()。
信号量的主要缺点是要求忙等待(busy waiting)。即在进入代码段中连续地循环。忙等待浪费了CPU时钟,这种类型的信号量也称为自旋锁(spinlock),这是因为进程在其等待锁的时还在运行(自旋锁有其优点,进程在等待锁时不进行上下文切换,而上下文切换可能需要花费相当长的时间。因此如果锁占用的时间短,那么锁就有用了,自旋锁常用于多处理器系统中,这样一个线程在一个处理器自旋时,另一线程可在另一个处理器上在其临界区内执行)
为克服这一缺点,修改wait()和signal()的定义,信号量值不为正时,不是忙等而是阻塞自己,阻塞操作将一个进程放入到与信号量相关的等待队列中,并将该进程的状态切换成等待状态,接着,控制转到CPU调度程序,以选择另一个进程来执行。
被阻塞在等待信号S上的进程,可以在其他进程执行signal()的时候操作之后重新被执行,该进程的重新执行是通过wakeup()操作来进行的将进程从等待状态切换到就绪状态。接着进程被放到就绪队列中。
因而将信号量定义为如下:
typedef struct{
int value; //记录了这个信号量的值
struct process *list; //储存正在等待这个信号量的进程(PCB链表指针)
}semaphore;
每个信号量都有一个整型值和一个进程链表,当一个进程必须等待信号量时,就加入到进程链表上,操作signal()会从等待进程链表中取一个进程以唤醒。
wait()实现:
wait(semaphore *S){
S->value--;
if(S->value <0){ //没有资源
add this process to S->list; //进入等待队列
block(); //堵塞
}
}
signal()实现:
signal(semaphore *S){
S->value++;
if(S->value<=0){ //上面++后,S仍然还<=0,说明资源供不应求,等待者还有很多,于是唤醒等待队列中的一个
remove a process P from S->list;
wakeup(P); //切换到就绪状态
}
}
操作block()挂起调用他的进程。
操作wakeup(P)重新启动阻塞进程P的执行。
这两个操作都是由操作系统作为基本系统调用来提供的。
在具有忙等的信号量经典定义下,信号量的值绝对不能为负数,但是本实现可能造成信号量为负值。如果信号量为负值,那么其绝对值就是等待该信号量的进程的个数。
等待进程的链表可以利用进程控制块PCB中的一个链接域来加以轻松实现。即每个信号量包括一个整型值和一个PCB链表的指针。
信号量的关键之处是它们原子的执行。必须确保没有两个进程能同时对一个信号量进行操作,在单处理器情况下,可以在执行wait()和signal()的时候简单的关闭中断,保证只有当前进程进行。
多处理器下,若禁止所有CPU的中断,则会严重影响性能,SMP系统必须提供其他加锁技术(如自旋锁),以确保wait()与signal()可原子地执行。
具有等待队列的信号量的实现可能会导致这样的情况:
两个或多个进程无限地等待一个事件,而该事件只能由这些等待进程之一来产生。这里的事件是signal()操作的执行。当出现这样的状态时,这些进程就称为死锁(deadlocked)。
例如,一个由P1 P2 组成的系统,每个都访问共享的信号量S和Q,SQ初值均为1:
P0:
wait(S);
wait(Q);
//......
signal(S);
signal(Q);
P1:
wait(Q);
wait(S);
//......
signal(Q);
signal(S);
假设,P0执行wait(S),接着P1执行wait(Q),P0再执行wait(Q)时,必须等待,直到P1执行signal(Q),而此时P1也在等待P0执行signal(S),两个操作都不能进行,P0和P1就死锁了。
与死锁相关的另一个问题是无限期阻塞(indefinite blocking)或饥饿(starvation):即进程在信号量内无限期等待。
举个例子来理解死锁与饥饿的区别:
指的是两个或者两个以上的进程相互竞争系统资源,导致进程永久阻塞。
例如:
1、桌子上有慢慢一桌子的美食,但是只有一双筷子。
2、甲拿了一根,然后在找另一根。
3、乙拿了一根,然后也在找另一根。
4、因为他们都掌握了对方必需的资源,导致最后他们俩谁都吃不到美食。
指的是等待时间已经影响到进程运行,此时成为饥饿现象。如果等待时间过长,导致进程使命已经没有意义时,称之为“饿死”。
例如:
1、小明要告诉妈妈明天开家长会。
2、小明妈妈因为工作太忙,在公司加班,没有回家。
3、于是第二天,小明的妈妈就错过了家长会。(“饿死”)
4、其实小明的妈妈没有出现“死锁”。只是小明的优先级过低,不如工作重要。
假设缓冲池有n个缓冲项,每个缓冲项能存在一个数据项。信号量mutex提供了对缓冲池访问的互斥要求,并初始化为1。信号量empty和full分别用来表示空缓冲项和满缓冲项的个数,信号量empty初始化为n;而信号量full初始化为0。
生产者进程结构:
do{
…
//produce an item in next p
…
wait(empty);
wait(mutex);
…
//add next p to buffer
…
signal(mutex);
signal(full);
}while(TRUE);
消费者进程结构:
do{
wait(full);
wait(mutex);
…
//remove an item from buffer to next c
…
signal(mutex);
signal(empty);
…
//consume the item in next c
…
}while(TRUE);
只读数据库的进程称为读者;更新(读和写)数据库的称为写者。
第一读者-写者问题:要求没有读者需要保持等待除非已有一个写者已获得允许已使用共享数据库。换句话说,没有读者会因为一个写者在等待而会等待其他读者的完成。
第二读者-写者问题:要求一旦写者就绪,那么写者会尽可能快得执行其写操作。换句话说,如果一个写者等待访问对象,那么不会有新读者开始读操作。
对于这两个问题的解答可能导致饥饿问题。对第一种情况,写者可能饥饿;对第二种情况,读者可能饥饿。
对于第一读者-写者问题的解决:
读者进程共享以下数据结构:
semaphore mutex, wrt;
int readcount;
信号量mutex和wrt初始化为1,readcount初始化为0,信号量wrt为读者和写者进程所共有。信号量mutex用于确保在更新变量readcount时的互斥。变量readcount用来跟踪有多少进程正在读对象。信号量wrt供写者作为互斥信号量,它为第一个进入临界区和最后一个离开临界区的读者所使用,而不被其他读者所使用。
写者进程结构:
do{
wait(wrt);
…;
//writing is performed
…;
signal(wrt);
}while(TRUE);
读者进程结构:
do{
wait(mutex);
readcount++;
if(readcount==1)
wait(wrt);
signal(mutex);
…;
//reading is performed
…;
wait(mutex);
readcount--;
if(readcount==0)
signal(wrt);
signal(mutex);
}while(TRUE);
推广为读写锁。
在以下情况下最为有用:
一是,当可以区分哪些进程只需要读共享数据,哪些进程只需要写共享数据;
二是,当读者进程数比写进程多时。
拿起与他相近的两只筷子,一个哲学家一次只能拿起一只筷子,同时有两只筷子时,就能吃,吃完,会放下两只筷子。
一种简单的方法,每只筷子都用一个信号量来表示。一个哲学家通过执行wait()操作试图获取相应的筷子,他会通过执行signal()操作以释放相应的筷子。
共享数据为:semaphore chopstick[5];其中所有chopstick的元素初始化为1。
哲学家i的结构:
do{
wait(chopstick[i]);
wait(chopstick[(i+1)%5]);
…;
//eat
…;
signal(chopstick[i]);
signal(chopstick[(i+1)%5]);
…;
//think
…;
}while(TRUE);
但这种方法会发生死锁,例如,所有哲学家同时饥饿,且同时拿起左边的筷子。
多种可以解决死锁的方法:
①最多只允许4个哲学家同时坐在桌子上;
②只有两只筷子都可用时才允许一个哲学家拿起它们(他必须在临界区内拿起两只筷子);
③使用非对称解决方法,即技术哲学家先拿起左边的筷子,接着拿起右边的筷子,而偶数哲学家先拿起右边的筷子,接着拿起左边的筷子。