进程同步与信号量以及死锁处理--OS

进程同步与信号量

说明信号量之前,我们先说一下信号

进程同步,也就是一个进程需要和另外一个进程合作,那么肯定会发生阻塞,因为需要等待另一个进程的信号。

//生产者进程
while(true)
{
    if(counter == BUFFER_SIZE)
        sleep();
    ...
    if(counter==1)
        wakeup("消费者")
}

//消费者进程
while(true)
{
    if(counter == 0)
        sleep();
    ...
    if(counter == BUFFER_SIZE - 1)
        wakeup("生产者")
}

从上面两个程序我们可以看出,counter就是两个进程的信号,当counter == BUFFER_SIZE的时候,生产者就会进入阻塞状态,而当消费者消费了一个元素,counter-1之后发现counter == BUFFER_SIZE-1,就会唤醒一个生产者,这在只有两个进程时可能有用,但是当有多个生产者阻塞时,永远只有一个会被唤醒,其他的生产者将会永远的阻塞在那边,因此我们才需要信号量,我们不能用counter来作为信号,我们需要信号量来告诉我们有多少给消费者或生产者被阻塞,然后根据信号量来唤醒。

信号量

根据上面的例子,我们引入信号量sem, sem表示有多少个资源可以使用

缓存区满,P1执行,P1 sleep       sem = -1
P2执行, P2 sleep               sem = -2
C执行一次循环, wakeup P1        sem = -1
C再执行一次循环,wakeup P2       sem = 0
C再执行一次循环,                sem = 1
P3执行,                        sem = 0

根据上面我们就可以看到,由一个信号量,我们就能完成进程的唤醒与睡眠

信号量的实现

struct semaphore
{
    int value; //记录资源个数
    PCB *queue;  //阻塞队列
}
P(semaphore s);  //消费资源
V(semaphore s);  //产生资源
P(semaphore s)
{
    s.value--;
    if(s.value < 0)
    {
        sleep(s.queue);
    }
}
V(semphore s)
{
    s.value++;
    if(s.value <= 0)
    {
        wakeup(s.queue);
    }
}

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

int fd = open("buffer.txt");
write(fd, 0, sizeof(int));
write(fd, 0, sizeof(int));

semaphore full = 0;
semaphore empty = BUFFER_SIZE;
semaphore mutex = 1;

Producer(item)
{
    P(empty);
    P(mutex); //互斥信号量

    V(mutex);
    V(full);
}

Cousumer()
{
    P(full);
    P(mutex); //互斥信号量

    V(mutex);
    V(empty);
}

信号量临界区保护

说明信号量临界区保护时,我们先说明为何需要临界区保护

用一个简单的例子说明一下

empty = -1;
Producer(item)
{
    P(empty);
    ...
}
//生产者P1
register = empty;
register = register - 1;
empty = register;
//生产者P2
register = empty;
register = register - 1;
empty = register;

加入现在出现这样一个调度

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

那么最后的结果将会是empty = -2,显然不符合实际情况,为了避免这种情况,我们引出锁的概念

我们希望当有一个进程正在执行对empty进行修改的代码的时候,其他进程不能执行他们的那段对empty修改的代码,也就是对empty进行上锁,当一个进程发现empty被锁了之后,就原地打转,等待这个锁被解开,这时候再执行对empty修改的代码,就不会出现前面的那种错误了

我们把每一个进程对empty进行修改的那段代码称为临界区

临界区(Critical Section)

临界区:一次只允许一个进程进入的该进程的那一段代码

读写信号量的代码一定是临界区

保护临界区,就是要写两段代码,一段是进入临界区的代码,一段是退出临界区的代码

临界区保护原则

  1. 互斥进入
  2. 有空让进
  3. 有限等待

Peterson算法

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

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

while(flag[1] && turn==1) //表示当轮到对面时且对面正好要进临界区,那么当前进程就原地打转,否则就进入临界区,这样就满足了有空让进且有限等待,因为当当前进程进入后,会把turn修改为对方

Peterson算法只能满足两个进程,因此,我们需要其他算法

面包店算法

依然是使用了标记和轮转的结合

轮转:每个进程都获得一个序号

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

choosing[i] = true; num[i] = max(num[0],...,num[n-1])+1; //取最大的号+1
choosing[i] = false; for(j=0; j

choosing[i]=false的作用是防止多个进程同时领号

面包店算法虽然是正确的,但是有些复杂,如果进程不断的领号,就有可能造成溢出,因此,我们寻找一些更好的方法

临界区保护的另一类解法

为什么需要保护临界区?,因为进程总是会切换来切换去,导致临界区被错误的修改,而进程切换肯定需要中断,中断后才能执行进程切换,因此我们可以再临界区前关闭中断,临界区结束后开启中断,这样对empty的修改肯定是正确的

cli();
    临界区
cli();
    剩余区

这种方法对多核cpu无效

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

之前我们提到了锁机制,也就是希望将对empty修改的代码进行上锁,不允许多个进程同时执行它,所以锁其实也是一个信号量,一个值为1或0的信号量,但是如果我们依旧采用之前的方式对锁这个信号量进行修改,那这个锁就没有任何意义了,因此硬件原子指令法,就是把这个锁的修改变成原子性的,就是不允许被中断,只能一次性执行完,这就是硬件原子指令法

boolean
TestAndSet(boolean &x)
{
    boolean rv = x;
    x = true;
    return rv;
}
/*这段代码是一次性执行完的*/
while(TestAndSet(&lock));
    临界区
lock = flase;
    剩余区

总结下来就是一句话:用临界区保护信号量,用信号量实现进程同步

信号量代码实现

//用户态程序
main()
{
    sd = sem_open("empty");
}
sem.c中
typedef struct{
    char name[20];
    int value;
    task_struct * queue;
}
sys_sem_open(char *name)
{
    在semtable中找到和name一样的信号量
    如果没有,则创建一个
    返回对应的下标
}
for(i=1 to 5)
    sem_wait(sd);
    write(fd,&i,4);
sys_sem_write(int fd)
{
    cli();
    if(semtable[sd].value-- < 0)
    {
        设置自己为阻塞态
        加入semtabel[sd].queue中
        schedule();
    }
    sti();
}

linux 0.11中的信号量实现

bread(int dev, int block)
{
    struct buffer_head * bh;
    ll_rw_block(READ, bh);
    wait_on_buffer(bh);
}
//启动磁盘读之后进入睡眠
lock_buffer(buffer_head *bh)
{
    cli();
    while(bh->b_lock) 
        sleep_on(&bh->b_wait);
    bh->b_block = 1;
    sti();
}
临界区保护
void sleep_on(struct task_struct **p)
{
    struct task_struct *tmp;
    tmp = *p;
    *p = current;
    current->state = TASK_UNINTERRUPTIBLE;
    schedule();
    if(tmp)
        tmp->state=0;
}

这里先提出一个问题,在lock_buffer里边,为什么while(bh->b_lock)中使用的是while而不是if;

下面我们来解释一下这段代码

struct task_struct *tmp;
tmp = *p;
*p = current;

进程同步与信号量以及死锁处理--OS_第1张图片

*p指向队列的头部

  • tmp = *p,*p指向的是下一个进程的PCB,将下一个进程的PCB赋给tmp;
  • *p = current,current指向当前进程的PCB,将当前进程的PCB赋给*p,也就是队列的头部

也许你会问,那队列之前的头部跑到哪里去了?

struct task_struct *tmp;

我们知道,在c语言中,定义一个局部变量,是保存在栈里边的,这是在内核中的代码,因此,tmp就保存在当前进程的内核栈中,在当前进程的内核栈中,我们就能找到下一个进程,这就很隐蔽的形成了一个队列,同理,在下一个进程的内核栈中,同样可以找到下下的进程的PCB,也就是tmp;

linux 0.11如何唤醒队列?

static void read_intr(void)  //磁盘中断
{
    ...  
    end_request(1);
}
end_request(int uptodate)
{
    ...
    unlock_buffer(CURRENT->bh);
}
unlock_buffer(struct buffer_head *hd)
{
    bh->b_lock = 0;
    wake_up(&bh->b_wait);
}
wake_up(struct task_struct **p)
{
    if(p && *p)
    {
        (**p).state = 0;
        *p = NULL;
    }
}

我们先看一下这三行代码

schedule();
if(tmp)
    tmp->state=0;

这三行代码,实现了将队列的每一个进程都唤醒的功能,那么,这三行代码是如何实现将所有进程都唤醒的功能的。

我们看一下wake_up函数

wake_up(struct task_struct **p)
{
    if(p && *p)
    {
        (**p).state = 0;
        *p = NULL;
    }
}

当一个进程被唤醒后,它会在哪里执行?

显然,肯定是从进入睡眠的下一处代码开始执行,下一处代码刚好就是前面的那三行代码

schedule();
if(tmp)
    tmp->state=0;

当这一个进程被唤醒后,它会去查看是否有下一个进程,如果有,那么就将其唤醒,同理下一个进程被唤醒后,也会去唤醒下下个进程,这样就实现了将所有进程都唤醒的功能。

将所有进程都唤醒有什么用吗?为什么不唤醒一个就可以了?

我们重新回到之前的问题

lock_buffer(buffer_head *bh)
{
    cli();
    while(bh->b_lock)
        sleep_on(&bh->b_wait);
    bh->b_lock = 1;
    sti();
}

lock_buffer里边,为什么while(bh->b_lock)中使用的是while而不是if;

我们将所有进程都唤醒,因为优先级高的进程可能晚进队列,但是,你要让优先级高的先执行,那么就得把所有进程都唤醒,让schedule来决定执行哪一个进程,再将其他被唤醒的进程重新进入睡眠,这就是为什么要使用while的原因。

死锁处理

死锁的4个必要条件

  • 互斥使用
  • 不可抢占
  • 请求和保持
  • 循环等待

死锁处理方法

  • 死锁预防

      破坏死锁形成的必要条件
      * 在进程执行前,一次性申请所有需要的资源
      缺点1:需要预知未来,编程困难
      缺点2:许多资源分配后很长时间才使用,资源利用率低
      * 对资源类型进行排序,资源申请必须按序进行,不会出现环路等待
      缺点:造成资源浪费
    
  • 死锁避免

      检测每个资源请求,如果造成死锁就拒绝
      如果系统中的所有进程存在一个可完成的执行序列P1,P2,...,Pn,则称系统处于安全状态
      安全序列:P1,p2,...,Pn
      如何找出这个安全序列?
      //银行家算法
      int Available[1..m]; //每种资源剩余数量
      int Allocation[1..n,1..m];  //已分配资源数量
      int Need[1..n,1..m]; //进程还需的各种资源数量
      int Work[1..m]; //工作向量
      bool Finish[1..n]; //进程是否结束
      Work = Available; Finish[1..n] = false;
      while(true)
      {
          for(i=1; i<=n; i++)
          {
              if(Finish[i]==false && Need[i]<=Work)
              {
                  Work = Work + Allocation[i];
                  Finish[i] = true;
                  break;
              }
              else
              {
                  goto end;
              }
          }
          End: 
          for(i=1; i<=n; i++)
          {
              if(Finish[i]==false)
                  return "deadlock";
          }
      }
      时间复杂度T(n) = O(mn^2);
    
  • 死锁检测和恢复

      检测到死锁出现时,让一些进程回滚,让出资源
      每次申请都执行O(mn^2),效率低。发现问题再处理
      定时检测或者是发现资源利用率低时检测
      Finish[1..n] = false;
      if(Allocation[i] == 0)
          Finish[i] = True;
      ...//银行家算法 
      for(i=1; i<=n; i++)
          if(Finish[i]==false)
              deadlock = deadlock + {i};
    

    选择哪些进程回滚?优先级?占用资源多的?
    如何实现回滚?那些已经修改的文件怎么办?

  • 死锁忽略

      无视死锁,重启大法
    

总而言之:死锁处理的代价太大,而且编程困难,效率低,所有大部分操作系统采用的都是死锁忽略的方法。

你可能感兴趣的:(进程同步与信号量以及死锁处理--OS)