多进程图像除了交替执行向前推进之外,还存在进程之间的合作;进程同步就是让这种进程之间的合作变得合理有序。如何实现合理有序要靠信号量。这篇文章包含一下常用多进程调度算法的理解,比如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()队列中唤醒一个生产者进程,反之如是。
看另外一种情况
这种情况下信号的处理就不够了,因为信号是一个二值量,只有“睡眠”和“唤醒”两种状态,不能记录有多少个进程在sleep,如果采用另外一种方式,用一个值来记录有多少个进程处于sleep。如下:
这个能记录有多少个进程处于sleep变量的值就是信号量,它不再是一个信号了,而是一个量;根据这个量得到信号。使用信号量,假设为信号量为sem
这样就没问题了,总结一下,当缓冲区满时,如果再来一个生产者进程,则睡眠,同时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。那如何保护呢?使用临界区
临界区:一次只允许一个进程进入的该进程的那一段代码
由临界区的概念可知,将改变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都会陷入空转状态。所以这种方式也肯定不行。
进程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这条指令在执行的时候是对硬件有要求的,硬件必须这么设计才能支持这条指令。
哈工大李志军操作系统