信号量的实现和应用(一)

生产者-消费者问题

从一个实际的问题:生产者和消费者出发,谈一谈为什么需要信号量?信号量用来做什么?

问题描述:现在存在一个文件"buffer.txt"作为一个共享缓存区,缓冲区同时最多只能保存10个数。现在一个生产者进程,依次向缓冲区写入整数0,1,2,....,M, M>=500;有N个消费者进程,消费者进程从缓冲区读数,每次读一个,并将读出的数从缓冲区删除。

为什么需要信号量?

对于生产者来说,当缓冲区满,也就是空闲缓冲区个数为0时,此时生产者不能继续向缓冲区写数,必须等待,直到有消费者从满缓冲区取走数后,再次有了空闲缓冲区,生产者才能向缓冲区写数。

对于消费者来说,当缓冲区空时,此时没有数可以被取走,消费者必须等待,直到有生产者向缓冲区写数后,消费者才能取走。并且如果当缓冲区空时,先后有多个消费者均想从缓冲区取数,那么它们均需要等待,此时需要记录下等待的消费者个数,以便缓冲区有数可取后,能将所有等待的消费者唤醒,确保请求取数的消费者最终都能取的数。

也就是说,当多个进程需要协同合作时,需要根据某个信号,判断当前进程是否需要停下来等待;同时,其他进程需要根据这个信息判断是否有进程在等待,或者有几个进程在等待,以决定是否需要唤醒等待的进程。而这个信息,就是信号量。

信号量来做什么?

设有一整形变量sem,作为一个信号量。此时缓冲区为空,sem=0。

1,消费者C1请求从缓冲区取数,不能取到,睡眠等待。sem=-1<0,表示有一个进程因缺资源而等待。

2,消费者C2也请求缓冲区取数,睡眠等待。sem=-2<0,表示有两个进程因缺资源而等待。

扫描二维码关注公众号,回复: 2237752 查看本文章

3,生产者P往缓冲区写入一个数,sem=sem+1=-1<=0,并唤醒等待队列的头进程C1,C1处于就绪,C2仍处于睡眠等待。

4,生产者P继续往缓冲区写入一个数,sem=0<=0,并唤醒C2,C1,C2就处于就绪状态。

由此可见,通过判断sem的值以及改变sem的值,就保证了多进程合作的合理有序的推进,这就是信号量的作用。

实现信号量

信号量有什么组成?

1,需要有一个整形变量value,用作进程同步

2,需要有一个PCB指针,指向睡眠的进程队列

3,需要有个名字来表示这个结构的信号量。

同时,由于该value的值是所有进程都可以看到和访问的共享变量,所以必须在内核中定义;同时,这个名字的信号量也是可供所有进程访问的,必须在内核中定义;同时,又要操作内核中的数据结构;进程控制块PCB,所以信号量一定要在内核中定义,而且必须是全局变量。由于信号量要定义在内核中,所以和信号量相关的操作函数必须做成系统调用,还是那句话:系统调用是应用程序访问内核的唯一方法。

和信号量相关的函数?

Linux在0.11版还没有实现信号量,我们可以先弄一套缩水版的类POSIX信号量,它的函数原型和标准并不完全相同,而且只包含如下系统调用:
      sem_t *sem_open(const char  *name, unsigned int value);
      int sem_wait(sem_t *sem);
      int sem_post(sem_t *sem); 
      int sem_unlink(const char *name);
sem_t是信号量类型,根据实现的需要自己定义。

信号量保护?

使用信号量还需要注意一个问题,这个问题是由于多进程的调用引起的。当一个进程正在修改信号量的值时,由于时间片耗完,引发调度,该修改信号量的进程被切换出去,而得到CPU使用权的新进程也开始修改此信号量,那么该信号量的值很有可能发生错误,如果信号量的值发错了,那么进程的同步也会出错。所以在执行修改信号量的代码时,必须加以保护,保证正在修改过程中其他进程不能修改同一个信号量的值。也就是说,当一个进程在修改信号量时,由于某种原因引发调度,该进程被切换出去,新的进程如果也想修改信号量,是不能操作的,必须等待,直到原来修改该信号量的进程完成修改,其他进程才能修改此信号量。信号量的代码一次只允许一个进程执行,这样的代码称为临界区,所以信号量的保护,又称临界区的保护。

实现临界区的保护有几种不同的方法,在Linux 0.11上比较简单的方法是通过开,关中断来阻止时钟中断,从而避免时间片耗完引发的调度,来实现信号量的保护。但是开关中断的方法,只适合单CPU的情况,对于多CPU的情况,不适合。Linux 0.11就是单CPU,可以使用这种方法。

对信号量的保护

//生产者
Producer(item)
{
    P(empty);//生产者先判断 缓存区个数 empty是否满了,empty == 0,阻塞
    ...
}

//生产者P1
register = empty;
register = register - 1;
empty = register;

//生产者P2
register = empty;
register = register - 1;
empty = register;

//初始情况
empty = -1; //空闲缓冲区的个数,-1表示有一个进程在睡眠

//一个可能的执行(调度)
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初始值为-1,P1执行完,empty=-2,P2执行完,empty=-3

上边的例子,empty=-2,所以信号量empty需要保护。

竞争条件:和调度有关的共享数据语义错误,错误是有多个进程并发操作共享数据引起的,错误和调度顺序有关,很难发现和调试,需要加保护,保证调度的正确执行,有的程序会增加空循环,减少调度的错误概率,但是不会根本解决调度问题。

解决竞争:在写共享变量empty时,给empty上锁,阻止其他进程访问empty,受保护的代码段一次只允许一个进程进入,不能被分割的代码段称为原子操作。

//仍然是上边出错的执行序列
P1.register = empty;
P1.register = P1.register - 1;

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

empty = P1.register;
empty = P2.register;

//执行过程
//P1检查并给empty上锁
P1.register = empty;
P1.register = P1.register - 1;

//P2 检查empty的锁,P2不能执行
//P1继续执行
empty = P1.register;
//P1给empty开锁

//P2检查并给empty上锁,下边三句是原子操作,不能分割
P2.register = empty;
P2.register = P2.register - 1;
empty = P2.register;
//给empty开锁

临界区

临界区:一次只允许一个进程进入的那一段代码(修改信号量的代码就是临界区),P1,P2中修改empty的代码,就是临界区。

//进程的代码结构
剩余区
进入区
临界区
退出区
剩余区

临界区代码的保护原则:

基本原则:

互斥进入:如果一个进程在临界区中执行,其他进程不允许进入。进程间是互斥关系

有空让进:若干进程要求进入空闲临界区时,要尽快使一个进程进入临界区

有限等待:从进程发出进入请求到允许,不能无限等待

进入临界区的尝试

轮换法:

//进程P0
//turn !=0,P0空转
while(turn != 0)
    ;
//turn = 0,P0进入临界区
临界区
turn = 1;//处理完临界区,置turn = 1
剩余区

//进程P1
//turn != 1,P1 空转
while(turn != 1)
    ;
//turn = 1,P1进入临界区
临界区
turn = 0;//执行完临界区,置turn = 0
剩余区

互斥:

P0进入临界区的条件turn=0

P1进入临界区的条件turn=1

问题:turn=1,P1可以进入临界区,但是P1被阻塞了,P1没操作临界区,P0不能执行临界区

进入临界区的尝试(例子):

冰箱是共享资源,目的是冰箱中一个牛奶

上边的轮换法类似于值日,1天中丈夫和妻子只有1个人买牛奶,要防止重复购买,丈夫和妻子要交流,谁去买牛奶,留一个便条

if(noMilk)
{
    if(noNote)
    {
        leave Note;
        buy milk;
        remove note;
    }
}

设置临界区

//进程P0
//P0要进临界区,打个标记,flag[0] = true
flag[0] = true;
//判断P1的flag,如果flag[1] = true,说明P1在操作临界区,P0空转等待
while(flag[1])
    ;
操作临界区; //flag[1] = false,P0可以操作进阶区
flag[0] = false; //操作完,将flag[0]置为false
剩余区

//进程P1
//P1要进临界区,打标记 置flag[1] = true
flag[1] = true;
while(flag[0])//判断P0的标记,flag[0] = true,说明P0在操作临界区,P1空转等待
    ;
操作临界区; // flag[0] = false,P1可以操作临界区
flag[1] = false;//操作完,置flag[1] = false
剩余区

问题:

P0进入临界区的条件是flag[0] = true, flag[1] = false

P1进入临界区的条件是flag[0] = false, flag[1] = true

满足互斥要求,但是能不能保证临界区空闲时,可以有进程来执行呢?

// 进程P0
flag[0] = true;

//进程P1
flag[1] = true;

//进程P0,空转
while(flag[1])
    ;

//进程P1,空转
while(flag[0])
    ;

flag[0] = true, flag[1] = true,临界区空闲,P0,P1的请求无限等待

买牛奶的例子:

丈夫要买牛奶,看到 妻子留了便条; 
妻子要买牛奶,看到 丈夫留了便条; 
最后谁都没买,冰箱中没有牛奶

非对称标记法:

带名字的便条+让一个人更加勤劳

关键:选择一个进程进入,另一个进程循环等待

//丈夫(A)
leave note A;
while(note B)
{
    do nothing;
}
if(noMilk)
{
    buy milk;
}
remove note A;


//妻子(B)
leave note B;
if(noNote A)
{
    if(noMilk)
    {
        buy milk;
    }
}
remove note B;

进入临界区的尝试——Peterson算法

结合了 标记 和 轮转 两种思想

//进程P0
//P0执行,只 flag[0] = true,turn = 1
flag[0] = true;
turn = 1;
//判断flag[1] 和 turn,如果flag[1] = true && turn == 1,空转
while(flag[1] && turn == 1)
    ;
临界区; //如果flag[1] = false || turn == 0,进入临界区执行
flag[0] = false;//执行完,置flag[0] = false
剩余区;


//进程P1
//P1执行,置flag[1] = true,turn = 0
flag[1] = true;
turn = 0;
// 如果 flag[0] == true,turn == 0,P1空转
while(flag[0] && turn == 0)
    ;
临界区;// 如果 flag[0] == flase || turn == 1,P1进入临界区执行
flag[1] = false;// 执行完,置flag[1] = false
剩余区;

Peterson算法的正确性

满足互斥条件:

P0进入临界区的条件:flag[0] = true, flag[1] = false || turn == 0

P1进入临界区的条件:flag[1] = true, flag[0] = false || turn == 1

P0 P1同时进入临界区,flag[0] = flag[1] = true, turn == 0 == 1

满足有空让进:

比如临界区空闲,P1阻塞,且不在临界区,则flag[1] == false || turn == 0 ,P0是可以执行的

满足有限等待:

比如P0要进入临界区,P0执行while阻塞了,此时flag[0] = true,只要P1执行一次,turn = 0, P0就可以进入临界区了

面包店算法(解决多进程情况)

仍是 标记 和 轮转 的结合

如何轮转:每个进程都有一个序号,序号最小的进入

如何标记:进程离开时序号为0,不为0的序号即标记

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

//每个进程有一个号(num[i])一个标记(choosing[i]),num[i]!=0表示要进入临界区,取号最小的进入
//进程i执行
choosing[i] = true; // 保证只有一个进程在选号
num[i] = max(num[0], ..., num[n-1]) + 1; // num[i]是已有号码的最大值 + 1
choosing[i] = false; //取号结束,置 choosing[i] = false
// 遍历所有进程
for(j = 0; j < n; j++)
{
    //别的进程在选号,空转
    while(choosing[j])
        ;
    // num[j] != 0表示进程j要进入临界区,j < i,进程i空转等待
    while((num[j] != 0) && (num[j], j) < (num[i], i))
        ;
}
临界区
num[i] = 0;
剩余区

正确性分析:

互斥进入:

设Pi在临界区,Pk试图进入,一定要(num[i],i)<(num[k],k) Pk循环等待

有空让进:

如果没有进程在临界区,最小序号的进程可以进入

有限等待:

离开临界区的进程 想再次进入 一定排在最后(FIFO),所以一个进程最多等n个进程

弊端:实现很复杂,一直往后取号,可能会溢出

硬件原子指令法

软件实现很复杂,希望通过硬件解决

一个进程在操作临界区,另一个进程请求进入临界区,一定发生了调度,能不能阻止这种调度

调度一定有中断,调度时 会调用schedule

//进程Pi
cli(); //关中断
临界区
sti();//开中断
剩余区

该方法适用于单CPU情况,Linux 0.11是单核的;多CPU是不适合

单CPU情况:

中断是在CPU上有一个中断寄存器INTR,发生中断,寄存器打个1,CPU每执行完一个指令(指令是汇编指令,C语言的是语句),看INTR是否是1,如果是1,就进入中断处理程序

一旦设置了cli(),指令执行完,就不判断INTR了

多CPU时不适用:

多CPU时,执行中断,每个CPU对应的INTR都置1

假设临界区在CPU1上,P1在执行,设置了cli(),CPU1上再有中断,就不调度了,CPU1上的临界区可以一直执行,设CPU2在执行P2,设置了cli(),就不判断中断,P2也执行

此时P1 P2就都在执行临界区了

临界区保护的硬件指令原子指令法:

我们的想法是 执行临界区之前上锁,然后执行临界区,执行完开锁 
计算机中的锁是一个变量,上锁 开锁 就是给变量赋值 
比如用 信号量 mutex 表示锁,metux = 1 表示有1个资源,0 表示没有资源 
锁不能用信号量实现 用信号量表示锁,信号量是一个锁,修改信号量需要保护 即 修改信号量这个锁 还需要个锁

锁由硬件实现保护,临界区不能被打断 是原子指令,硬件原子指令 使锁 上锁 开锁 不被打断

// TestAndSet是操作锁的,不能被打断
boolean TestAndSet(boolean &x)
{
    //该函数代码 一次执行完毕
    boolean rv = x;
    x = true;
    return rv;
}

//进程Pi
// lock = true 表示上锁,TestAndSet返回true,如果锁上了,Pi就空转
while(TestAndSet(&lock))
    ;
临界区; // 没上锁,进入临界区执行
lock = false; // 执行完临界区,解锁
剩余区

满足互斥要求:当lock=false,进程1判断TestAndSet返回false,执行临界区,其他进程此时申请临界区,判断TestAndSet返回true 不能执行,进程1执行完 临界区,释放锁。

转载:https://blog.csdn.net/jieqiong1/article/details/54798801

猜你喜欢

转载自blog.csdn.net/dongyu_1989/article/details/81060403