背景
动机:同一程序顺序执行时是正确的,但并发执行却会发生错误的结果.
- 共享数据的并发访问可能导致数据的不一致
- 维护数据的一致性需要能够保证协作进程顺序执
行的机制
并发执行的程序会出现很多问题,一个简单的例子是生产者-消费者问题。
生产者生产资源(执行count++)的同时消费者消耗(count- -),count的初值是5,理想结果是count=5,但如果同时执行两条语句,对于生产者和消费者,他们的count都是5,那么最后根据恢复的情况不同(生产者后执行完或消费者后执行完),最后会导致count还有4、6两种情况。
这是并发带来的问题,即多个进程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关,称为竞争条件(race condition),为了避免这一个问题,需要确保一段时间只有一个进程能操作变量count
需要避免这些问题,因此需要进程同步(Process synchronization)和协调(coordination)
临界区问题
没有两个进程可同时在临界区内执行。
临界区问题的解答必须满足如下三个要求:
- 互斥:如果进程Pi在其临界区内执行,那么其他进程都不能在其临界区内执行。
- 前进(有空让进):如果没有进程在临界区,且有进程需要进入临界区,那么只有那些不在剩余区内执行的进程可以参与选择,并且这种选择不能无限推迟。
- 有限等待:从一个进程做出进入临界区的请求,直到该请求允许为止,其他进程允许进入其临界区的次数有上限。
在操作系统的临界区问题中,通过抢占内核和非抢占内核两种方式来处理,很显然非抢占内核不会导致竞争条件,而对于抢占内核,则需要精心设计以避免竞争条件。
Peterson算法
Peterson算法是一个基于软件的临界区问题的解答。
Peterson算法适用于两个进程在临界区与剩余区间交替执行。
2个进程共享2个变量:
int turn;
boolean flag[2];
turn表示哪个进程可以进入临界区,flag表示哪个进程想进入临界区。
Peterson算法中的进程Pi的结构
do{
flag[i] = TRUE;
turn = j;
while(flag[j] && turn == j);
//临界区
flag[i] = FALSE;
//剩余区
}while(TRUE);
这个算法其实是两个进程之间互相“谦让”的:如果对方想进,并且turn是对方的,那么自己就进入等待区。
如果两个进程同时想进入临界区,那么会同时改变turn的值,但turn最后只会保持为一个值,也就是竞争是平等的,但如果争不过,那就不争了。
证明这一解答是正确的:
- 互斥成立:turn的值只可能为0或1,不可能同时为两个值。只要Pj在其临界区内,flag[j]= =true和turn= =j就同时成立,所以互斥成立。
- 有空让进:如果进程Pi不在临界区,则flag[i]= =false或者turn= =j,那么P[j]能进入。
- 有限等待:Pi要求进入,flag[i]==true。后面的Pj不可能一直进入,因为Pj执行一次就会让trun=i。
硬件同步
任何临界区问题都需要一个工具——锁,一个进程在进入临界区之前必须得到锁,在其退出临界区时释放锁。
- 对于单处理器,临界区问题的解决很简单:只需要在修改共享变量时禁止中断出现即可(也就是要求非强占),这样能确保当前指令序列的执行不会中断。
- 对于多处理器,问题要复杂的多,禁止中断的操作要传递到所有处理器上会很费时,从而降低系统效率。
许多现代计算机系统提供了特殊硬件指令以允许能原子地(不可中断地)检查和修改字的内容或交换两个字的内容(作为不可中断的指令)。可以使用这些特殊指令来相对地解决临界区问题。
//TestAndSet()指令的定义
boolean TestAndSet(boolean *target){
boolean rv = * target;
*target = TRUE;
return rv;
}
//使用TestAndSet的互斥实现
do{
while(TestAndSetLock(&lock));
//临界区
lock = FALSE:
//剩余区
}while(TRUE);
//Swap() 指令的定义
void Swap(boolean *a,boolean *b){
boolean temp = *a;
*a = *b;
*b = temp;
}
//使用Swap()指令的互斥实现
do{
key = TRUE;
while(key == TRUE)
swap(&lock,&key);
//临界区
lock = FALSE;
//剩余区
}while(TRUE);
上面这些算法解决了互斥,但并没有解决有限等待。
boolean waiting[n];
boolean lock;
//算法实现
do{
waiting[i] = TRUE; //表示进程Pi处于等待获取锁的状态
key = TRUE;
while(waiting[i] && key)
key = TestAndSet(&lock); //如果进程Pi抢到了锁,记录key=false
waiting[i] = FALSE;
//临界区
j=(j+1) % n;
while((j!=i) && !waiting[j] ) //进程Pi处于等待获取锁的状态
j = (j+1)%n;
if(j == i)
lock = FALSE;
else
waiting[j] = FALSE;
//剩余区
}while(TRUE);
该算法满足临界区问题的三个要求:
- 临界区条件1:互斥
第一个进入的进程Pi要等执行了TestAndSet之后才能进入,这时Pi的key=false,其他进程key=true;后续进入的进程Pi,只有在其他进程将waiting[i]设为false之后,才可能进入。 - 临界区条件2:空闲让进
初始,key和所有的waiting[i]都True,lock=false,因此首次执行TestAndSet的进程会进入临界区,当进入临界区的进程Pi执行完临界区操作之后,在退出区,通过While循环扫描当前处于等待状态的进程j (j!=i),如果找到j,那么waiting[j]被设为false,Pj会随后进入临界区如果没有找到j,那么lock被置为false。总之,只要临界区资源空闲,想进入临界区的进程(其waiting[j]=true)都会被放进临界区。 - 临界区条件3:有限等待
每个进程退出临界区的时候,总会按顺序执行一个扫描,这个循环扫描的过程保证一个进程,最多等待n-1次即可进入临界区操作。
信号量
应用层面解决临界区问题:信号量
信号量S是个整数变量,除了初始化外,它只能通过两个标准原子操作:wait()和signal()来访问。即P和V。
wait()就是等待资源的过程,定义可表示为:
wait(S)
{
while(S<=0)
;//no-op
S--;
}
signal()就是释放资源的过程,定义可表示为:
signal(S)
{
S++;
}
在wait()和signal()操作中,对信号量整型值的修改必须不可分地执行,即当一个进程修改信号量时,不能有其他进程同时修改同一信号量的值。
用法
当每个进程需要使用资源时,需要对信号量执行wait()操作(减少信号量的计数)。
当进程释放资源时,需要对该信号量执行signal()操作(增加信号量的计数)。
可以用信号量来解决同步问题,有两个并发进程:P1有语句S1而P2有语句S2,假设要求只有在S1执行完之后才执行S2。
实现:让P1和P2共享一个共同信号量synch,且将其初始化为0,在进程P1中插入语句:
S1;
signal(synch);
进程P2中插入语句:
wait(synch);
S2;
因为初始化synch为0,P2只有在P1调用signal(synch),即(S1)之后,才会执行S2。
实现
信号量的主要缺点是要求忙等待。这种类型的信号量也称为自旋锁(spinlock),这是因为进程在其等待锁的时还在运行。
(自旋锁有其优点,进程在等待锁时不进行上下文切换,而上下文切换可能需要花费相当长的时间。因此如果锁占用的时间短,那么锁就有用了,自旋锁常用于多处理器系统中,这样一个线程在一个处理器自旋时,另一线程可在另一个处理器上在其临界区内执行).
为克服这一缺点,修改wait()和signal()的定义,信号量值不为正时,不是忙等而是阻塞自己,阻塞操作将一个进程放入到与信号量相关的等待队列中,并将该进程的状态切换成等待状态,接着,控制转到CPU调度程序,以选择另一个进程来执行,从而使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的执行。
在具有忙等的信号量经典定义下,信号量的值绝对不能为负数,但是本实现可能造成信号量为负值。如果信号量为负值,那么其绝对值就是等待该信号量的进程的个数。
死锁与饥饿
死锁:两个或多个进程无限地等待一个事件,而这些事件只能由这些等待进程之一来产生。这里的事件是signal()操作地执行。
例如,一个由P0和P1组成的系统,每个都访问共享的信号量S和Q,S和Q初值均为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):即进程在信号量内无限期等待。
经典同步问题
有限缓冲问题
信号量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);
哲学家进餐问题
有五个哲学家,他们的生活方式是交替地进行思考和进餐。他们共用一张圆桌,分别坐在五张椅子上。在圆桌上有五个碗和五支筷子,平时一个哲学家进行思考,饥饿时便试图取用其左、右最靠近他的筷子,只有在他拿到两支筷子时才能进餐。进餐完毕,放下筷子又继续思考。
共享数据为: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个哲学家同时坐在桌子上;
②只有两只筷子都可用时才允许一个哲学家拿起它们(他必须在临界区内拿起两只筷子);
③使用非对称解决方法,即奇数哲学家先拿起左边的筷子,接着拿起右边的筷子,而偶数哲学家先拿起右边的筷子,接着拿起左边的筷子。