操作系统(六) -- 多进程合作与信号量

文章目录

    • 前言
    • 多进程合作实例:
      • 多进程共同完成一个任务的实例
      • 生产者-消费者实例
    • 信号量的提出
    • 信号量
      • 用信号量解决生产者消费者问题:
    • 信号量的临界区保护
      • 信号量为什么要进行保护呢?
    • 临界区(Critical Section)
      • 临界区代码的保护原则:
      • 临界区保护的直观想法:
    • 两个进程之间的调度
      • 进入临界区的第一个尝试 - 轮换法:
      • 进入临界区的又一个尝试 - 标记法:
      • Peterson算法:
    • 多个进程调度
      • 面包店算法:
      • 通过开关中断来实现
      • 硬件原子指令法
    • 参考资料

前言

多进程图像除了交替执行向前推进之外,还存在进程之间的合作;进程同步就是让这种进程之间的合作变得合理有序。如何实现合理有序要靠信号量。这篇文章包含一下常用多进程调度算法的理解,比如Peterson算法、面包店算法等。

多进程合作实例:

多进程共同完成一个任务的实例

司机:
while(true)
{
	启动车辆;
	正常运行;
	到站停车;
}

售票员
while(true)
{
	关门;
	售票;
	开门;
}

上面是司机和售票员这两个进程的内容。但是这两个进程的执行需要协调配合并不能随意执行。比如说司机启动车辆之前售票员必须关好门,不能在开门的时候启动车辆。售票员开门必须是车辆停车的时候,不行在司机车辆正常运行的时候打开车门。

生产者-消费者实例

buffer是一块公共缓冲区,BUFFER_SIZE是这块缓冲区的大小

生产者进程:
while (true)
{
	while(counter==BUFFER_SIZE)
  		;
	buffer[in]=item;
	in = (in+1)%BUFFER_SIZE;
	counter++;
}

消费者实例:
while (true)
{
	while(counter==0)
		;
	item=buffer[out];
	out=(out+1) % BUFFER_SIZE;
	counter--;
}

从生产者进程可以看出,当counter==BUFFER_SIZE时,不能再生产了,即停在这里,只有当消费者进程运行一次,将counter减一时才能运行生产者进程。因为缓冲区已经满了,肯定要消耗一块之后才能继续生产。消费者进程同理。

无论是司机与售票员实例还是生产者消费者实例,两个进程之间的合作都需要一个等待的过程,在条件未满足的情况下,另外一个进程不能执行; 需要等待另外一个进程给该进程发信号才能继续执行。

信号量的提出

生产者进程
while (true)
{
	while(counter==BUFFER_SIZE)
    	缓存区满,生产者要停;
  	buffer[in]=item;
  	in=(in+1) % BUFFER_SIZE;
	counter++;	// 发信号让消费者走
}

消费者进程
while (true)
{
	while(counter==o}
    	缓存区空,消费者要停;
  	item=buffer[out];
	out=(out+1) % BUFFER_SIZE;
	counter--;	// 发信号让生产者走
}

看起来好像没问题,但是如果只是上述这样简单的发信号并不能解决全部问题,为什么呢? 看下面

生产者进程
while (true)
{
	if(counter==BUFFER_ SIZE)
        sleep():	// 当缓冲区满了之后如果再来一个生产者进程,进入sleep
    counter ++;
    if(counter==1) 	// 当counter==1,说明之前缓冲区里面是没有资源的。唤醒消费者进程
    	wakeup(消费者);
}

消费者进程
while (true)
{
	if(counter==0)	// 如果缓冲区里面没有资源,再来一个消费者,进入sleep()
		sleep();
	counter --;
	if(counter == BUFFER_ SIZE-1)	// 说明之前缓冲区是满,则唤醒生产者进程
		wakeup(生产者);
}

看起来好像没什么问题,当缓冲区满了之后再来一个生产者进程,该进程进入sleep(),如果缓冲区空了,再来一个消费者进程,该进程进入sleep();如果缓冲区是满的,运行一个消费者进程将资源消耗了一个,那么可以从sleep()队列中唤醒一个生产者进程,反之如是。

看另外一种情况

  1. 缓冲区满以后生产者P1生产一个item放入,会sleep
  2. 又一个生产者P2生产一个item放入,会sleep
  3. 消费者C执行1次循环,counter==BUFFER_ SIZE-1,发信号给P1, P1 wakeup
  4. 消费者C再执行1次循环,counter==BUFFER_ SIZE-1,不能wakeupP2,P2不能被唤醒。

这种情况下信号的处理就不够了,因为信号是一个二值量,只有“睡眠”和“唤醒”两种状态,不能记录有多少个进程在sleep,如果采用另外一种方式,用一个值来记录有多少个进程处于sleep。如下:

  1. 缓冲区满,P1执行,P1 sleep,记录一个进程在等待。
  2. P2执行,P2 sleep,记录两个进程在等待。
  3. C执行一个循环,发现两个进程在等待,wakeup(P1)。
  4. C再次执行一个循环,发现还有一个进程,wakeup(P2)。

这个能记录有多少个进程处于sleep变量的值就是信号量,它不再是一个信号了,而是一个量;根据这个量得到信号。使用信号量,假设为信号量为sem

  1. 缓冲区满,P1执行, P1 sleep, 此时 sem=-1
  2. P2执行,P2 sleep, 此时 sem=-2
  3. C执行一次循环, wakeup(P1),此时 sem=-1
  4. C再次执行一次循环,wakeup(P2),此时 sem=0
  5. C再次执行一次循环, 此时 sem=1
  6. P3执行, 此时 sem=0

这样就没问题了,总结一下,当缓冲区满时,如果再来一个生产者进程,则睡眠,同时sem-1.当缓冲区为空,如果再来一个消费者进程,则睡眠,同时sem+1

信号量

下面来真正看一下信号量的定义:
信号量:1965年,由荷兰学者Dijkstra提出的一种特殊整型变量,量用来记录,信号用来控制进程的sleep和wakeup。

struct semaphore
{
	int value;		// 记录资源个数
	PCB *queue;		// 记录等待在该信号量上的进程
}
P(semaphore s)
{
	s.value --;
	if (s.value < 0)		// 说明没资源了 还来一个消费者
	{
		sleep(s.queue);
	}
}
V(semaphore s)
{
	s.value ++;
	if(s.value <= 0)		// +1之后<=0就说明有进程在sleep
	{
		wakeup(s.queue);
	}
}

用信号量解决生产者消费者问题:

semaphore empty = BUFFER_SIZE//空闲缓冲区个数
semaphore full = 0;//资源个数

生产者:
Producer(item)
{
	P(empty);		// 空闲缓冲区是不是为零

	//代码区

	V(full);		// 生产者执行一次之后,肯定要增加一次资源个数
}

消费者:
Consumer()
{
	P(full);		// 资源是不是为零

	// 代码区
	
	V(empty);		// 消费者执行一次之后,肯定要增加一次空闲缓冲区个数
}

记得首先考虑什么时候进程停止。

信号量的临界区保护

信号量为什么要进行保护呢?

以前面的生产者消费者实例中的信号量empty为例,当empty=-1的时候,如果再来一个消费者进程,那么该进程sleep,同时empty=-2。仔细看一下这句话,消费者进程sleep的前提时empty=-1。sem的值必须要正确,如果empty的值不正确,那么后面就乱套了。在程序里面一般会使用empty++或者empty–这样的操作来改变信号量,但是这样的操作一定能保证empty的值正确吗?在某种情况下是不行的,

empty–这句话在内核里面是这样解释的

register = empty;
register = register - 1;
empty = register;

empty这个信号量是存储在内存里面的,内存里面是不可以进行算数运算的,只能先将这个值放到寄存器里面运算完在赋值回来。如果同时有两个生产者进程P1和P2,

P1

P1.register = empty;
P1.register = P1.register - 1;
empty = P1.register;

P2

P2.register = empty;
P2.register = P2.register - 1;
empty = P2.register;

假设empty=-1,经过P1和P2之后empty应该为-3;
一种可能的调度是这样:

P1.register=empty;			// P1.register = -1
P1.register=P1.register-1;	// P1.register = -2
P2.register=empty;			// P2.register = -1
P2.register=P2.register-1;	// P2.register = -2
empty=P1.register;			// empty = -2
empty=P2.register;			// empty = -2

也就是运行完之后empty的值为-2,这样与预期结果不一样,这个结果是错误的,为什么会错误,因为执行顺序发生了改变,进程会随时切换,谁也不知道时间片什么时候会用完。其实这种错误很常见,因为多个进程操作一个共享变量执行一段时间出问题的可能很大;程序是没问题的,但是调度过程不正确导致结果也不正确,这种错误是很难发现的。我们期望程序的执行寻顺序应该是这样:

P1.register = empty;
P1.register = P1.register - 1;
empty = P1.register;			// -2
P2.register = empty;
P2.register = P2.register - 1;
empty = P2.register;			// -3

也就是当P1进程执行的时候就不允许切换出去执行其他进程,只能执行完P1之后才能执行P2。那如何保护呢?使用临界区

临界区(Critical Section)

临界区:一次只允许一个进程进入的该进程的那一段代码
由临界区的概念可知,将改变empty的这段代码放在临界区中就可以解决这个问题。

临界区代码的保护原则:

  • 基本原则:互斥进入:如果一个进程在临界区中执行,则其他进程不允许进入。一个好的临界区肯定不能只包含一个基本原则,肯定还有一些优化,比如:
    • 有空让进:若干进程要求进入空闲临界区时,应尽快使一进程进入临界区
    • 有限等待:从进程发出进入请求到允许进入,不能无限等待

临界区保护的直观想法:

Pi进程

加锁
临界区
开锁
剩余区

也就是Pi进程执行临界区代码的时候加个锁,表示我开始执行了,你们都别进来;执行完之后把锁打开,意思是我执行完了,你们可以执行了。

两个进程之间的调度

进入临界区的第一个尝试 - 轮换法:

进程P0
while (turn!=0);
临界区
turn=1;
剩余区

进程P1
while (turn!=1);
临界区
turn=0;
剩余区

可以看到进程P0进入临界区trun一定为0,进程P1进入临界区trun一定为1;满足互斥进入条件。但是如果因为某种情况导致P1阻塞,那么turn永远也不会变成0,也就是如果有空位也没有进程进去,因为P0一直在空转。所以这种方式不好

进入临界区的又一个尝试 - 标记法:

进程P0
flag[0] = true;
while(flag[1]);
临界区
flag[0] = false;
剩余区

进程P1
flag[1] = true;
while(flag[0]);
临界区
flag[1] = false;
剩余区

进程P0进入临界区的时候flag[0] = true;flag[1] = false;当进程P1进入临界区的时候 flag[0]=false;falg[1]=true;满足互斥。但是如果程序按照如下方式执行:

flag[0] = true;
flag[1] = true;
while(flag[1]);
while(flag[0]);

那么进程P0和P1都会陷入空转状态。所以这种方式也肯定不行。

Peterson算法:

进程P0
flag[0] = true;
trun = 1;
while (flag[1] && trun == 1);
临界区
flag[0] = false;

进程P1
flag[1] = true;
trun = 0;
while(flag[0] && turn == 0);
临界区
flag[1] = false;
剩余区

如果两个进程同时进入,那么flag[0]=flag[1]=true;对于进程P0,当flag[1]=true时,如果不空转,那么turn一定
为0,如果trun为0,进程P1就不可能进入。满足互斥

如果P1不在临界区,那么flag[1]=false,trun=0;而这两点无论满足哪一点P0都能进入。满足有空让进。
如果P0要求进入,那么flag[0]=true;那么P1不可能一直进入,因为当flag[0]=true时,如果P1想进入,turn必须要
为1,但是如果P0不执行,P1每次执行都会将turn设置为0,因此当进程P0要求进入时,P1顶多还能先进入一次(当P0执行到trun=1时切出去执行P1)。满足有限等待。

因此Peterson算法满足要求,其实Peterson算法里面也包含了轮换+标记的思想。

以上都是考虑两个进程之间的调度,如果有n个进程呢?

多个进程调度

面包店算法:

面包店:每个进入商店的客户都获得一个号码,号码最小的先得到服务;号码相同时,名字靠前的先服务。

对应到进程切换来说就是给每个进程一个数字,调度的时候选择数字最小的先执行,执行完之后将该号码值为0,如果该进程还需要再次执行,那么就重新获取一个号码,并且这个号码比其他进程的号码都大。

Pi进程
choosing[i]=true; 
num[i]=max(num[0],…,num[n-1 ])+1;	// 进程进来之前先取号,每次进入的时候都取最大那个
choosing[i]=false; 
for(j=0; j

对于这一句的理解,

while((num[j]!=0) && (num[j],j)<(num[i],i]));	

首先 i 表示当前进程为Pi,num[j]!=0说明进程j想执行,(num[j],j) < (num[i],i),并且进程Pj的号码比进程Pi的号码小,那么就让进程Pi空转,让进程Pj执行,当进程Pj执行完之后,如果Pj进程不用执行了,那么num[j]==0,如果Pj进程还要执行,那么num[j]肯定大于num[i],都会使Pi跳出空转。

面包店算法含有轮转+标记的意味,轮转体现在:每个进程第二次执行都至多要等n个进程,因为取号的时候是取的最大号,标记就是号码咯。很明显面包店算法也符合“互斥进入”、“有空让进”和“有限等待”。

面包店算法有自己的优点也有其缺点,优点前面已经说了,对于多进程的调度实现的比较好,而且是纯软件的不需要硬件支持;缺点就是太麻烦了,代码复杂。
下面说一种简单的方法

通过开关中断来实现

cin();
临界区
sti();
剩余区

cin()表示关中断,sti()开中断。进程怎么切换?还不是通过中断嘛,我直接把中断关了就不用切换了吧。CPU中断的原理:CPU旁边有个INTR寄存器,如果有中断来,就将INTR的某一位置为一,CPU每执行完一条指令之后就看一下是不是有中断来,如果有就执行;cin()函数的作用就是让CPU不看INTR寄存器,这样不管有没有中断来,CPU都不管。

但是这种方式对于多核不好使,因为你只能控制一个CPU,其他的CPU控制不了,而现在的计算机基本上都是多核了。这种方式只适用于单核的小系统。

硬件原子指令法

boolean TestAndSet(boolean &x)
{
	boolean rv = x;
	x = true;
	return rv;
}

简单的讲,就是TestAndSet在执行的时候不会被打断。还记得最初的“直观想法”吗?就是简单的加锁开锁罢了,上面的这些什么算法都是实现这个加锁开锁的过程。同样硬件原子指令法也是这样,

while(TestAndSet(&lock));
临界区
lock=false;
剩余区

很明显对于调度几个方面的要求都是符合的。它为什么叫硬件原子指令法呢?因为TestAndSet这条指令在执行的时候是对硬件有要求的,硬件必须这么设计才能支持这条指令。

参考资料

哈工大李志军操作系统

你可能感兴趣的:(操作系统,操作系统)