408 · 操作系统 · 第 2 章(中)

同步与互斥

多个进程并发跑,会遇到两类麻烦:抢同一个资源(互斥),以及必须按先后次序配合(同步)。这一页从最朴素的软件锁讲起,一步步看每种做法漏了哪条原则,最后落到信号量和 P/V 大题。

互斥:抢同一个东西间接制约。mutex 初值 1,P、V 在同一个进程里夹住临界区
同步:规定谁先谁后直接制约。初值 0(或资源数),前操作之后 V,后操作之前 P,P、V 分在两个进程里
判断一种方法好不好对照四原则。软件方法、硬件指令、整型信号量都卡在让权等待(在 while 里忙等),记录型信号量才全部满足
一 · 演进导图

每一步都在解决上一步留下的问题

进程同步与互斥让并发进程正确地共享临界资源(互斥),并按规定次序协作(同步)
忙等派:软件方法 → 硬件方法进不去就在 while 里空转,一直占着 CPU

单标志法

违背空闲让进
做法
一个 turn 表示轮到谁,用完把 turn 交给对方
新问题
只能严格轮流。对方不想进,turn 就交不回来,自己也进不去

双标志先检查

违背忙则等待
做法
每人一个 flag[i] 表示“我想进”。先看对方的 flag,再把自己的 flag 置 true
新问题
“检查”和“上锁”是两步,中间能被打断:两人都看到对方没进,一起进去

双标志后检查

违背空闲让进违背有限等待
做法
先上锁(flag[i] = true),再检查对方
新问题
两人同时上锁,都看到对方想进,一起等,谁都进不去,产生饥饿

Peterson 算法

满足前三条不满足让权等待
做法
先表态 flag[i] = true,再谦让 turn = j;只有“对方也想进,且轮到对方”时才等
代价
等的时候仍在 while 里空转

硬件:关中断 / TSL / Swap

不满足让权等待
做法
把“检查 + 上锁”做成一条原子指令,从根上消除先检查法的漏洞
代价
TSL、Swap 仍忙等;关中断只适合单处理机、只能内核用

互斥锁(自旋锁)

忙等
做法
acquire() 拿锁、release() 放锁,两者原子执行
代价
拿不到锁就自旋;多处理机上锁时间很短时反而划算(不用切换进程)
阻塞派:信号量 → 管程进不去就把自己挂到队列里睡觉,把 CPU 让出来

整型信号量

不满足让权等待
做法
一个整数 S 表示资源数;wait:while(S<=0); S--; signal:S++;
新问题
S ≤ 0 时还是在 while 里空转

记录型信号量

四原则全满足
做法
value + 等待队列 L。P:value−−,<0 就 block 自己;V:value++,≤0 就 wakeup 一个
解决
没资源时阻塞,让出 CPU → 满足让权等待

三种用法

互斥 · 同步 · 前驱
互斥
mutex = 1,P/V 夹住临界区
同步
初值 0,前 V 后 P
前驱
每条边一个信号量

经典问题

大题主力
内容
生产者-消费者、苹果橘子、吸烟者、读者-写者、哲学家进餐
新问题
P/V 散在各个进程里,顺序写错就死锁,难写难查

管程

互斥由编译器保证
做法
把共享数据和操作它的过程封装在一起,每次只让一个进程进来;需要等待时用条件变量
好处
程序员不用自己写 mutex,不容易写错

下一页 → 死锁:P 的顺序写反会死锁,哲学家进餐就是死锁的经典例子。死锁的四个必要条件、银行家算法都在下一页。

二 · 基本概念

临界资源、临界区,以及“同步”和“互斥”到底差在哪

临界资源 vs 临界区

临界资源:一次只允许一个进程使用的资源,比如打印机、共享变量、缓冲区。
临界区:进程中访问临界资源的那段代码。临界区是代码,临界资源是资源,别混。

访问临界资源的代码可以拆成四段:

进入区
检查能不能进;能进就“上锁”,挡住别人
临界区
真正访问临界资源的代码
退出区
“解锁”,把进入权还回去
剩余区
其他与临界资源无关的代码
do {
    entry section;      // 进入区:上锁
    critical section;   // 临界区
    exit section;       // 退出区:解锁
    remainder section;  // 剩余区
} while (true);

同步 vs 互斥

同步互斥
又叫直接制约关系间接制约关系
起因进程之间相互合作,有先后次序进程之间共享资源,谁也不让谁
例子生产者先放,消费者才能取两个进程都要用打印机
信号量初值 0 或资源数;P、V 在不同进程初值 1;P、V 在同一进程成对

判别口诀:题干里有“必须在……之后”“等……完成才能”,是同步;有“不能同时”“一次只能一个”,是互斥。

互斥的四条原则(判断下面每个算法的尺子)

临界区空着空闲让进临界区没人,谁申请就让谁进。
临界区有人忙则等待有人在里面,其他人必须等。
等的人有限等待申请者要在有限时间内进去,不能饿死。
等的方式让权等待进不去时立即释放处理机,不要忙等。(原则性要求,不是必须)
三 · 软件实现方法

四个算法,每个都是在补上一个的洞

共享变量都在内存里,两个进程交替执行。下面代码里每一行都当作不可分割的一步;危险就出在“两行之间被切走”。

① 单标志法

违背空闲让进
int turn = 0; // 轮到谁 · P0
while (turn != 0);  // 进入区:不是我就空转
critical section;   // 临界区
turn = 1;           // 退出区:交给 P1
remainder section;  // 剩余区
P1
while (turn != 1);
critical section;
turn = 0;
remainder section;

turn 只在退出区被改成对方。P0 用完后,如果 P1 一直不来,turn 永远是 1,P0 想再进也进不去,而临界区明明空着。必须 P0 → P1 → P0 严格轮流。

② 双标志先检查法

违背忙则等待
bool flag[2] = {false, false}; · P0
while (flag[1]);    // ① 先检查:P1 想进吗
flag[0] = true;     // ② 再上锁
critical section;
flag[0] = false;    // 解锁
remainder section;
P1
while (flag[0]);    // ③
flag[1] = true;     // ④
critical section;
flag[1] = false;
remainder section;

按 ① ③ ② ④ 的顺序执行:两人检查时对方都还没上锁,于是都通过,然后一起进入临界区。根源:检查和上锁不是一个原子操作。

③ 双标志后检查法

违背空闲让进违背有限等待
P0
flag[0] = true;     // ① 先上锁
while (flag[1]);    // ② 再检查
critical section;
flag[0] = false;
remainder section;
P1
flag[1] = true;     // ③
while (flag[0]);    // ④
critical section;
flag[1] = false;
remainder section;

把上锁提前,忙则等待修好了。但按 ① ③ ② ④ 执行:两人都举了手,又都看到对方举手,一起谦让,谁也不进。临界区空着没人进(违背空闲让进),双方长期进不去(违背有限等待,产生饥饿)。

④ Peterson 算法

空闲让进 · 忙则等待 · 有限等待不满足让权等待
bool flag[2]; int turn; · P0
flag[0] = true;     // 表态:我想进
turn = 1;           // 谦让:你先请
while (flag[1] && turn == 1);  // 你也想进且轮到你,我才等
critical section;
flag[0] = false;
remainder section;
P1
flag[1] = true;
turn = 0;
while (flag[0] && turn == 0);
critical section;
flag[1] = false;
remainder section;

双标志后检查 + 单标志的 turn。两人同时想进时,turn 只能有一个值:最后写 turn 的那个人把机会让了出去,自己等,另一个进。对方不想进时 flag 为 false,直接进。剩下的缺点只有一个:等的时候还在 while 里空转。

算法核心变量违背的原则一句话原因
单标志turn空闲让进严格轮流,对方不进自己也进不了
双标志先检查flag[2]忙则等待先检查后上锁,两步之间被打断,两人一起进
双标志后检查flag[2]空闲让进 有限等待先上锁后检查,两人都上锁后互相等,产生饥饿
Petersonflag[2] + turn让权等待前三条都满足;等待时仍空转

四种软件方法等待时都在 while 里空转,所以都不满足让权等待。表里只列出各自额外违背的那条,是选择题最常考的对应关系。

四 · 动手看 · 交错执行器

你来当调度程序:让 P0、P1 以任意顺序走

每点一次按钮,对应进程执行一行。while 那一行条件成立时它原地空转(高亮变黄),不成立就往下走。试着排出让两人同时进临界区、或者两人都卡死的顺序。当前显示的是“双标志先检查”的反例已经跑完的结果。

turn0
flag[0]false
flag[1]false
临界区里
P0
P1
交错顺序
执行记录
    五 · 硬件实现方法

    把“检查 + 上锁”焊成一条指令

    软件方法的病根是检查和上锁之间会被打断。硬件的办法:要么干脆不让打断(关中断),要么提供一条执行过程中不可分割的指令(TSL、Swap)。

    中断屏蔽(关中断)

    关中断;
    critical section;
    开中断;

    进程切换靠中断触发。关了中断,当前进程就不会被切走,临界区自然独占。

    • 优点:简单、高效
    • 只适合单处理机:关中断只关本 CPU,别的 CPU 照样访问
    • 只适合内核进程:开/关中断是特权指令
    • 关中断时间长会影响系统效率

    TestAndSet(TSL)

    // 硬件执行,全程不可打断
    bool TestAndSet(bool *lock) {
        bool old = *lock;  // 记下原来的值
        *lock = true;      // 无论如何上锁
        return old;
    }
    // 使用
    while (TestAndSet(&lock));  // 原来是 true 就空转
    critical section;
    lock = false;
    remainder section;

    返回 false 说明原来没锁,而且这一步已经顺手锁上了:检查和上锁在一条指令里完成。

    Swap(XCHG)

    // 硬件执行,交换两个变量
    void Swap(bool *a, bool *b) {
        bool temp = *a;
        *a = *b;
        *b = temp;
    }
    // 使用
    bool key = true;
    while (key == true)
        Swap(&lock, &key);  // 把 lock 旧值换到 key
    critical section;
    lock = false;
    remainder section;

    逻辑和 TSL 相同:lock 被换成 true,旧值到了 key;key 为 false 说明抢到了。

    TSL / Swap 的得失

    • 优点:实现简单;适用于多处理机;一个进程可以有多个临界区,每个用一个 lock
    • 缺点:抢不到的进程在 while 里反复执行指令,不满足让权等待
    • 从等待者中挑谁进去是随机的,某个进程可能一直抢不到,导致饥饿

    互斥锁(mutex lock)

    bool available = true;  // 锁是否可用
    void acquire() {        // 原子执行
        while (!available); // 忙等
        available = false;
    }
    void release() {        // 原子执行
        available = true;
    }

    需要连续循环忙等的互斥锁都叫自旋锁(TSL、Swap、单标志法也是)。缺点是忙等,违背让权等待。优点是等待时不用切换进程;在多处理机上,如果上锁时间很短,自旋的代价比切换进程低。不适合单处理机:自旋时持锁进程根本上不了 CPU,时间片内不可能解锁。

    六 · 信号量

    一个计数器 + 一对原语 P、V

    信号量就是一个表示“某种资源还剩几个”的变量,只能用两个原语操作:P(S) 即 wait(S),申请一个;V(S) 即 signal(S),释放一个。原语执行时不可中断,所以不会出现先检查法那种漏洞。

    整型信号量

    不满足让权等待
    int S = 1;              // 可用资源数
    void wait(int S) {      // P 操作
        while (S <= 0);     // 没资源就空转
        S = S - 1;
    }
    void signal(int S) {    // V 操作
        S = S + 1;
    }

    和前面的软件、硬件方法一样,没资源时在 while 里忙等。S 的值不会小于 0,也不记录有几个人在等。

    记录型信号量

    满足让权等待
    typedef struct {
        int value;           // 剩余资源数
        struct process *L;   // 等待队列
    } semaphore;
    
    void wait(semaphore S) {       // P
        S.value--;
        if (S.value < 0)
            block(S.L);  // 把自己挂到 S.L,运行态 → 阻塞态
    }
    void signal(semaphore S) {     // V
        S.value++;
        if (S.value <= 0)
            wakeup(S.L); // 从 S.L 唤醒一个,阻塞态 → 就绪态
    }

    没资源就调用 block 自我阻塞,放弃处理机,满足让权等待。

    为什么 P 是“< 0”才阻塞,V 是“≤ 0”才唤醒?

    • P 先减 1。减完 < 0,说明减之前 ≤ 0,本来就没资源了 → 阻塞。
    • V 先加 1。加完 ≤ 0,说明加之前 < 0,队列里有人在等 → 唤醒一个。

    记法:先改值,再判断;P 看“< 0”,V 看“≤ 0”。

    value 的含义

    3正数:还有 3 个资源可用
    0资源刚好用完,没人在等
    −2负数:绝对值 = 2 个进程在 L 里等

    常考:初值为 k 的资源信号量,被 n 个进程各 P 一次,value 的取值范围是 k−n ~ k。互斥信号量(k = 1)就是 1−n ~ 1。

    七 · 信号量的三种用法

    互斥夹住,同步前 V 后 P,前驱每条边一个

    实现互斥

    初值 1
    semaphore mutex = 1;
    P1() {
        ...
        P(mutex);   // 上锁
        临界区;
        V(mutex);   // 解锁
        ...
    }
    P2() { 同上 }
    • P、V 在同一个进程里,成对出现
    • 缺 P:互斥失效;缺 V:资源永不释放,别人一直阻塞
    • 不同的临界资源要设不同的 mutex

    实现同步

    初值 0
    // 要求:代码 x 先于代码 y
    semaphore S = 0;
    P1() {
        代码 x;
        V(S);       // x 做完,发信号
    }
    P2() {
        P(S);       // 没收到信号就等
        代码 y;
    }
    • 口诀:前操作之后 V,后操作之前 P
    • P2 先到:P(S) 使 S = −1,阻塞;P1 做完 x 后 V(S) 把它唤醒
    • P1 先到:V(S) 使 S = 1;P2 来了直接通过

    实现前驱关系

    每条边一个,初值 0
    ab cd gef S1 S2 S3 S4 S5 S6

    前驱图的代码(信号量 a ~ g 初值全为 0)

    P1() { S1; V(a); V(b); }
    P2() { P(a); S2; V(c); V(d); }
    P3() { P(b); S3; V(g); }
    P4() { P(c); S4; V(e); }
    P5() { P(d); S5; V(f); }
    P6() { P(e); P(f); P(g); S6; }

    做法

    • 前驱关系本质是多个同步关系:每条边 A → B 就是“A 做完 B 才能做”
    • 每条边设一个同步信号量,初值 0
    • 边的起点做完后 V,边的终点开始前 P
    • 入边有几条就 P 几次(S6 有 3 条入边,P 三次);出边有几条就 V 几次
    八 · 生产者-消费者

    一个缓冲区,两个同步关系,一个互斥关系

    生产者往大小为 n 的缓冲区放产品,消费者取。缓冲区满了生产者得等,空了消费者得等,而且同一时刻只能有一个进程访问缓冲区。

    关系分析

    • 互斥:生产者、消费者对缓冲区的访问互斥 → mutex = 1
    • 同步 1:缓冲区有空位,生产者才能放 → empty = n
    • 同步 2:缓冲区有产品,消费者才能取 → full = 0
    信号量初值含义
    mutex1互斥访问缓冲区
    emptyn空闲缓冲区的数量
    full0产品数(满缓冲区的数量)

    关键点

    • 生产者 P(empty) … V(full),消费者 P(full) … V(empty):一个进程 P 的,正是另一个进程 V 的
    • 先同步 P,后互斥 P。写反会死锁,下面模拟器里能亲眼看到
    • V 的顺序可以互换,不会死锁
    • “生产一个产品”“使用产品”放在临界区外,缩短临界区
    semaphore mutex = 1;  // 互斥访问缓冲区
    semaphore empty = n;  // 空闲缓冲区数
    semaphore full  = 0;  // 产品数
    
    producer() {
        while (1) {
            生产一个产品;
            P(empty);     // 要一个空位
            P(mutex);     // 进缓冲区
            把产品放入缓冲区;
            V(mutex);     // 出缓冲区
            V(full);      // 产品数 +1
        }
    }
    
    consumer() {
        while (1) {
            P(full);      // 要一个产品
            P(mutex);
            从缓冲区取出一个产品;
            V(mutex);
            V(empty);     // 空位 +1
            使用产品;
        }
    }

    动手看 · 缓冲区 n = 5

    每点一次按钮,对应进程完整跑一轮循环,直到跑完或被阻塞。被唤醒的进程接着从阻塞处往下执行。当前显示的是:生产者连续生产,把缓冲区塞满后,第 7 个产品放不进去,生产者阻塞在 empty 的队列里。

    producer
    consumer
    执行记录
      九 · 其他经典问题

      都是生产者-消费者的变形,差别在关系怎么找

      多生产者-多消费者(苹果橘子)

      桌上一个盘子,每次只能放一个水果。爸爸只放苹果,妈妈只放橘子;女儿只吃苹果,儿子只吃橘子。盘子空时爸妈才能放;盘里是自己要的水果时,儿女才能取。

      信号量初值含义
      plate1盘子里还能放几个水果(同步)
      apple0盘中苹果数(同步)
      orange0盘中橘子数(同步)
      mutex1互斥访问盘子(容量为 1 时可省)

      关键点

      • “多”指多类:不同进程要的产品不同,所以苹果、橘子各一个信号量
      • 从事件看关系:“盘子变空”可以由儿子或女儿引发,不用分别给爸妈各设一个
      • 缓冲区容量为 1 时,plate 最多为 1,同一时刻最多一个进程能过 P(plate) 或 P(apple/orange),所以 mutex 可以省;容量 > 1 必须加 mutex
      semaphore mutex = 1, plate = 1;
      semaphore apple = 0, orange = 0;
      
      dad() {
          while (1) {
              准备一个苹果;
              P(plate);
              P(mutex);
              把苹果放入盘子;
              V(mutex);
              V(apple);
          }
      }
      mom() {             // 与 dad 对称
          while (1) {
              准备一个橘子;
              P(plate);
              P(mutex);
              把橘子放入盘子;
              V(mutex);
              V(orange);
          }
      }
      daughter() {
          while (1) {
              P(apple);
              P(mutex);
              从盘中取出苹果;
              V(mutex);
              V(plate);   // 盘子空了
              吃掉苹果;
          }
      }
      son() {             // 与 daughter 对称
          while (1) {
              P(orange);
              P(mutex);
              从盘中取出橘子;
              V(mutex);
              V(plate);
              吃掉橘子;
          }
      }

      吸烟者问题

      三个抽烟者分别只有烟草、纸、胶水中的一种。供应者每次把另外两种材料放到桌上,缺这两种的那位抽烟者拿走、卷烟、抽完后通知供应者,供应者再放下一组。三位抽烟者轮流抽。

      信号量初值含义
      offer10桌上组合一(纸 + 胶水)的数量,给有烟草的 1 号
      offer20组合二(烟草 + 胶水),给有纸的 2 号
      offer30组合三(烟草 + 纸),给有胶水的 3 号
      finish0抽烟是否完成

      关键点

      • 本质:一个生产者、生产多种产品,每种产品对应一个消费者
      • 把“两种材料”看成一个组合,一个组合一个信号量
      • “轮流”用整型变量 i 实现,每次 i = (i + 1) % 3
      • 桌子容量为 1,同一时刻只有一个组合在桌上,不需要 mutex
      semaphore offer1 = 0, offer2 = 0, offer3 = 0;
      semaphore finish = 0;
      int i = 0;          // 轮到哪位抽烟者
      
      provider() {
          while (1) {
              if (i == 0) {
                  把组合一放桌上;
                  V(offer1);
              } else if (i == 1) {
                  把组合二放桌上;
                  V(offer2);
              } else {
                  把组合三放桌上;
                  V(offer3);
              }
              i = (i + 1) % 3;
              P(finish);  // 等抽烟者抽完
          }
      }
      smoker1() {
          while (1) {
              P(offer1);
              从桌上拿走组合一; 卷烟; 抽掉;
              V(finish);
          }
      }
      smoker2() {         // P(offer2),其余同 smoker1
          while (1) {
              P(offer2);
              从桌上拿走组合二; 卷烟; 抽掉;
              V(finish);
          }
      }
      smoker3() {         // P(offer3),其余同 smoker1
          while (1) {
              P(offer3);
              从桌上拿走组合三; 卷烟; 抽掉;
              V(finish);
          }
      }

      读者-写者问题(读优先)

      多个读者可以同时读文件;写者写的时候,其他读者、写者都不能访问;写者写之前,已有的读者、写者都要退出。

      变量初值含义
      rw1互斥访问文件(读-写、写-写互斥)
      count0整型变量:正在读的读者数
      rmutex1互斥访问 count(王道原书叫 mutex)

      关键点

      • 核心是 count:第一个读者负责 P(rw) 挡住写者,最后一个读者负责 V(rw) 放行写者
      • “检查 count == 0”和“count++”必须一气呵成,否则两个读者可能都以为自己是第一个,都去 P(rw),第二个被阻塞。所以用 rmutex 把这两步包起来
      • 缺点:只要读者源源不断,count 永远不归零,写者可能饿死
      semaphore rw = 1;      // 文件的互斥
      int count = 0;         // 正在读的读者数
      semaphore rmutex = 1;  // 保护 count
      
      writer() {
          while (1) {
              P(rw);
              写文件;
              V(rw);
          }
      }
      reader() {
          while (1) {
              P(rmutex);
              if (count == 0)  // 第一个读者
                  P(rw);       // 替所有读者挡住写者
              count++;
              V(rmutex);
      
              读文件;
      
              P(rmutex);
              count--;
              if (count == 0)  // 最后一个读者
                  V(rw);       // 放写者进来
              V(rmutex);
          }
      }

      读者-写者(写优先)

      在读优先的基础上再加一个信号量 w = 1,读者和写者进门前都先 P(w)。

      • 写者来了先 P(w),并一直占着 w 直到写完。它之后到达的读者卡在 P(w) 上,不能再“插队”进去读
      • 写者只需等当前正在读的读者读完,就能进去,不会饿死
      • 严格说这是“读写公平”:大家在 w 上按到达顺序排队。王道称为写优先
      semaphore w = 1;       // 新增:实现写优先
      
      writer() {
          while (1) {
              P(w);
              P(rw);
              写文件;
              V(rw);
              V(w);
          }
      }
      reader() {
          while (1) {
              P(w);          // 有写者在排队就进不来
              P(rmutex);
              if (count == 0) P(rw);
              count++;
              V(rmutex);
              V(w);          // 进门后立刻放开 w
              读文件;
              P(rmutex);
              count--;
              if (count == 0) V(rw);
              V(rmutex);
          }
      }

      哲学家进餐问题

      5 名哲学家围坐圆桌,每两人之间一根筷子。哲学家 i 左边是 chopstick[i],右边是 chopstick[(i+1)%5],拿到左右两根才能吃。

      信号量初值含义
      chopstick[5]各 1每根筷子的互斥
      limit4方案一:同时拿筷子的人数上限
      mutex1方案三:拿筷子这件事整体互斥

      为什么朴素写法会死锁

      每人先拿左、再拿右。若 5 人同时拿起左筷子,每人都在等右边那根,而右边那根正被邻居拿着:循环等待,死锁。

      三种防死锁方案

      • 最多 n − 1 = 4 人同时拿筷子:5 根筷子分给 4 个人,至少有一人能拿到两根
      • 奇偶号不同顺序:奇数号先左后右,偶数号先右后左。相邻两人先争同一根,抢输的直接阻塞,手里不握筷子
      • 拿两根筷子整体互斥:用 mutex 把“拿左 + 拿右”包起来。更准确的说法是“各哲学家拿筷子这件事互斥执行”:某人拿到一根后等另一根时占着 mutex,别人连拿都不能拿
      semaphore chopstick[5] = {1, 1, 1, 1, 1};
      
      // 朴素写法:可能死锁
      Pi() {
          while (1) {
              P(chopstick[i]);         // 拿左
              P(chopstick[(i+1)%5]);   // 拿右
              吃饭;
              V(chopstick[i]);
              V(chopstick[(i+1)%5]);
              思考;
          }
      }
      
      // 方案一:最多 4 人同时拿
      semaphore limit = 4;
      Pi() {
          while (1) {
              P(limit);
              P(chopstick[i]);
              P(chopstick[(i+1)%5]);
              吃饭;
              V(chopstick[i]);
              V(chopstick[(i+1)%5]);
              V(limit);
              思考;
          }
      }
      
      // 方案二:奇数号先左后右,偶数号先右后左
      Pi() {
          while (1) {
              if (i % 2 == 1) {
                  P(chopstick[i]);
                  P(chopstick[(i+1)%5]);
              } else {
                  P(chopstick[(i+1)%5]);
                  P(chopstick[i]);
              }
              吃饭;
              V(chopstick[i]);
              V(chopstick[(i+1)%5]);
              思考;
          }
      }
      
      // 方案三:拿筷子整体互斥
      semaphore mutex = 1;
      Pi() {
          while (1) {
              P(mutex);
              P(chopstick[i]);
              P(chopstick[(i+1)%5]);
              V(mutex);
              吃饭;
              V(chopstick[i]);
              V(chopstick[(i+1)%5]);
              思考;
          }
      }
      十 · 管程

      把 P/V 收进一个“房间”,由编译器管门

      信号量的 P/V 分散在各个进程里,顺序错一个就死锁。管程把共享数据和操作它的过程封装在一起,进程只能通过调用这些过程访问共享数据,而且同一时刻只允许一个进程在管程内执行。

      组成(四部分)

      1. 管程的名字
      2. 局部于管程的共享数据结构说明
      3. 对该数据结构进行操作的一组过程(函数)
      4. 对局部共享数据设置初始值的语句

      基本特征

      • 局部数据只能被管程内的过程访问
      • 进程只有调用管程内的过程,才能进入管程访问共享数据
      • 每次只允许一个进程在管程内执行某个过程,这个互斥由编译器实现,程序员不用写
      • 管程内等待某条件时,用条件变量 x:x.wait 阻塞并释放管程,x.signal 唤醒一个在 x 上等的进程

      用管程写生产者-消费者

      monitor ProducerConsumer {
          condition full, empty;  // 条件变量
          int count = 0;          // 产品数
      
          void insert(Item item) {
              if (count == N) wait(full);   // 满了就等
              count++;
              insert_item(item);
              if (count == 1) signal(empty); // 空→非空,叫醒消费者
          }
          Item remove() {
              if (count == 0) wait(empty);  // 空了就等
              count--;
              if (count == N - 1) signal(full); // 满→不满,叫醒生产者
              return remove_item();
          }
      }
      producer() { while (1) { item = 生产; ProducerConsumer.insert(item); } }
      consumer() { while (1) { item = ProducerConsumer.remove(); 使用 item; } }
      对比项信号量 P / V条件变量 wait / signal
      有没有值有 value,记录剩余资源数没有值,只有一个等待队列
      等待P:value − 1,结果 < 0 才阻塞wait:一定阻塞,并释放管程
      唤醒V:value + 1;没人等也会加上,这次 V 被“记住”signal:唤醒一个等待者;没人等就什么也不做,这次 signal 丢失
      资源剩多少value 本身管程里的共享变量(如 count),wait 前先用 if 判断
      互斥由谁做程序员写 P(mutex) / V(mutex)编译器保证管程内每次只有一个进程
      十一 · 做 P/V 大题

      先找关系,再设信号量,最后写代码

      信号量的定义(含义 + 初值)通常单独给分。代码没写完,只要关系和信号量写对,也能拿到一部分分。

      关系分析有几类进程?每类进程在干什么?逐对找:谁和谁互斥(抢同一资源),谁和谁同步(谁必须在谁之后)。
      设信号量每个互斥关系一个 mutex = 1;每个同步关系一个信号量,初值 = 一开始就有的资源数(没有就是 0)。每个都写含义和初值。题干给了字母(n、m)就用字母。
      写代码每个进程写成 while (1) { … }。同步:前操作之后 V,后操作之前 P;互斥:P/V 贴着临界区。最后按下面的自检表过一遍。

      为什么同步的 P 必须在互斥的 P 之前

      把消费者写成先 P(mutex) 再 P(full),缓冲区为空时:

      步谁操作结果
      1消费者P(mutex)mutex 1→0,进入缓冲区
      2消费者P(full)full 0→−1,阻塞,手里还拿着 mutex
      3生产者P(empty)empty n→n−1,通过
      4生产者P(mutex)mutex 0→−1,阻塞

      消费者等生产者的 V(full),生产者等消费者的 V(mutex),谁都醒不了:死锁。生产者先 P(mutex) 再 P(empty) 也一样,在缓冲区满时死锁。回到模拟器打开开关看一遍。

      V 操作不会阻塞,所以两个 V 的顺序可以随便换。

      交卷前自检表

      • 每个信号量都写了含义和初值
      • 互斥信号量初值为 1
      • 同步信号量初值 = 初始资源数,题干给字母就写字母
      • 同步的 P 在互斥的 P 之前
      • 每个 P 都有配对的 V(同步的 V 可能在另一个进程里)
      • 共享的整型变量(count 等)读写都在互斥里
      • 生产、使用等耗时操作放在临界区外
      • 每个进程是 while(1) 循环(题目只做一次的除外)
      • 从初始状态手动走一遍:有没有进程一开始就永远阻塞
      • 有没有两个进程互相 P 对方要 V 的信号量(死锁)
      十二 · 易错速记 & 信号量一览表

      选择题直接对表

      临界区是代码,临界资源是资源访问临界资源的那段代码叫临界区。
      先检查 vs 后检查,别记反先检查 → 两人一起进 → 违背忙则等待;后检查 → 两人都不进 → 违背空闲让进和有限等待。
      Peterson 只差一条满足空闲让进、忙则等待、有限等待;不满足让权等待。
      谁满足让权等待只有记录型信号量(和管程)。软件方法、TSL、Swap、互斥锁、整型信号量都是忙等。
      关中断的两个限制只适合单处理机;只能内核用(特权指令)。
      自旋锁不适合单处理机多处理机上,锁持有时间短时,自旋省去了进程切换的开销。
      P:先减,<0 阻塞;V:先加,≤0 唤醒value = −3 表示有 3 个进程在等;value = 2 表示还有 2 个资源。
      value 的范围初值 k,n 个进程各 P 一次:k−n ~ k。
      初值怎么定互斥 1;同步看一开始有几个资源,没有就 0;前驱图每条边 0。
      顺序同步 P 在前,互斥 P 在后;两个 V 的顺序无所谓。
      条件变量没有值signal 在没人等时什么也不做;V 一定会让 value 加 1。
      管程的互斥谁来做编译器。同一时刻只有一个进程在管程内执行。
      读优先的代价读者源源不断时写者饿死;加 w 信号量实现写优先(读写公平)。
      哲学家朴素写法5 人同时拿左筷子 → 死锁。三种解法:限 4 人、奇偶反向、拿筷子整体互斥。
      问题信号量初值含义类型
      生产者-消费者mutex1互斥访问缓冲区互斥
      emptyn空闲缓冲区数同步
      full0产品数同步
      苹果橘子plate1盘子还能放几个水果同步
      apple0盘中苹果数同步
      orange0盘中橘子数同步
      mutex1互斥访问盘子(容量 1 时可省)互斥
      吸烟者offer1/2/30桌上组合 1/2/3 的数量同步
      finish0抽烟完成同步
      读者-写者rw1文件的读写、写写互斥互斥
      rmutex1保护 count互斥
      count0正在读的读者数(整型变量,不是信号量)计数
      w1写优先时新增,挡住写者之后到来的读者互斥
      哲学家进餐chopstick[5]各 1每根筷子互斥
      limit4方案一:最多 4 人同时拿筷子资源数
      mutex1方案三:拿筷子动作整体互斥互斥
      前驱图每条边一个0边的起点做完 V,终点开始前 P同步