【操作系统】第二章 进程与线程(三)

2.3 同步与互斥

2.3.1 同步与互斥的基本概念

  1. 临界资源
    • 一次仅允许一个进程使用的资源称为临界资源
    • 对临界资源的访问过程可以分为四个部分:
      • 进入区:检查能否进入临界资源,若能进入,则应设置正在访问临界区的标志,以阻止其他进程同时进入临界区(上锁)
      • 临界区:访问临界资源的代码
      • 退出区:将正在访问临界区的标志清楚
      • 剩余区:代码中的其余部分
    • 公共队列是临界资源,磁盘存储介质是共享资源,不是临界资源
  2. 同步
    • 同步也称直接制约关系,是指为完成某种任务而建立的两个或多个进程,这些进程因为需要协调它们的运行次序而等待、传递信息所产生的制约关系。同步关系源于进程之间的相互合作
  3. 互斥
    • 互斥也称为间接制约关系
    • 当一个进程进入临界区使用临界资源时,另一个进程必须等待,当占用临界资源的进程退出临界区后,另一个进程才允许区访问此临界资源
    • 不同线程对同一个进程内的共享变量的访问需要互斥的进行;不同进程的线程、代码段或变量不存在互斥访问的问题,同一个线程内部的局部变量也不存在互斥访问的问题
  4. 准则
    • 空闲让进:临界区空闲时,可允许一个进程进入临界区
    • 忙则等待:当已有进程进入临界区时,其他试图进入临界区的进程必须等待
    • 有限等待:对请求访问的进程,应保证能在有限时间内进入临界区
    • 让权等待:当进程不能进入临界区时,应立即释放处理机,防止进程忙等待

2.3.2 实现临界区互斥的基本方法

软件实现方法
  1. 单标志法

    • 算法设置一个公用整型变量turn,指示允许进入临界区的进程编号。turn=0时允许P0进入临界区,turn=1时允许P1进入
    • 进程退出临界区时,将临界区的使用权赋予另一个进程

    进程P0:

    复制代码
    while(turn != 0); //进入区
    critical section; //临界区
    turn = 1;		  //退出区
    remainder section;//剩余区

    进程P1:

    复制代码
    while(turn != 1); //进入区
    critical section; //临界区
    turn = 0;		  //退出区
    remainder section;//剩余区
    • 两个进程必须交替进入临界区 ,若某个进程不再进入临界区,则另一个进程也将无法进入,违背"空闲让进"原则
  2. 双标志先检查法

    • 设置一个布尔型数组flag[2],用来标记各进程想进入临界区的意愿。flag[i]=true表示Pi想进入临界区(i=1, 2)
    • Pi进入临界区之前先检查对方是否想进入临界区,若对方想,则等待;若对方不想,则将自己的flag[i]置为true,再进入临界区;当Pi退出临界区时,将flag[i]置为false

    P0进程

    c 复制代码
    while(flag[1]);   //进入区
    flag[0] = true;	  //进入区
    critical section; //临界区
    flag[0] = false;  //退出区
    remainder section;//剩余区

    P1进程

    c 复制代码
    while(flag[0]);   //进入区
    flag[1] = true;	  //进入区
    critical section; //临界区
    flag[1] = false;  //退出区
    remainder section;//剩余区
    • 优点:不用交替进入,可连续使用
    • 缺点:两个进程可能同时进入临界区(取决于进程并发执行的顺序),违反"忙则等待"原则
  3. 双标志后检查法

    • 设置一个布尔型数组flag[2],用来标记各进程想进入临界区的意愿。
    • 进入临界区之前先对临界区上锁,然后再检查对方是否有意愿进入临界区

    P0进程

    c 复制代码
    flag[0] = true;	   //进入区
    while(flag[1]);    //进入区
    critical section;  //临界区
    flag[0] = false;   //退出区
    remainder section; //剩余区

    P1进程

    c 复制代码
    flag[1] = true;	   //进入区
    while(flag[0]);    //进入区
    critical section;  //临界区
    flag[1] = false;   //退出区
    remainder section; //剩余区
    • 两个进程不会同时进入临界区,但可能两个进程都进不去临界区,违反"空闲让进"和"有限等待"原则,造成死锁
  4. Peterson算法

    • 结合算法一与算法三,使用turn解决饥饿问题,使用flag解决互斥访问问题
    • flag[i]表示Pi想访问临界资源,turn=i表示谦让给Pi进入临界区

    P0进程

    c 复制代码
    flag[0] = true;
    turn = 1;
    while(flag[1] && turn==1);
    critical section;
    flag[0] = false;
    remainder section;

    P1进程

    c 复制代码
    flag[1] = true;
    turn = 0;
    while(flag[0] && turn==0);
    critical section;
    flag[1] = false;
    remainder section;
    • 先谦让的进程turn值会被后谦让的进程覆盖,不会出现死锁(先谦让的进程就是客气一下,后谦让的才是真谦让)
    • 解决了"空闲让进"、"忙则等待"、"有限等待"的问题,但依然未遵循["让权等待"](#2.3.1 同步与互斥的基本概念),不能进入临界区的进程仍然会被分配时间片,但是啥也不干,就忙等
硬件实现方法
  1. 中断屏蔽方法

    • CPU指在发生中断时引起进程切换,因此屏蔽中断能够保证当前运行在临界区代码顺利的执行完

      关中断
      临界区
      开中断

    优点:简单高效

    缺点:

    1. 不适用于多处理机
    2. 只适用于操作系统内核进程,不适用于用户进程(因为开/关中断指令只能运行在内核态)
  2. TestAndSet指令(TS/TSL)

    • 该指令用硬件实现,不允许中断
    • 不需要在关中断的状态下实现
    c 复制代码
    //使用共享变量lock表示当前临界区是否上锁
    bool TsetAndSet (bool *lock){
        bool old;
        old = lock;   //保存旧的lock值
        *lock = true; //无论之前是否上锁,都给临界区上锁
        return old;
    }
    c 复制代码
    while(TestAndSet(&lock)); //对临界区检查并上锁
    临界区代码
    lock = false;
    剩余区代码
    • 优点:实现简单;适用于多处理机
    • 缺点:不满足"让权等待"原则,无法进入临界区的进程会占用CPU并循环执行TSL指令,从而导致忙等
  3. Swap指令

    • 也叫Exchange指令,或简称XCHG指令
    • Swap指令也是用硬件实现的,执行过程不允许被中断
    c 复制代码
    Swap(bool *a, bool *b){
        bool temp;
        temp = *a;
        *a = *b;
        *b = temp;
    }
    c 复制代码
    //lock表示临界区是否上锁
    bool old = true;
    while(old == true)     //只有lock为false时,swap后的old为false,才能进入临界区
        Swap(&lock, &old);
    临界区代码
    lock = false;
    剩余区代码
    • 优点:实现简单,适用于多处理机环境
    • 缺点:不满足"让权等待"原则,无法进入临界区的进程会占用CPU并循环执行Swap指令,从而导致忙等

2.3.3 互斥锁

c 复制代码
acquire(){
    while(!available);
	available = false;
}
releease(){
    available = true;
}
  • acquire()和release()都是原子操作
  • 缺点是忙等待
  • 需要连续循环忙等待的互斥锁,都可称为自旋锁
  • 优点是等待锁期间不需要切换进程上下文,若上锁时间低,则等待代价低。常用于多处理器系统
  • 不太适用单处理器系统

2.3.4 信号量

信号量就是一个变量,可以用一个信号量来表示系统中某种资源的数量

可以用系统提供的一对原语wait/P和signal/V来操作信号量

PV操作由两个不可中断的过程组成

  1. 整型信号量

    • 用一个整型变量作为信号量
    • 只能对信号量进行初始化、P操作、V操作
    c 复制代码
    int S = 1;
    void wait(int S){
        while(S<=0);   //如果资源数不够,就一直循环等待
        S--;
    }
    void signal(int S){
        S++;	//使用完资源后,退出区释放资源
    }

    进程P

    c 复制代码
    wait(S);
    使用临界资源
    signal(S);
    • 整型信号量未遵循"让权等待"原则
  2. 记录型信号量

    • 使用value代表资源数量,使用进程链表L用于链接所有等待该资源的进程
    c 复制代码
    typedef struct{
        int value;
        struct process *L; //等待队列
    }semaphore;
    
    void wait(semaphore S){
        S.value--;
        if(S.value<0)   //如果当前的剩余资源不够
            block(S.L); //就阻塞该进程
    }
    
    void signal(semaphore S){
        S.value++;
        if(S.value<=0)	 //如果当前还有进程等待使用资源
            wakeup(S.L); //就把该进程唤醒
    }
    • 无法进入临界区的进程会被阻塞,不会占用CPU,符合"让权等待"原则
  3. 利用信号量实现进程互斥

    • 使用互斥信号量mutex,初始值为1,表示可以进入临界区的进程数
    • 进入临界区前先对mutex进行P操作,退出临界区后对mutex进行V操作,释放临界区
    c 复制代码
    semaphore mutex=1;
    P1(){
        ...
        P(mutex); //上锁
        临界区代码
        V(mutex); //解锁
        ...
    }
    P2(){
        ...
        P(mutex);
        临界区代码
        V(mutex);
        ...
    }
    • 对不用的临界资源需要设置不同的互斥信号量
    • P、V操作必须成对出现。缺少P操作不能保证互斥访问,缺少V操作会导致资源永不被释放,等待进程不被唤醒
  4. 利用信号量实现同步

    • 进程同步:要让各并发进程按要求有序的推进
    • 设置一个同步信号量S,初始值为0,表示初始没用这种资源,需要由进程产生这种资源
    • 在先执行的操作后执行V操作,产生资源S
    • 在后执行的操作前执行P操作,消耗资源S
    c 复制代码
    P1(){
        代码1;
        V(S);
    }
    P2(){
        P(S);
        代码2;
    }
  5. 利用信号量实现前驱关系

    • 每对前驱关系都是一个同步问题
    • 对每一对前驱关系设置一个同步信号量,按照同步关系实现"前V后P"
  6. 利用信号量实现资源分配问题

    • 设置信号量,由多少个资源就将信号量的初始值设为几
    • 申请资源时执行P操作,释放资源后执行V操作
  7. 分析进程同步和互斥问题的方法步骤

    1. 关系分析。找出问题中的进程数,分析他们之间的同步和互斥关系
    2. 整理思路。确定P、V操作流程
    3. 设置信号量。

2.3.5 经典同步问题

生产者-消费者问题
  • 问题描述:系统中有一组生产者和一组消费者进程,生产者进程每次生成一个产品放入缓冲区,消费者进程每次从缓冲区中取出一个产品;缓冲区满时,生产者进程阻塞等待;缓冲区空时,消费者进程阻塞等待。各进程必须互斥的访问缓冲区
  • 问题分析:
    1. 关系分析。生产者和消费者访问缓冲区是互斥的 ;同时又是相互协作的,只有生产者生产了,消费者才能消费,消费者消费后,生产者才能生产,存在两个同步关系
    2. 整理思路:使用两个信号量full、empty表示同步关系。生产者生产前消耗(P)一个空缓冲区empty,生产后产生(V)一个满缓冲区full;消费者消费前消耗(P)一个满缓冲区full,消费后产生(V)一个空缓冲区empty。使用互斥信号量mutex表示互斥关系,需要在进程拿到缓冲区资源后再尝试占用缓冲区❗️顺序不能倒
    3. 设置信号量。mutex表示互斥,初值为1。full表示满缓冲区数量,初始缓冲区均为空,初值为0;empty表示空缓冲区数量,初始均为空,初值为n。缓冲区的大小仅与empty的初值有关,与互斥信号量mutex无关
c 复制代码
semaphore mutex=1;
semaphore full=0;
semaphore empty=n; //空缓冲区的数量

producer(){
	while(1){
        P(empty);  //对两个信号量的P操作顺序不能颠倒
        P(mutex);
        把产品放入缓冲区;
        V(mutex);
        V(full);
    }    
}

consumer(){
    while(1){
        P(full);
        P(mutex);
        从缓冲区取出一个产品;
        V(mutex);
        V(empty);
    }
}
  • 若颠倒了两个P操作的顺序,可能会造成死锁 。比如,生产者进程先占据了缓冲区资源P(mutex),但在申请空缓冲区时发现缓冲区都满了,进程会阻塞在empty的等待队列中,不释放缓冲区mutex资源,导致其他进程都无法进入临界区。死锁了
  • V操作的顺序可以颠倒
  • 生产者-消费者问题用于解决多个进程之间的同步和互斥问题
多生产者-多消费者问题
  • 问题描述:一个盘子,只能放一个水果,父亲往里面放苹果,母亲往里面放橘子;儿子只吃苹果,女儿只吃橘子
  • 问题分析:
    1. 关系分析。所有进程对缓冲区(盘子)的访问需要互斥的进行。同步关系:父亲放完儿子吃,母亲放完女儿吃,只有盘子空时才能放。
    2. 思路整理。
      • 对于父母进程:设置信号量plate表示是否有空盘子用。设置互斥信号量mutex,确保互斥访问临界资源(盘子)。如果有空盘子用,就占用申请占用临界资源,然后访问临界区,最后产生相应的资源并释放临界区。
      • 对于子女进程:先查看水果资源,然后锁定临界区,取出水果后产生一个空盘子并释放临界区
    3. 设置信号量。互斥信号量mutex初值为1。plate表示空盘子数量,初始为1。apple、orange表示盘子里水果的数量,初始为0。
c 复制代码
semaphore mutex=1;  //互斥访问临界资源(盘子),可以去掉
semaphore apple=0;  //盘子里有几个苹果
semaphore orange=0; //盘子里有几个橘子
semaphore plate=1;  //有几个空盘子
dad(){
    while(1){
        准备一个苹果;
        P(plate);//申请一个盘子资源
        P(mutex);
        把苹果放入盘子;
        V(apple);//增加一个苹果资源
        V(mutex);//释放临界资源
    }
}

mom(){
    while(1){
        准备一个橘子;
        P(plate);//申请一个盘子资源
        P(mutex);
        把橘子放入盘子;
        V(orange);//增加一个橘子资源
        V(mutex);//释放临界资源
    }
}

son(){
    while(1){
        P(apple); //消耗一个苹果
        P(mutex); //申请临界区
        取出苹果;
        V(plate); //产生一个空盘子
        V(mutex); //释放临界资源
    }
}

daughter(){
    while(1){
        P(orange); //消耗一个橘子
        P(mutex);  //申请临界区
        取出橘子;
        V(plate);  //产生一个空盘子
        V(mutex);  //释放临界资源
    }
}
  • 互斥信号量mutex可以去掉。因为这里缓冲区的大小为1,plate、orange、apple在同一时刻只能有一个是1,最多只有一个进程不会被P操作阻塞
  • 缓冲区大小大于1的时候必须设置互斥信号量
  • 两个P操作的顺序不能反

去掉互斥信号量mutex后的进程同步实现:

c 复制代码
semaphore mutex=1;  //互斥访问临界资源(盘子)
semaphore apple=0;  //盘子里有几个苹果
semaphore orange=0; //盘子里有几个橘子
semaphore plate=1;  //有几个空盘子
dad(){
    while(1){
        准备一个苹果;
        P(plate);//申请一个盘子资源
        把苹果放入盘子;
        V(apple);//增加一个苹果资源
    }
}

mom(){
    while(1){
        准备一个橘子;
        P(plate);//申请一个盘子资源
        把橘子放入盘子;
        V(orange);//增加一个橘子资源
    }
}

son(){
    while(1){
        P(apple); //消耗一个苹果
        取出苹果;
        V(plate); //产生一个空盘子
    }
}

daughter(){
    while(1){
        P(orange); //消耗一个橘子
        取出橘子;
        V(plate);  //产生一个空盘子
    }
}
读者-写者问题
  • 问题描述:有读者和写者两个进程,共享一个文件,允许多个读进程同时访问;写进程只能单独访问;写进程完成前不允许其他进程工作;写操作开始前其他进程必须退出

  • 问题分析:

    1. 关系分析:写进程-写进程、写进程-读进程 互斥关系
    2. 思路整理。
      • 设置互斥信号量rw实现对文件的互斥访问。
      • 使用计数器count表示当前读进程的数量,第一个读进程进入时要对文件上锁,最后一个读进程退出时要解锁
      • 设置互斥信号量mutex实现对计数器count的互斥访问。若不互斥的访问count,可能导致if条件始终不满足,无法释放资源
    3. 设置信号量。
      • rw初始为1,表示有一个文件可以访问
      • count初始为0,表示没有读进程正在访问
      • mutex初始为1,表示有一个计数器count可供访问
    c 复制代码
    semaphore rw=1;		//实现对文件的互斥访问
    int count=0;		//记录当前有几个读进程在访问文件
    semaphore mutex=1;  //互斥访问count变量
    
    writer(){
        P(rw); //上锁
        写文件;
        V(rw); //解锁
    }
    
    reader(){
        while(1){
            P(mutex);	//各读进程互斥访问count
            if(count==0)
                P(rw);  //第一个读进程对文件上锁
            count++;
            V(mutex);
            读文件;
            P(mutex);  //互斥访问count
            count--;
            if(count==0)
                V(rw);  //最后一个读进程对文件解锁
            V(mutex);
        }
    }
    • 算法中读进程是优先的,只要有读进程在读,写进程就要等待,后到达的读进程也可能比写进程先执行
    • 为实现写进程优先,可以额外设置一个信号量w表示写优先
    c 复制代码
    semaphore rw=1;		//实现对文件的互斥访问
    int count=0;		//记录当前有几个读进程在访问文件
    semaphore mutex=1;  //互斥访问count变量
    semaphore w=1;		//实现写优先
    
    writer(){
        P(w);  //对w上锁
        P(rw); //上锁
        写文件;
        V(rw); //解锁
        V(w);  //对w解锁
    }
    
    reader(){
        while(1){
            P(w);		//对w上锁
            P(mutex);	
            if(count==0)
                P(rw);  
            count++;
            V(mutex);
            V(w);		//对w解锁
            读文件;
            P(mutex); 
            count--;
            if(count==0)
                V(rw);  
            V(mutex);
        }
    }
    • 设置了信号量w之后,先到达的读进程会被阻塞在w的队首,读进程释放了w之后,会优先唤醒队首的写进程,写进程进入rw的等待队列队首,在写进程之后到达的读进程仍被阻塞在w的等待队列中,直到写进程执行结束后释放w
哲学家进餐问题
  • 问题描述:圆桌上坐着五个哲学家,每两人之间摆一根筷子。哲学家饥饿时,会试图一根一根拿起左、右两根筷子进餐。如果筷子在别人手上,则需等待。只有使用两根筷子进餐完毕后,才会放下筷子继续思考

  • 问题分析:

    1. 关系分析:每相邻的两个哲学家对筷子的访问是互斥关系

    2. 整理思路。只存在互斥关系,但要注意避免死锁

      • 对哲学家和筷子从0~4编号,规定i号哲学家左手边为i号筷子,右手边为(1+1)%5号筷子

      有以下几种方法避免死锁:

      1. 至多允许四名哲学家同时进餐,保证至少有一名哲学家能够拿到两只筷子
      2. 仅当一名哲学家左右两边的筷子都可以使用时,才允许他拿起筷子
      3. 要求奇数号哲学家先拿左边的筷子,偶数号哲学家先拿右边筷子
    3. 设置信号量。chopstick[5]={1,1,1,1,1}用于实现对五个筷子的互斥访问

    第一种方法:

    • 使用变量seat表示座位数量,初始设置为4,表示只允许4个哲学家坐下就餐
    c 复制代码
    semaphore chopstick[5]={1,1,1,1,1};
    semaphore seat=4; //四个座位
    
    Pi(){
        P(seat);
        P(chopstick[i]);
        P(chopstick[(i+1)%5]);
        吃饭;
        V(chopstick[i]);
        V(chopstick[(i+1)%5]);
        V(seat);
    }

    第二种方法:

    • 设置互斥信号量mutex,哲学家拿筷子前先对筷子上锁,保证在同一时刻只有一个哲学家能拿筷子,且能顺利拿到一双筷子
    • 当多个进程尝试拿筷子时,先拿到mutex资源的进程会阻塞其他进程,并顺利拿起两只筷子。当前进程结束后会释放资源,其他进程可以执行
    c 复制代码
    semaphore chopstick[5]={1,1,1,1,1};
    semaphore mutex=1; //实现对筷子的互斥访问
    
    Pi(){
        do{
            P(mutex);
            P(chopstick[i]);		//拿左
            P(chopstick[(i+1)%5]);  //拿右
            V(mutex);
            吃饭;
            V(chopstick[i]);		//放左
            V(chopstick[(i+1)%5]);  //放右
        }while(1);
    }

2.3.6 管程

  1. 管程的定义

    • 利用共享数据结构抽象的表示系统中的共享资源,并将对该数据结构实施的操作定义为一组过程。进程对资源的申请、释放等操作都通过这组过程实现,还可以实现每次只有一个进程使用共享资源。这样一组资源管理程序叫做管程

    • 管程由四部分组成:

      1. 管程的名称
      2. 局部于管程内部的共享数据结构说明
      3. 对该数据结构进行操作的一组过程/函数
      4. 对局部于管程内部的共享数据设置初始值的语句
    • 管程的基本特征:

      1. 管程将对共享资源的操作封装起来
      2. 每次仅允许一个进程进入管程

      管程很像一个类class

    • 各进程互斥的进入管程是由编译器实现的

    • 管程对外界提供"入口",进程只能通过"入口"(函数)进入管程

  2. 条件变量

    • 当一个进程进入管程被阻塞后,直到阻塞原因被解除时,在此期间,若进程不释放管程,则其他进程无法进入管程。为此,将阻塞原因定义为条件变量
    • 每个条件变量保存一个等待队列,用于记录因该原因而阻塞的所有进程,对条件变量只能进行两种操作,wait和signal
    • x.wait:当对应的条件不满足时,正在调用管程的进程调用x.wait将自己插入x条件的等待队列中,并释放管程
    • x.signal:x对应的条件发生了编号,调用x.signal,唤醒一个因x条件而阻塞的进程
相关推荐
寻寻觅觅☆7 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
偷吃的耗子7 小时前
【CNN算法理解】:三、AlexNet 训练模块(附代码)
深度学习·算法·cnn
化学在逃硬闯CS8 小时前
Leetcode1382. 将二叉搜索树变平衡
数据结构·算法
ceclar1238 小时前
C++使用format
开发语言·c++·算法
Gofarlic_OMS9 小时前
科学计算领域MATLAB许可证管理工具对比推荐
运维·开发语言·算法·matlab·自动化
夏鹏今天学习了吗9 小时前
【LeetCode热题100(100/100)】数据流的中位数
算法·leetcode·职场和发展
忙什么果10 小时前
上位机、下位机、FPGA、算法放在哪层合适?
算法·fpga开发
董董灿是个攻城狮10 小时前
AI 视觉连载4:YUV 的图像表示
算法
ArturiaZ11 小时前
【day24】
c++·算法·图论
大江东去浪淘尽千古风流人物11 小时前
【SLAM】Hydra-Foundations 层次化空间感知:机器人如何像人类一样理解3D环境
深度学习·算法·3d·机器人·概率论·slam