第六章:进程同步【临界区,Peterson算法、互斥锁、信号量、经典同步问题、管程】

目录

6.1 背景

6.2 临界区问题

6.3 Peterson算法

6.4 硬件同步

6.5 互斥锁

6.6 信号量

6.6.1 信号量的使用

6.6.2 信号量的实现

6.6.3 死锁与饥饿

6.7 经典同步问题

6.7.1 有限缓存问题—生产者消费者模型

6.7.2 读者-作者问题

6.7.3 哲学家进餐问题

6.8 管程


6.1 背景

互相协作的进程之间有共享的数据,于是这里就有一个并发情况下,如何确保有序操作这些数据、维护一致性的问题,即进程同步。

多个进程并发访问和操作同一数据且执行结果与访问发生的特定顺序有关,称之为竞争条件。为了防止竞争条件,需要确保一次只有一个进程可以操作变量。

 

6.2 临界区问题

假设某个系统有n个进程{p0,p1...},每个进程有一个代码段称为临界区,在该区中进程可能改变共同变量、更新一个表或写一个文件等。这种系统的重要特征是当一个进程进入临界区,其他进程不允许在他们的临界区内执行,即没有两个进程可同时在临界区内执行。

临界资源每次只能被一个进程访问。而临界区则是能够访问临界资源的代码片段

临界区问题是设计一个以便进程协作的协议。每个进程必须请求允许进入其临界区。实现请求的代码段称为进入区,临界区之后可以有退出区,其他代码段成为剩余区。

一个典型进程的通用结构:

do{
    进入区
        临界区
    退出区
        剩余区
}while(TRUE)

临界区问题的解答必须满足三项要求:

  • 互斥:如果进程Pi在其临界区内执行,那么其他进程都不能在其临界区内执行;
  • 前进:如果没有进程在其临界区内执行且有进程需进入临界区,那么只有那么不在剩余区内执行的进程可参加选择,以确定谁能下一个进入临界区;
  • 有限等待: 从一个进程做出进入临界区的请求,直到该请求允许为止,其他进程允许进入其临界区内的次数有上限。

 

一个操作系统,在某个时刻,可同时存在有多个处于内核模式的活动进程,因此实现操作系统的内核代码,会存在竞争条件。内核开发人员有必要确保其操作系统不会产生竞争条件。

有两种方法用于处理操作系统内的临界区问题:抢占式内核与非抢占式内核。抢占式内核允许处于内核模式的进程被抢占,非抢占式内核不允许内核模式的进程被抢占。

非抢占式内核的内核数据结构基本不会导致竞争条件,因为在任一时间点只有一个进程处于内核模式。但是抢占式内核响应更快,更适合实时编程,对于抢占式内核需要认真设计以确保其内核数据结构不会导致竞争条件。

 

6.3 Peterson算法

Peterson算法是一种经典的基于软件的临界区问题算法,可能现代计算机体系架构基本机器语言有些不同,不能确保正确运行。

Peterson算法适用于两个进程在临界区与剩余区间交替执行,为了方便,当使用Pi时,Pj来标示另一个进程,即j=i−1。Peterson算法需要在两个进程之间共享两个数据项:

int turn;  //变量turn表示哪个进程可以进入其临界区,即如果turn==i,那么进程Pi允许在其临界区内执行。
bool flag[2];  //数组flag表示哪个进程想要进入临界区,如果flag[i]为true,即Pi想进入其临界区。

进程Pi的Peterson算法

do{
 
   flag[i]=true;
   turn=j;
   while( flag[j] && turn==j ); //一直等
 
       临界区
 
   flag[i]=false;
 
       剩余区
 
}while (true)

分析:
当不存在并发时,进程 Pi 执行时,flag[ i ]=true; turn= j;但是while循环的flag[ j ]不满足,不会阻塞,进程Pi直接进入临界区。使用完资源,进程将释放资源,即:flag[ i ]=false;
当进程 Pi,Pj 并发执行,在进程Pi中flag[0]=true; turn=1; 在Pj中,flag[1]=true;turn=0;
  此时flag[0]和flag[1]均为true,表示进程Pi,Pj都想进入临界区,turn的值决定了哪个进程可以进入临界区。turn虽然被写了2次,但是只会保留最后的值,只能是0或1的一个。
  假设turn=0;那么对于进程 Pi 来说,turn==j的条件不满足,不阻塞,进入临界区;对于进程Pj来说,循环条件满足,阻塞。
  阻塞持续到什么时候? 进程Pi退出临界区时会将资源占用释放。这时进程j便可以进入临界区了。

总结:
  Peterson算法实际上是一种谦让的过程,进程Pi准备好后,不立即执行,而是先让Pj执行。Pj也同样如此。唯一的变量turn决定了谁先执行。

 

6.4 硬件同步

通过要求临界区用锁来防护,就可以避免竞争条件,即一个进程在进入临界区之前必须得到锁,而其退出临界区时释放锁。

do{
    请求锁
        临界区
    释放锁
        剩余区
}while(TRUE)

硬件特性能简化编程任务且提高系统效率。

对于单处理器环境,临界区问题可简单地加以解决:在修改共享变量时要禁止中断出现。这样其他指令不可能执行,所以共享变量也不会被意外修改。这种方法通常为抢占式内核所采用。

在多处理器环境下,这种解决方法是不可行的,低效且影响系统时钟。

特殊硬件指令以允许能原子地(不可中断的)检查和修改字的内容或交换两个字的内容。如TestAndSet(),当两个指令同时执行在不同的CPU上,那么它们会按任意顺序来顺序执行。

 

6.5 互斥锁

一个进程在进入临界区时应得到锁,在退出临界区时释放锁。

每个互斥锁都有一个布尔变量available,表示锁是否可用。如果锁是可用的,那么调用函数获得锁 acquire() 会成功,并且锁不再可用。当一个进程试图获取不可用锁时,他会阻塞,直到锁被释放。

acquire(){  //获得锁
    while (!available) ; //忙等待
    available=false;
}

release(){ //释放锁
    available=true;
}


do {

    acquire(); //获得锁

        临界区;

    release(); //释放锁

        剩余区; 

} while (true);

从代码中可以看出,当有一个进程在临界区中,任何其他进程在进入临界区时必须连续循环地调用acquire(),也就是忙等待。这种类型的互斥锁也称为自旋锁,意思是进程在不停地旋转,以等待锁变的可用。

忙等待虽然浪费了CPU周期,但是其不需要上下文切换,所以当使用锁的时间较短时,就采用自旋锁。

 

6.6 信号量

应用层解决临界区问题:信号量

信号量S是个整数变量,除了初始化外,它只能通过两个标准原子操作:wait()和signal()来访问。即P和V

wait()表示等待资源,signal()表示释放资源,其定义可表示为:

wait(S){
  while(S<=0) ;  //忙等待
  S--;
}

signal(S){
  S++;
}

在wait()和signal()操作中,对信号量整型值的修改必须不可分地执行。即当一个进程修改信号量值时,不能有其他进程同时修改同一信号量的值。

6.6.1 信号量的使用

通常操作系统区分计数信号量二进制信号量。计数信号量的值域不受限制,而二进制信号量的值只能为0或1。所以二进制信号量类似于6.5所讲的互斥锁。

1. 计数信号量可以用于控制访问具有多个实例的某种资源。
信号量初值为可用资源的数量。
当每个进程需要使用资源时,需要对该信号量执行wait()操作。当进程释放资源时,需要对该信号执行signal()操作。
当信号量的计数值为0时,表示所有资源都在使用中,之后,需要使用资源的进程将会阻塞,直到计数值大于0。

2. 也可以使用信号量来解决各种同步问题。
例如有两个并发运行的进程:P1和P2。P1中有语句S1,P2中有语句S2。要求先执行P1的S1语句,然后再执行P2的S2语句。
我们可以让P1和P2共享同一个信号量s,并初始化为0。

  • 在进程P1的语句S1后插入signal(s);
  • 在进程P2的语句S2前插入wait(s):

因为初始化s为0,P2只有在P1调用signal(synch),执行完S1之后,才会执行S2,达到要求。

6.6.2 信号量的实现

6.5中的互斥锁有忙等待的缺点,为克服这一缺点,在信号量中,修改wait()和signal()的定义:当一个进程执行wait()操作,并且发现信号量值不为正时,它必须等待,但不再是忙等待而是阻塞自己。阻塞操作将一个进程放入到与信号量相关的等待队列中,并将该进程的状态切换成等待状态,接着,控制转到CPU调度程序,以选择另一个进程来执行,从而使CPU占用率变高。

因等待信号量s而被阻塞的进程,在其他进程执行signal()操作后应被重新执行,进程的重新执行是通过wakeup()操作实现的,它将进程从等待状态切换到就绪状态。然后进程被添加到 就绪队列中。

为了实现这样的信号量,其定义如下:

typedef struct{
    int value;                  //记录了这个信号量的值   
    struct process *list;       //储存正在等待这个信号量的进程(PCB链表指针)
}semaphore;

每个信号量都有一个整型值value和一个进程链表list。当一个进程必须等待信号量时,就加入到进程链表上,操作signal()会从等待进程链表中取一个进程并唤醒。

现在的wait()和signal()定义如下:

wait(semaphore *S){
 	S->value--;
 	if(S->value<0){                //没有资源
  		add this process to S->list;      //把这个进程加入到等待队列  
		block();                          //阻塞这个进程
	}
}

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()可原子地执行。

这里要承认,这种定义的操作wait()和signal()并没有完全取消忙等待。我们只是将 忙等待 从 进入区 移到 临界区,因为临界区往往比较短,将忙等待限制在操作wait()和signal()的临界区内,可以使得忙等待很少发生,所需时间很短。

6.6.3 死锁与饥饿

具有等待队列的信号量的实现可能会导致这样的情况:两个或多个进程无限地等待一个事件,而该事件只能由这些等待进程之一来产生。这里的事件是signal()操作的执行。当出现这样的状态时,这些进程就称为死锁

例如,一个由P1和P2组成的系统,每个都访问共享的信号量S和Q,S和Q初值均为1。

假设P0执行wait(S),接着P1执行wait(Q),P0再执行wait(Q)时,必须等待,直到P1执行signal(Q),而此时P1也在等待P0执行signal(S),两个操作都不能进行,P0和P1就死锁了。

与死锁相关的另一个问题是无限期阻塞或饥饿:即进程在信号量内无限期等待。比如对信号量有关的链表进行先进后出的顺序来增加和删除,或者按优先级等,都可能发生无限阻塞(这里和CPU调度算法的饥饿类似)。

 

6.7 经典同步问题

6.7.1 有限缓存问题—生产者消费者模型

假设缓冲池有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);

6.7.2 读者-作者问题

只读数据库的进程称为读者;更新(读和写)数据库的称为作者。

第一读者-作者问题:除非已有一个作者已经获得权限,否则读者就不应等待。

第二读者-作者问题:要求一旦作者就绪,那么作者会尽可能快得执行其写操作。

对于这两个问题的解答可能导致饥饿问题。对第一种情况,作者可能饥饿;对第二种情况,读者可能饥饿。

对于第一读者-作者问题的解决:

读者进程共享以下数据结构:

semaphore wrt=1;
semaphore mutex=1; 
int readcount=0;

信号量mutex用于确保在更新变量readcount时的互斥。变量readcount用来跟踪有多少进程正在读对象。
信号量wrt为读者和作者进程所共有。信号量wrt供作者作为互斥信号量;它也为第一个进入临界区和最后一个离开临界区的读者所使用,而不被其他读者所使用。

进一步解释wrt:多个进程执行读操作,那么第一个读者要占用wrt锁,表示现在正在读,不能再写数据了。进程读完之后,一个个离开,最后一个读者读完之后需要释放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);

将读者-作者问题的解答进行抽象,推广为读写锁。

读写锁在以下情况下最为有用:

  • 当可以区分哪些进程只需要读共享数据,哪些进程只需要写共享数据;
  • 当读者进程数比写进程多时。因为读写锁的建立开销通常大于信号量和互斥锁,但是这一开销可以通过允许多个读者的并发程度的增加来弥补。

6.7.3 哲学家进餐问题

5个哲学家吃饭,他们会拿起与他相近的两只筷子,一个哲学家一次只能拿起一只筷子,同时有两只筷子时就能吃,吃完会放下两只筷子。

其典型特点:在多个进程之间分配多个资源,而且不会出现死锁和饥饿 。

一种简单的方法,每只筷子都用一个信号量来表示。一个哲学家通过执行wait()操作试图获取相应的筷子,他会通过执行signal()操作以释放相应的筷子。

哲学家i的结构:

//共享数据为:
semaphore chopstick[5];//其中所有chopstick的元素初始化为1。

do{
	wait(chopstick[i]);
 	wait(chopstick[(i+1)%5]);
  		…;
  		//eat
  		…;
  	signal(chopstick[i]);
  	signal(chopstick[(i+1)%5]);
 		…;
  		//think
  		…;
}while(TRUE);

但这种方法会发生死锁,例如,所有哲学家同时饥饿,且同时拿起左边的筷子。

多种可以解决死锁的方法:
①最多只允许4个哲学家同时坐在桌子上;
②只有两只筷子都可用时才允许一个哲学家拿起它们(他必须在临界区内拿起两只筷子);
③使用非对称解决方法,即奇数哲学家先拿起左边的筷子,接着拿起右边的筷子,而偶数哲学家先拿起右边的筷子,接着拿起左边的筷子。

 

6.8 管程

管程在功能上和信号量及PV操作类似,属于一种进程同步互斥工具,但是具有与信号量及PV操作不同的属性。

信号量机制的缺点:进程自备同步操作,P(S)和V(S)操作大量分散在各个进程中,不易管理,易发生死锁。

管程特点:管程封装了同步操作,对进程隐蔽了同步细节,简化了同步功能的调用界面。用户编写并发程序如同编写顺序(串行)程序。

引入管程机制的目的:

  • 把分散在各进程中的临界区集中起来进行管理;
  • 防止进程有意或无意的违法同步操作;
  • 便于用高级语言来书写程序,也便于程序正确性验证。

 

  • 10
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Peterson算法是一种经典临界区管理算法,可以确保在多个进程或线程同时访问共享资源时,只有一个进程或线程可以进入临界区。下面分别介绍两种实现Peterson算法的尝试,并进行分析。 ## 尝试一 ``` #define TRUE 1 #define FALSE 0 #define N 2 volatile int turn; volatile int interested[N]; void enter_region(int process) { int other; other = 1 - process; interested[process] = TRUE; turn = process; while (turn == process && interested[other] == TRUE) {} } void leave_region(int process) { interested[process] = FALSE; } ``` 这种实现尝试使用两个变量`turn`和`interested`来实现临界区的管理。变量`turn`指示当前可以进入临界区的进程号,变量`interested`用于指示进程是否有兴趣进入临界区。当进程`process`想要进入临界区时,它会将`interested[process]`设置为TRUE,并将`turn`设置为`process`。然后它会在while循环中等待,直到`turn`变为`process`且另一个进程不再对进入临界区感兴趣(即`interested[other]`为FALSE)。当进程`process`离开临界区时,它将`interested[process]`设置为FALSE,以允许其他进程进入临界区。 然而,这种实现可能会出现饥饿情况。如果两个进程都对进入临界区感兴趣,但是`turn`被设置为另一个进程,那么当前进程将一直在等待,直到另一个进程不再对进入临界区感兴趣。 ## 尝试二 ``` #define TRUE 1 #define FALSE 0 #define N 2 volatile int turn; volatile int interested[N]; void enter_region(int process) { int other; other = 1 - process; interested[process] = TRUE; turn = process; if (turn == process && interested[other] == TRUE) { interested[process] = FALSE; while (turn == process && interested[other] == TRUE) {} interested[process] = TRUE; } } void leave_region(int process) { interested[process] = FALSE; turn = 1 - process; } ``` 这种实现尝试通过在进入临界区时检查`turn`和`interested[other]`的值来解决饥饿问题。当进程`process`想要进入临界区时,它会将`interested[process]`设置为TRUE,并将`turn`设置为`process`。然后,如果`turn`等于`process`且另一个进程对进入临界区感兴趣,当前进程将等待,直到另一个进程不再对进入临界区感兴趣。如果进程不需要等待,则它可以直接进入临界区。当进程`process`离开临界区时,它将`interested[process]`设置为FALSE,并将`turn`设置为另一个进程。 这种实现可以避免饥饿情况,但是可能会出现死锁问题。如果两个进程交替进入和离开临界区,但是在某个时刻它们同时设置了`interested`为TRUE,那么它们将永远等待对方离开临界区。 因此,为了避免死锁和饥饿问题,需要对Peterson算法进行更精细的设计和实现。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值