【OS操作系统】Operating System 第十章:信号量与管程

OS操作系统系列文章目录

【OS操作系统】Operating System 第一章:操作系统的概述
【OS操作系统】Operating System 第二章:启动、中断、异常和系统调用
【OS操作系统】Operating System 第三章:连续内存分配
【OS操作系统】Operating System 第四章:非连续内容分配
【OS操作系统】Operating System 第五章:虚存技术
【OS操作系统】Operating System 第六章:页面置换算法
【OS操作系统】Operating System 第七章:进程与线程
【OS操作系统】Operating System 第八章:处理机调度
【OS操作系统】Operating System 第九章:同步互斥问题
【OS操作系统】Operating System 第十章:信号量与管程
【OS操作系统】Operating System 第十一章:死锁与进程通信
【OS操作系统】Operating System 第十二章:文件系统



第十章:信号量与管程

背景

利用信号量和管程解决同步互斥的问题

  • 并发问题:竞争/竞态条件
    • 多程序并发存在大的问题;

  • 同步

    • 线程共享公共数据的协调条件;
    • 包括互斥和条件同步;
    • 互斥:在同一时间内只有一个线程可以执行临界区;
  • 解决同步问题方法较难

    • 需要高层次的编程抽象(如:锁);
    • 从底层硬件支持编译;
  • 解决过程图

    在这里插入图片描述

信号量

  • 抽象数据类型
    • 一个整型(sem),两个原子操作;
    • P操作:sem减一;
      • 如果信号量sem<0,认为执行P操作进程的需要睡眠;
      • 如果信号量sem>0,认为执行P操作的进程可以继续执行,可以进入临界区;
      • 如果挡住了,就不能执行后续的程序,起到了一个阻挡得作用;
    • V操作:sem加一;
      • 如果信号量sem<=0,唤醒一个或多个等待的进程;

  • 信号量是整数(有符号数)
    • 一开始通常会设定为一个大于0的数,所以一开始执行P操作不会被阻塞;
    • 但是多次执行P操作之后,执行P操作的进程就会等待在上面;
    • 这是需要其它进程执行V操作,然后唤醒在上面等待的进程(如果只能唤醒一个进程,一般是唤醒第一个等待的进程,才有FIFO思想);

  • 信号量是被保护的变量
    • c初始化完成之后,唯一改变信号量的值的方法是,通过P操作和V操作;
    • 操作必须是原子的;

  • P操作(信号量减一操作)能够阻塞;
  • V操作(信号量加一操作)不会阻塞;

  • 假定信号量是公平的;
    • 没有线程因为P操作被阻塞在P操作仍然阻塞如果V操作被无线频繁调用(在同一个信号量);
    • 在实际中,FOFP常被使用;

  • 两类信号量
    • 二进制信号量:可以是0或1(与前面的lock起到同样的效果);
    • 一般/计数信号量:可取任何非负值;
    • 两者可以互相表现(给定一个可以思想另一个);

  • 信号量可以用在
    • 互斥;
    • 条件同步(调度约束——一个线程等待另一个线程的事件发生);

信号量的使用

用二进制信号量实现的互斥

  • 一开始设置一个初始值,为了模仿Lock操作,使初始值为1;
  • 在临界区之前,做一个信号量的P操作;
  • 在临界区之后,做一个信号量的V操作;
  • 这就是二进制信号量的最常用法,完全可以代替前面的Lock操作;
mutex = new Semaphore(1);

mutex->P();
...
Critical Section;
...
mutex->V();

用二进制信号量完成同步操作

  • 用P操作实现线程等待;
  • 用V操作实现线程提醒;
//设定一个二进制信号量;
condition = new Senmaphore(0);

//Thread A
{
    ...
    condition->P();
    ...
}
//Thread B
{
    ...
    condition->V();
    ...
}

其它复杂问题

  • 一个线程等待另一个线程处理事情;
    比如生产东西或消费东西、互斥(锁机制)是不够的;

实例:有界缓冲区的生产者-消费者问题

  • P r o d u c e r → B u f f e r → C o n s u m e r Producer\rarr Buffer\rarr Consumer ProducerBufferConsumer
    • 一个或多个生产者产生数据,将数据放在一个缓冲区里;
    • 单个消费者每次从缓冲区取出数据;
    • 在任何一个时间只有一个生产者或消费者可以访问缓冲区;

  • 实现细节:
    • buffer是有限的;
    • 任何一个时间只能有一个线程操作缓冲区(互斥);
      • 允许一个或多个生产者往buffer中写数据,但是这时候不允许消费者读数据;
      • 允许一个或多个消费者往buffer中读数据,但是这时候不允许生产者写数据;
    • 当缓冲区为空时,消费者要休眠,消费者必须等待生产者(调度、同步约束);
    • 当缓冲区为满时,生产者必须等待消费者(调度、同步约束);

  • 信号量设置:
    每个约束用一个单独的信号量;
    • 二进制信号量互斥:
      对buffer做添加或取出的保障;
    • 一般信号量fullBuffers:
      代表一开始buffer的数据多少,初始化为0,表示一开始buffer为空;
    • 一般信号量emptyBuffers:
      代表当前生产者可以往buffer添加的数据量,初始化为n,表示当前buffer可用添加n条数据;
  • 两个一般信号量用于同步操作,当buffer还有空间时,应唤醒生产者继续生产;

代码实现

  • 数据初始化
class BoundedBuffer {
    mutex = new Semaphore(1);
    fullBuffers = new Semaphore(0);
    emptyBuffers = new Semaphore(n);
}

  • 生产者操作
//调用这个函数实现生产者不断地添加数据;
BoundedBuffer::Deposit(c) {
    
    Add c to the buffer;
    
}

  • 消费者操作
//调用这个函数实现消费者不停地取出数据;
BounderBuffer::Remove(c) {
    
    Remove c from buffer;
    
}

  • 解决互斥同步总实现代码
//信号量初始化;
class BounderBuffer {
    mutex = new Semaphore(1);
    fullBuffers = new Semaphore(0);
    emptyBuffers = new Semaphore(n);
}

//生产者代码;
BounderBuffer::Deposit(c) {
    emptyBuffers->P();
    mutex->P();
    Add c to the buffer;
    mutex->V();
    fullBuffers->V();
}

//消费者代码;
BounderBuffer::Remove(c) {
    fullBuffers->P();
    mutex->P();
    Remove c from buffer;
    mutex->V();
    emptyBuffers->V();
}


以下运用互斥机制和同步机制实现生产者和消费者问题,需要注意好初值的确定:

  • 对于生产者而言,因为buffer设置允许添加的数据是n,即emptyBuffers为n,进行减一操作后,生产者可以往下执行,进行buffer的生产操作;
  • 但是在生产之前,需要对mutex进行减一操作,得mutex为0;生产操作完成后,对mutex进行加一操作,得mutex为1;这样可以保证buffer的互斥属性,确保每次只有一个线程在执行;
  • 互斥操作完成后,对fullBuffers进行加一操作,并提醒消费者可以正常消费;
  • 对于消费者而言,fullBuffers的初始值为0,表示没有数据,消费者进入等待;所以,刚刚生产者唤醒消费者后,将会和生产者的fullBuffers加一操作相匹配;而后进行互斥的进行取数据的互斥操作;
  • 消费者取出数据后,会emptyBuffers进行加一操作,并唤醒生产者可以继续生产;

  • 问题:
    P和V操作的顺序分别调整,会有影响吗?
    • V操作是加一操作,不会阻塞,所以没有影响;
    • P操作是减一操作,会导致阻塞,进而产生严重的情况,如出现死锁;

信号量的实现


不仅要会用信号量,还需直到信号量使用的细节;

伪代码实现

  • 信号量定义
class Semaphore {
    int sem;  //信号量
    WaitQueue q;  //等待队列
}

  • P操作定义
Semaphore::P() {
    --sem;
    if (sem < 0) {
        Add this thread t to q; //将线程t放入等待队列;
        block(t);  //令线程t休眠;
    }
}

  • V操作定义
Semaphore::V() {
    ++sem;
    if (sem <= 0) {
        Remove a thread t from q;  //将线程t移出队列;
        wakeup(t);  //唤醒线程t,采用FIFO思想;
    }
}

需要注意的问题

  • 信号量的双用途
    • 互斥和条件同步;
    • 但等待条件是独立的互斥;
  • 读、开发代码比较困难
    • 程序员必须非常了解信号量;
  • 容易出错
    • 使用的信号量已经被另一个线程占用;
    • 忘记释放信号量;
  • 不能处理死锁问题;

管程


管程的抽象程度更高,更加容易完成相应的同步互斥问题;

基本概念

  • 目的:
    分离互斥和条件同步的关注;
    (一开始是完成编程语言的设计,而不是操作系统的设计,所以整体上是针对语言的并发机制来完成的)

  • 什么是管程(moniter)
    管程是包含了一系列的共享变量,以及针对这些变量的函数,的一个组合或模块;包括:
    • 一个锁:
      指定临界区(确保互斥性,只能有一个线程);
    • 0或者多个条件变量:
      等待、通知信号量用于管理并发访问共享数据;

一般方法

  • 收集在对象、模块中的相关共享数据;
  • 定义方法来访问共享数据;

    大致的结构图,如下:

    在这里插入图片描述

一开始,所有进程在右上角的排列队列中,排队完成后进行wait()操作,等到signal()操作唤醒后,执行这个进程的代码;

  • Lock
    • Lock::Acquire() —— 等待直到锁可用,然后抢占锁;
    • Lock::Release() —— 释放锁,唤醒等待者;
  • Condition Variable
    • 允许等待状态进入临界区
      • 允许处于等待(睡眠)的线程进入临界区;
      • 某个时刻原子释放锁进入睡眠;
    • Wait() operation
      • 释放锁,睡眠,重新获得锁返回;
    • Signal() operation (or broadcast() operation)
      • 唤醒等待者(或者所有等待者);

  • 实现:
    • 需要维持每个条件队列
    • 线程等待的条件等待signal()
class Condition {
    int numWaiting = 0;
    WaitQueue q;
}

Condition::Wait(lock) {
    ++numWaiting;
    Add this thread t to q;
    release(lock);
    schedule();  //need mutex
    acquire(lock);
}

Condition::Signal() {
    if (numWaiting > 0) {
        Remove a thread t from q;
        wakeup(t); //need mutex
        --numWaiting;
    }
}

  • 解析:
    • numWaiting表示当前den等待线程的个数,而之前的sem表示为信号量的个数;
    • 信号量的实现P操作和V操作一定会执行,即一定会执行减一和加一操作;但是在这里,wait操作会进行加操作,而signal操作不一定会进行减操作;
    • 这里的wait函数,还没有require lock就要先release lock的原因在后面讲解;
    • release lock操作之后,会做一次schedule(),表示会选择下一次线程执行,因为本身这个线程已经处于睡眠状态了;
    • schedule()完毕后会进行一次require lock操作,这里的release和require和之前的不一样,后面进行讲解;
    • signal函数是作唤醒的操作,从等待队列里面取出一个线程唤醒,与之前的schedule()是对应的;wakeup(t)是对schedule的进一步触动机制;最后waitting再进行减减操作;
    • 如果等待队列为0,则啥操作也不做,这里的numWaiting表示当前等待线程的个数,而之前的sem代表信号量的个数;

使用

  • 对管程进行初始化
    • lock变量保证互斥的操作;
    • condition条件变量,这里有两个,分别表示buffer满和buffer空;
    • count表示buffer的空闲情况,如果count = 0则buffer是空的;
class BounderBuffer {
    ...
    Lock lock;
    int count = 0;
    Condition notFull, notEmpty;
}

  • 互斥机制
    管程一次只能进入一个线程,故设立互斥机制;
    • 这里不仅仅要对buffer操作,还要再count中记录;
    • 信号量互斥的实现是仅仅依靠这个buffer的,而这里的互斥是在函数的头尾lock进行的;
    • buffer空了,消费者会进入睡眠;而buffer满了,生产者会进入睡眠;
//生产者
BounderBuffer::Deposit(c) {
    lock->Acquire();
    ...
    Add c to the buffer;
    ++count;
    ....
    lock->Release();
}

//消费者
BounderBuffer::Remove(c) {
    lock->Acquire();
    ...
    Remove c from buffer;
    --count;
    ...
    lock->Release(); 
}

  • 同步机制
  • 生产者的buffer未满操作
    当buffer满的时候,count = n,这时会进行notFull.wait(&lock)操作;notFull是一个条件变量,不要初始值;notFull.wait(&lock)表示当前已满,需要睡眠,同时会输入一个lock,这个是外部管程的lock;
BounderBuffer::Deposit(c) {
    lock->Acquire();
    while (count == 0) {
        notFull.Wait(&lock);
    }
    Add c to the buffer;
    ++count;
    ...
    lock->Releas();
}

  • 解释前面的问题:为什么在管程的Wait函数中,需要先释放锁再请求一个锁;
Condition::Wait(lock) {
    ++numWaiting;
    Add this thread t to q;
    release(lock);
    schedule(); //need mutex
    require(lock);
}

  • 解析:
    • release(lock),实际上锁让当前的生产者释放这个锁,这使得其它的线程才有可能进入管程;
    • 因为这时候生产者线程进入睡眠,所有必须将之前请求的锁释放;不然所有其它等待的线程都在等待,系统陷入死锁;
    • 一旦将来被唤醒,意味着可用继续冲schedule中继续往下执行,故再次申请一个锁,跳出wait操作,查看count是否等于n;

  • 消费者的buffer未满操作
    针对生产者在buffer已满的情况下,执行消费者线程;
    一旦count做了减减操作,buffer就会消费了一个数据量,故buffer就会未满,故消费者代码下面进行notFull.signal()操作,唤醒陷入睡眠的生产者线程;
BounderBuffer::Renmove(c) {
    lock->Acquire();
    ...
    Remove c from buffer;
    --count;
    notFull.Signal();
    lock->Release();
}

  • 消费者的buffer为空操作 & 生产者的buffer非空唤醒操作
    消费者在buffer为空的情况中,会在while中判断count是否为0;
    如果是,则进行notEmpty().Wait()操作进入睡眠,直到生产者进行notEmpty().Signal()操作唤醒消费者线程;
    两者相结合就是完整的管程实现生产者和消费者问题;
//生产者
BounderBuffer::Deposit(c) {
    lock->Acquire();
    while (count == n) {
        notFull.Wait(&lock);
    }
    Add c to the buffer;
    ++count;
    notEmpty.Signal();
    lock->Release();
}

//消费者
BounderBuffer::Remove(c) {
    lock->Acquire();
    while (count == 0) {
        notEmpty.Wait(&lock);
    }
    Remove c from buffer;
    --count;
    notFull.Signal();
    lock->Release();
}

管程实现与信号量实现的比较

  • 管程实现
//管程初始化
class Condition {
    int numWaiting = 0;
    WaitQueue q;
}

Condition::Wait(lock) {
    ++numWaiting;
    Add this thread t to q;
    release(lock);
    schedule(); //need mutex
    require(lock);
}

Condition::Signal() {
    if (numWaiting > 0) {
        Remove a thread from q;
        wakeup(t); //need mutex
        --numWaiting;
    }
}

//执行生产者和消费者问题
class BoundBuffer{
    ...
    Lock lock;
    int count = 0;
    Condition notFull, notEmpty;
}

BoundBuffer::Deposit(c) {
    lock->Acquire();
    while (count == n) {
        notFull.Wait(lock);
    }
    Add c to the buffer;
    ++count;
    notEmpty.Signal();
    lock->Release();
}

BounfBuffer::Remove(c) {
    lock->Acquire();
    while (count == 0) {
        notEmpty.Wait(&lock);
    }
    Remove c from buffer;
    --count;
    notFull.Signal();
    lock->Release();
}

  • 信号量实现:
//信号量初始化
class Semaphore {
    int sem;
    WaitQueue q;
}

Semaphore::P() {
    --sem;
    if (sem < 0) {
        Add this thread t to q;
        block(t);
    }
}

Semaphore::V() {
    ++sem;
    if (sem <= 0) {
        Remove a thread t from q;
        wakerup(t);
    }
}

//执行生产者和消费者问题
class BoundBuffer {
    mutex = new Semaphore(1);
    fullBuffers = new Semaphore(0); //buffer存在的数据量
    emptyBuffers = new Semaphore(n); //buffer剩余空位
}

BoundBuffer::Deposit(c) {
    emptyBuffers->P();
    mutex->P();
    Add c to the buffer;
    mutex->V();
    fullBuffers->V();
}

BoundBuffer::Remove(c) {
    fullBuffers->P();
    mutex->P();
    Remove c from buffer;
    mutex->V();
    emptyBuffers->V();
}

管程的两种实现方式

  • 问题:
    当线程 i 发出signal操作后,那么就要唤醒线程 j;
    应该是先继续执行线程 i 的后续代码,在执行等待的线程 j ?
    还是应该先执行等待线程 j 后,再执行线程 i ?

  • 方案1:Hoare方法(比较完美)

    应该先执行睡眠的线程 j,而线程 i 先等待,直到线程 j 释放了锁再继续执行;
  • 特点:
    比较直观,但是实现困难;
  • 执行流程:

    在这里插入图片描述

  • 方法2:Hansen方法

    当线程 i 执行唤醒操作后,先不交出CPU控制,而是继续执行,直到释放锁后,再让等待的线程 j 开始执行;
  • 特点:
    容易实现;
  • 执行流程:

    在这里插入图片描述

  • 两种方法的比较
    • 对于Hansen而言,其并没有马上让等待的线程执行,而是必须当前线程执行完后才会释放锁;所以这时会有多个被唤醒的线程,它们会进行抢占,故可能某些线程在抢占的时候,count已经不满足情况了,所以要循环查询;
    • 对于Hoare而言,执行之后会马上转移CPU的控制权,故只有一个线程会被唤醒,不存多个的问题;故其一定可用往下执行,因为count一定不为n;

      在这里插入图片描述

  • 总结

    在这里插入图片描述

在这里插入图片描述

经典同步问题1 —— 读者与写者问题

读者优先情况(信号量实现)

  • 出现场景:
    共享数据的访问

  • 使用者:

    • 读者:
      不需要修改数据;
    • 写者:
      读取和修改数据;
  • 问题约束:

    • 允许同一时间有多个读者,但在任何时候只有一个写者;
    • 当没有写者时,读者才能访问数据;
    • 但没有读者和写者时,写者才能访问数据;
    • 任何时候只能有一个线程可以操作共享变量;
    • 读者优先,不按时间顺序进行等待;
  • 共享数据的设计:

    • 数据集;
    • 信号量CountMutex初始化为1,保证count的读写是互斥的;
    • 信号量WriteMutex初始化为1,保证写者的互斥保护,因为只允许一个写操作;
    • 整数Rcount初始化为0,当前读者的数量,因为可以有多个读者同时操作;

实现过程

  • Sem_wait:P操作,进行减一;
  • Sem_post:V操作,进行加一;

  • 写者的互斥保护
    针对信号量WriteMutex的PV操作,确保只有一个线程可以进行写操作;
    一旦写者在写,读者不能读,只能等待;
    当读者在读时,写者不能写数据;
    体现了读者与写者的互斥操作,和写者与写者的互斥操作,但是没有体现出允许多个读者读数据;
//Writer
{
    sem_wait(WriteMutex);
    ...
    write;
    ...
    sem_post(WriteMutex);
}

//Reader
{
    ...
    sem_wait(WriteMutex);
    ...
    read;
    ...
    sem_post(WriteMutex);
}

  • 多读者体现
    • Rcount = 0,代表当时没有读者,所以只要没有写者,就可以继续执行;
    • 如果Rcount != 0,表明当前有读者线程在读数据,且写者一定不能进来,故直接++Rcount;
    • 当读完后,如果Rcount = 0,则当前没有读者,故需要唤醒可能在等待的写者;
//Reader
{
    ...
    if (Rcount == 0) {
        sem_wait(WriteMutex);
    }
    ++Rcount;
    ...
    read;
    ...
    --Rcount;
    if (Rcount == 0) {
        sem_post(WriteMutex);
    }
    ...
}

  • 多读者的互斥
    确保不会存在多个读者同时对Rcount进行操作,保证Rcount数据的互斥性;
//Reader
{
    sem_wait(CountMutex);
    if (Rcount == 0) {
        sem_wait(WriteMutex);
    }
    ++Rcount;
    sem_post(CountMutex);
    ...
    read;
    ...
    sem_wait(CountMutex);
    --Rcount;
    if (Rcount == 0) {
        sem_post(CountMutex);
    }
    sem_post(CountMutex);
}

  • 完整的读者优先的读者写者问题
//Writer
{
    sem_wait(WriteMutex);
    write;
    sem_post(WriteMutex);
}

//Reader
{
    sem_wait(CountMutex);
    if (Rcount == 0) {
        sem_wait(WriteMutex);
    }
    ++Rcount;
    sem_post(CountMutex);
    read;
    sem_wait(CountMutex);
    --Rcount;
    if (Rcount == 0) {
        sem_post(WriteMutex);
    }
    sem_post(CountMutex);
}

  • 读者优先于写者优先的区别
    • 基于读者优先策略的方法,只要有一个读者处于活动状态,后来的读者都会被接纳;如果读者源源不断出现,那么写者则始终处于阻塞状态;
    • 基于写者优先策略的方法,一旦写者就绪,那么写者会尽可能快地执行写操作;如果写者源源不断地出现,则读者则始终处于阻塞状态;

写者优先情况(管程实现)

  • 大致流程:
    • 读者进行读操作时,要注意当前是否有写者(两类:正在写数据的写者和正在等待的写者),这两类写者只要有一个存在,那么读者就需要等待;等都不存在才有机会进行读操作;
    • 读完之后,检测是否有写者正在等待,其有责任去唤醒;
    • 写者进行写操作时,需要注意是否有读者正在读,或者写者正在写,如果有需要等待(正在等待的读者不需要理会,写者优先);
    • 写完后,唤醒正在等待的写者或者正在等待的读者;
Database::Read() {
    Wait until no writers;
    read database;
    check out - wake up waiting writers;
}

Database::Write() {
    Wait until no readers/writers;
    write database;
    check out - wake up waiting readers/writers;
}

  • 数据构成
AR = 0;  //当前正在读的个数
AW = 0;  //当前正在写的个数
WR = 0;  //当前等待的读者个数
WW = 0;  //当前等待的写者个数
Condition okToRead;  //表示当前可以去读
Condition okToWrite;  //表示当前可以去写
Lock lock;  //确保只有一个函数进入管程执行;

  • 读者具体实现
    • 读者读数据时,要确保没有正在读的读者和正在写的写者(写者优先),故while语句中判断的依据为AW + WW > 0;在等待循环中,WR加一,但被唤醒时,WR减一;
    • 读完之后,AR减一;若此时没有读者且有正在等待的写者时,应该唤醒写者;若还有正在读的读者时,即AR大于零,为了保证读写的互斥,就不会唤醒写者;
  • 写者具体实现
    • 写者写数据时,先判断当前有无正在读的读者或者正在写的写者,等待队列不考虑;若没有,说明有机会被唤醒执行后面的操作;否则需要继续等待,知道被唤醒;
    • 当写完后,AW减一(AW = 0 或 1),此时表面没有正在写的写者,而有等待的写者,就去唤醒其中一个写者;若没有等待的写者当时有等待的读者,则唤醒全部的读者;
    • 注意,signal是唤醒等待在这个条件变量上的一个线程,而broadcast是唤醒等待在这个条件变量上面的所有线程;
class Database {
public:
    AR = 0;
	AW = 0;
	WR = 0;
	WW = 0;
	Condition okToRead;
	Condition ofToWrite;
	Lock lock;
}

//Reader
Public Database::Read() {
    //Wait until no writers;
    StartRead();
    read database;
    //check out - wake up waiting writers;
    DoneRead();
}

Private Database::StartRead() {
    lock.Acquire();
    while ((AW + WW) > 0) {
        ++WR;
        okToRead.wait(&lock);
        --WR;
    }
    ++AR;
    lock.Realease();
}

Private Database::DoneRead() {
    lock.Acquire();
    --AR;
    if (AR == 0 && WW > 0) {
        okToWrite.signal();
    }
    lock.Release();
}

//Writer
Public Database::Writer() {
    //Wait until no readers/writers;
    StartWrite();
    write database;
    //check out - wake up waiting readers/writers;
    DoneWrite();
}

Private Database::StartWrite() {
    lock.Acquire();
    while ((AW + AR) > 0) {
        ++WW;
        okToWrite.wait(&lock);
        --WW;
    }
    ++AW;
    lock.Release();
}

Private Database::DoneWritr() {
    lock.Acquire();
    --AW;
    if (WW > 0) {
        okToWrite.signal();
    }
    else if (WR > 0) {
        okToRead.broadcast();
    }
    lock.Release();
}

经典同步问题2 —— 哲学家就餐问题

  • 问题描述:
    5个哲学家围绕一张圆桌而坐,桌子上放着5支叉子,每两个哲学家之间放一支;哲学家的动作包括思考和进餐,进餐时同时需要拿起左右两把叉子,思考时则同时放下左右两把叉子;如何保证哲学家的动作有序进行;

  • 共享数据

    • Bowl of rice (data set)
    • Semaphore fork[5] initialized to 1
    //拿起叉子,进行减一P操作
    take_fork(i) : P(fork[i]);
    //放下叉子,进行加一V操作
    put_fork(i) : V(fork[i]);
    

不完美方案

  • 方案1:
    会导致死锁,谁都拿不了右边的叉子
#define N 5  //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
    while (true) {
        think();  //哲学家在思考
        take_fork(i);  //拿左边的叉子
        take_fork((i + 1) % N);  //拿右边的叉子
        eat();  //哲学家在进餐
        put_fork(i);  //放左边的叉子
        put_fork((i + 1) % N);  //放右边的叉子
    }
}  

  • 方案2:
    会重复过程
#define N 5  //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
    while (true) {
        take_fork(i);  //拿左边叉子
        if (fork((i + 1) % N)) {
            take_fork((i + 1) % N);  //右边有叉子并拿起
            break;
        }
        else {
            put_fork(i);  //放下左边叉子
            wait_some_time();  //等待一会
        }
    }
}  

  • 方案3:
    等待时间随机变化,可行;当不是很好的方法,可能等待时间长的哲学家一直在等待;
#define N 5  //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
    while (true) {
        take_fork(i);  //拿左边叉子
        if (fork((i + 1) % N)) {
            take_fork((i + 1) % N);  //右边有叉子并拿起
            break;
        }
        else {
            put_fork(i);  //放下左边叉子
            wait_random_time();  //等待随机长时间
        }
    }
}  

  • 方案4:
    互斥访问可以实现不会出现死锁的情况,但每次只有一个人可以进餐;本来可以并行两个哲学家吃饭,效率较低;
    将就餐(而不是叉子)看出是必须互斥访问的临界资源,因此会造成(叉子)资源的浪费;
#define N 5  //哲学家个数
//哲学家编号:0 - 4
void philosopher(int i) {
    while (true) {
        think();  //哲学家在思考
        P(mutex);  //进入临界区
        take_fork(i);  //拿左边的叉子
        take_fork((i + 1) % N);  //拿右边的叉子
        eat();  //哲学家在进餐
        put_fork(i);  //放左边的叉子
        put_fork((i + 1) % N);  //放右边的叉子
        V(mutex);  //退出临界区
    }
}  

实现思路

  • 哲学家角度:
    要么不拿,要么就拿两把叉子
    • 思考中;
    • 进入饥饿状态;
    • 如果左邻居或右邻居正在进餐,等待;否则继续执行;
    • 拿起两把叉子;
    • 吃面条;
    • 放下左边的叉子;
    • 放下右边的叉子;
    • 新的循环开始;

  • 计算机角度:
    不能浪费CPU时间;进程间相互通信
    • S1:思考中;
    • S2:进程饥饿状态;
    • S3:如果左邻居或右邻居正在进餐,进餐进入阻塞状态;否则转入S4;
    • S4:拿起两把叉子;
    • S5:吃面条;
    • S6:放下左边的叉子,看看左邻居现在能否进餐(饥饿状态,其两把叉子都在),若能,则唤醒;
    • S7:放下右边的叉子,看看右邻居现在能否进餐(饥饿状态,其两把叉子都在),若能,则唤醒;
    • 新的循环,转入S1;

大致实现

  • 编写思路:
    • 必须有数据结构,描述每个哲学家的当前状态;
    • 该状态是临界资源,每个哲学家对它的访问应该互斥地进行 —— 进程互斥;
    • 一个哲学家吃饱后,可能要唤醒它的左邻右舍,两者之间存在着同步关心 —— 进程同步;

  • 数据结构
//1、必须有一个数据结构,描述每个哲学家的当前状态
#define N 5  //哲学家个数
#define LEFT i  //第i个哲学家的左邻居
#define RIGHT (i + 1) % N  //第i个哲学家的右邻居
#define THINKING 0  //思考状态
#define HUNGRY 1  //饥饿状态
#define EATING 2  //进餐状态
int state[N];  //记录每个哲学家的状态

//2、该状态是临界资源,应该互斥访问
semaphore mutex = 1;  //互斥信号量

//3、一个哲学家吃饱后,可能要唤醒邻居,存在同步关系
semaphore s[N];  //同步信号量,初值为0

  • 操作方法
void philosophy(int i) {
    while (true) {
        think();  //S1
        take_fork(i);  //S2 - S4
        eat();  //S5
        put_fork(i);  //S6 - S7
    }
}

具体实现

  • 函数 take_fork
//功能:要么拿到两把叉子,要么被阻塞
//hungry的状态需要互斥保护
//拿两把叉子的过程也是在互斥保护中
void take_fork(int i) {
    P(mutex);  //进入临界区
    state[i] = HUNGRY;  //进入饥饿状态
    test_take_left_right_forks(i);  //试图拿起两把叉子
    V(mutex);  //退出临界区
    P(s[i]);  //没有拿到叉子就进入阻塞状态
}

  • 函数 test_take_left_right_forks
void test_take_left_right_forks(int i) {
    if (state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] != EATING) {
        state[i] = EATING;  //拿起两把,正在进餐
        V(s[i]);  //此时已经在进餐,防止因为后面P操作进入阻塞;且有唤醒进餐的作用
    }
}

  • 分析:
    • 首先确保自己是处于饥饿状态,然后判断两边的人是否处于进餐状态,如果都不是,表示两边有叉子,可以进餐;
    • 初始化时,s[i]的初始为0,故进入进餐状态后,需要进行V操作,防止被外面的P操作而进入阻塞状态;

  • 函数 put_fork
//功能:将两把叉子返回原位,并在需要的时候唤醒邻居
//这里查看左邻居是否能进餐时,需要查看左邻居的左邻居的状态;
//如果左邻居的左邻居的状态是进餐状态,则左邻居不能进餐;右邻居同理
void put_fork(int i) {
    P(mutex);  //进入临界区
    state[i] = THINKING;  //交出两把叉子
    test_take_left_right_forks(LEFT);  //判断左邻居能否进餐
    test_take_left_right_forks(RIGHT);  //判断右邻居能否进餐
    V(mutex);  //退出临界区
}

  • 程序设计的思考过程
    • 以一般的思路分析问题,写出伪代码,再将伪代码变成程序;
    • 在这个过程中要设定好变量(同步和互斥的机制);
    • 逐步细化的方式实现这个处理过程,一般说是会匹配的(P操作和V操作);
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值