同步与互斥:进程间的两种制约关系

本篇对应王道第二章同步部分(原 10 篇)。核心问题:进程同步是什么?不同进程间有什么关系?怎么用信号量把关系管起来?

一、同步与互斥是什么

多道程序环境下进程并发执行,不同进程间存在相互制约关系,为协调这些关系引入进程同步。

直观理解:系统里有加法进程和乘法进程,要结果正确必须先乘后加。但 OS 有异步性,不加以制约的话加法进程完全可能先跑——所以需要制约机制。

你和朋友编辑同一份文档,不沟通谁何时改,就会出现互相覆盖;进程同步就是防止这种问题发生在程序上的机制。

临界资源与临界区

一次仅允许一个进程使用的资源叫临界资源——打印机、共享变量、共享数据都是。对临界资源的访问必须互斥进行,访问它的那段代码叫临界区

访问过程分四部分:

do{
    entry section;      // 进入区:检查能否进入,能则上锁(设"正在访问"标志)
    critical section;   // 临界区:访问临界资源的代码
    exit section;       // 退出区:清除"正在访问"标志
    remainder section;  // 剩余区:其他代码
}while(true);

同步:合作导致的制约

同步也称直接制约关系:为完成同一任务而建立的多个进程,因协调工作次序而等待、传递信息。源于相互合作

例子:输入进程 A 通过单缓冲向进程 B 供数据——缓冲区空时 B 阻塞,A 放入数据后唤醒 B;缓冲区满时 A 阻塞,B 取走数据后唤醒 A。

互斥:争抢导致的制约

互斥也称间接制约关系:一个进程进临界区用临界资源时,另一个必须等。源于资源共享(争抢)。

例子:仅一台打印机,A 要打印时打印机已分给 B,A 阻塞;B 释放后系统唤醒 A。

简而言之:进程间合作导致的制约叫同步,进程间争抢资源导致的制约叫互斥。

同步机制四准则

准则 含义
空闲让进 临界区空闲时,允许一个请求进入的进程立即进入
忙则等待 已有进程在临界区,其他试图进入的必须等
有限等待 请求访问的进程能在有限时间内进入(不能饿死)
让权等待 进不去时应立即释放处理机,防止忙等待

后面每种方法都拿这四条来打分。

二、互斥的实现(软件)

思路:进入区设置标志,用循环检查等待;退出区修改标志。

单标志法

一个整型变量 turn 指示轮到谁:turn=0 时 P0 进,turn=1 时 P1 进;用完把 turn 让给对方。

// P0:                          // P1:
while(turn != 0);               while(turn != 1);
critical section;               critical section;
turn = 1;                       turn = 0;
remainder section;              remainder section;

问题:不满足"空闲让进"——对方不要进临界区,turn 永远不变,我就永远进不去。临界区只能强制交替使用

双标志法先检查

每人一个标志 flag[]先检查对方想不想进,再设置自己想进:

// P0:                          // P1:
while(flag[1]);                 while(flag[0]);
flag[0] = true;                 flag[1] = true;
critical section;               critical section;
flag[0] = false;                flag[1] = false;

好处是不用交替进入、可连续使用。问题:违背"忙则等待"——两进程几乎同时检查,都看到对方标志为 false,双双进入,互斥失效。

双标志法后检查

交换顺序:先设置自己想进,再检查对方:

// P0:                          // P1:
flag[0] = true;                 flag[1] = true;
while(flag[1]);                 while(flag[0]);
critical section;               critical section;
flag[0] = false;                flag[1] = false;

互斥保住了,问题:违背"空闲让进"和"有限等待"——同时想进时双方互相谦让,谁也进不了,饥饿("礼让"事故)。

Peterson 算法

单标志 + 双标志后检查的结合:flag 解决互斥,turn 解决饥饿。想进时先插自己的旗,再说一句**"轮到你先"**:

// P0:                          // P1:
flag[0] = true;                 flag[1] = true;
turn = 1;                       turn = 0;
while(flag[1] && turn == 1);    while(flag[0] && turn == 0);
critical section;               critical section;
flag[0] = false;                flag[1] = false;

双方都想进时,turn 只能是一方的值——谦让有先后,turn 是最后表态的那个,于是唯一的一方进入。四准则全满足(除"让权等待",它还在忙等)。

缺点:依赖原子操作、只适用于两个进程。

硬件实现

中断屏蔽

CPU 只在中断时进行进程切换,那最简单直接暴力的方式——把中断关了

void enter_critical_section() { disable_interrupts(); /* 临界区 */ }
void leave_critical_section() { enable_interrupts();  /* 剩余区 */ }

简单直接,但限制了处理机交替执行程序的能力,效率明显降低;关中断的权力不能交给用户——某进程关了中断不再开,系统可能因此终止。只适用于单处理机、不适合长临界区。

Test-and-Set(TS)

原子地"读旧值 + 置 1":

int test_and_set(int *lock) {
    int original = *lock;
    *lock = 1;
    return original;
}
// 加锁: while(test_and_set(&lock));   解锁: lock = 0;

锁为 0 时第一个调用者拿到旧值 0 进入,其余人拿到 1 忙等。适合短时间互斥。

Compare-and-Swap(CAS)

原子地"比较预期旧值,相同则换新值":

int compare_and_swap(int *value, int expected, int new_value) {
    int temp = *value;
    if (*value == expected) *value = new_value;
    return temp;
}
// 加锁: while(compare_and_swap(&lock, 0, 1) != 0);

比 TS 更适合复杂同步模式(无锁数据结构)。另有 LL/SC 原语(Load-Link 读值,Store-Conditional 仅当地址未被改过才写入),能检测读写间是否发生冲突。

硬件方法小结

优点:适用于任意数目进程、单/多处理机都行;简单、易验证正确性;支持多个临界区(每区一个布尔变量)。缺点:等待时消耗处理机时间、不能让权等待(忙等);从等待进程中随机选一个进入,可能饥饿。

互斥锁(mutex lock)

解决临界区最简单的工具:进临界区 acquire() 拿锁,出临界区 release() 放锁:

acquire()  { while(!available) ; available = false; }
release()  { available = true; }

两个操作必须原子(通常用硬件机制实现)。主要缺点还是忙等待——多个进程共享一个 CPU 时白白浪费周期,所以互斥锁常用于多处理器系统(一个核上忙等,不妨碍别的核干活)。

三、信号量

信号量机制功能更强,互斥与同步都能解决。只能被两个标准原语访问:wait(S) 和 signal(S),即 P 操作和 V 操作(P=Proberen 尝试,V=Verhogen 增加)——进程通信那一篇提过,这里展开。

整型信号量 → 忙等

wait(S)   { while(S <= 0); S = S - 1; }   // S<=0 就一直测
signal(S) { S = S + 1; }

问题一目了然:S≤0 时不断测试,不遵循"让权等待",进程处于忙等;而且没有等待队列

记录型信号量 → 真正可用

整型变量 value 之外,再加一个进程链表 L 链接所有等待进程:

typedef struct {
    int value;
    struct process *L;
} semaphore;

void wait(semaphore S) {        // P操作
    S.value--;                  // 请求一个该类资源
    if (S.value < 0) {          // 资源分配完毕
        add this process to S.L;
        block(S.L);             // 自我阻塞,放弃处理机
    }
}

void signal(semaphore S) {      // V操作
    S.value++;                  // 释放一个资源
    if (S.value <= 0) {         // 仍有进程在等
        remove a process P from S.L;
        wakeup(P);              // 唤醒队首等待进程
    }
}

读法:S.value 的值就是剩余资源数;减完小于 0,说明有人没分到,去排队;V 完仍 ≤0,说明队列里还有人,唤醒一个。这下"让权等待"落实了——阻塞而不是忙等。

三种用法

实现同步:A 必须等 B 完成后才能执行——信号量初值 0,B 干完 signal(sem),A 开头 wait(sem)

semaphore sem = 0;
// B: do_something(); signal(sem);
// A: wait(sem); continue_work();

实现互斥:初值 1,进出临界区夹 P/V:

semaphore mutex = 1;
// wait(mutex); critical_section(); signal(mutex);

互斥是不同进程对同一信号量做 P/V:某进程 P 成功进入,退出后由它自己 V,表示可让其他进程进了。

实现前驱关系:每条前驱边设一个初值 0 的信号量,前件末尾 V、后件开头 P:

image-239cf7ad
semaphore a1=a2=b1=b2=c=d=e=0;
S1(){ ……; V(a1); V(a2); }
S2(){ P(a1); ……; V(b1); V(b2); }
S3(){ P(a2); ……; V(c); }
S4(){ P(b1); ……; V(d); }
S5(){ P(b2); ……; V(e); }
S6(){ P(c); P(d); P(e); ……; }

小结口诀

  • 同步问题:某行为要用到某种资源 → 行为前面 P 一下;某行为会提供某种资源 → 行为后面 V 一下
  • 互斥问题:PV 操作紧夹使用互斥资源的那个行为,中间不能夹其他冗余代码

四、管程

信号量机制的问题:每个要访问临界资源的进程都得自备同步的 PV 操作,大量分散的同步操作不好管,还容易因操作不当导致死锁。

于是有了管程:用共享数据结构抽象表示系统中的共享资源,把对该数据结构的操作定义成一组过程,进程对资源的申请释放都通过这组过程——统一管理所有访问,互斥由管程保证,无须程序员自己实现

管程很像一个类

monitor Demo {
    共享数据结构 S;              // 局部于管程内部的共享数据
    init_code() { S = 5; }      // 初始化语句
    take_away() { S--; }        // 过程:申请资源
    give_back() { S++; }        // 过程:归还资源
}
  • 共享数据只能被管程内的过程访问;进程只有通过管程内的过程才能进入
  • 每次只允许一个进程进入管程(各进程串行执行管程内的过程)——互斥自动保证

条件变量

进程进管程后可能被阻塞,若它不释放管程,别人也进不来。于是把阻塞原因定义为条件变量 condition,每个条件变量对应一个等待队列。两个操作:

  • x.wait:条件不满足时,调用进程插入 x 的等待队列,并释放管程(别人能用)
  • x.signal:条件变化时,唤醒一个因 x 阻塞的进程
monitor Demo {
    共享数据结构 S;  condition x;
    take_away() {
        if (S <= 0) x.wait();   // 资源不够,在x上阻塞等待并释放管程
        dosomething;
    }
    give_back() {
        归还资源 dosomething;
        if (有进程在等待) x.signal();
    }
}

条件变量 vs 信号量(高频考点):wait/signal 相似,都能阻塞唤醒;但条件变量没有值,只实现排队等待;信号量有值,反映剩余资源数——管程里剩余资源数由共享数据结构记录。

五、经典同步问题

生产者-消费者

生产者往共享缓冲区放数据,消费者取走处理。挑战:缓冲区满不能写、空不能读。三个信号量:

  • empty = N(空闲位置数)、full = 0(已占用数)——管"同步"
  • mutex = 1(互斥访问缓冲区)——管"互斥"
semaphore empty = N, full = 0, mutex = 1;

void producer() {
    while (true) {
        item = produce_item();
        wait(empty);                    // 先同步:等空位
        wait(mutex);                    // 再互斥:锁缓冲区
        put_item_into_buffer(item);
        signal(mutex);
        signal(full);                   // 通知:多了一件
    }
}

void consumer() {
    while (true) {
        wait(full);                     // 等数据
        wait(mutex);
        item = take_item_from_buffer();
        signal(mutex);
        signal(empty);                  // 通知:空了一个位
        consume_item(item);
    }
}

⚠️ P 操作顺序不能颠倒:先 wait(mutex)wait(empty),缓冲区满时生产者会拿着锁去等空位——死锁。同步 P 在前,互斥 P 在后。

读者-写者

多个读者可同时读,写者必须独占。核心技巧:计数器 + 第一个/最后一个读者负责加解锁

semaphore rw_mutex = 1;   // 写者与"第一/最后读者"竞争
semaphore mutex = 1;      // 保护 read_count
int read_count = 0;

void reader() {
    while (true) {
        wait(mutex);
        read_count++;
        if (read_count == 1) wait(rw_mutex);   // 第一个读者锁资源
        signal(mutex);
        read_data();
        wait(mutex);
        read_count--;
        if (read_count == 0) signal(rw_mutex); // 最后一个读者放资源
        signal(mutex);
    }
}

void writer() {
    while (true) {
        wait(rw_mutex);    // 写者直接独占
        write_data();
        signal(rw_mutex);
    }
}

公平性权衡:基本版里读者源源不断时写者饥饿。改进思路:加队列/写者优先规则(如读写公平法,写者到达后堵住后续读者)。这个问题的价值就在权衡效率、公平与简单性。

哲学家进餐

五位哲学家围圆桌,每两人之间一根筷子,进餐需要同时拿左右两根。5 根筷子 = 5 个初值 1 的信号量:

semaphore chopstick[5] = {1,1,1,1,1};

void philosopher(int i) {
    while (true) {
        think();
        wait(chopstick[i]);             // 先左
        wait(chopstick[(i+1) % 5]);     // 再右
        eat();
        signal(chopstick[i]);
        signal(chopstick[(i+1) % 5]);
    }
}

naive 版本的问题:五人同时拿起左边筷子,全在等右边——死锁。经典解法:只让一位哲学家(如编号最大的)先拿右边再拿左边,破坏"循环等待",至少保证一人能凑齐两根。

这个思路其实和贪心是相反的——贪心强调争取眼前最好的,不考虑后果;哲学家问题若用贪心解,眼前有筷子能拿就拿,就会死锁。若不仅考虑眼前一步、还考虑下一步——能不能一次拿起两根筷子才做决定——就能避开死锁。这是哲学家问题的精髓:多想一步,而不是见好就抢。

吸烟者

三个吸烟者各拥有无限量的一种材料(烟草/纸/火柴各一),卷一支烟需要两种材料。供应者每次随机放两种材料上桌,拥有第三种材料的吸烟者拿走、卷、抽,然后发信号让供应者继续:

semaphore agentSem = 1;
semaphore tobacco = 0, paper = 0, match = 0;

void agent() {                      // 供应者:随机放两种
    while (true) {
        wait(agentSem);
        if (random() == 0)      { signal(tobacco); signal(paper); }
        else if (random() == 1) { signal(paper);   signal(match); }
        else                    { signal(tobacco); signal(match); }
    }
}

void smoker_with_tobacco() {        // 有烟草的人:等纸和火柴
    while (true) {
        wait(paper); wait(match);
        smoke();
        signal(agentSem);           // 抽完叫供应者继续
    }
}
// smoker_with_paper / smoker_with_match 同理,等待各自缺的两种

四个问题的关注点各不相同:

问题 关注点
生产者-消费者 缓冲管理、满不写空不读的协调
读者-写者 读写公平性与效率、防写者饿死
哲学家进餐 循环等待的死锁避免、资源公平分配
吸烟者 按需分配资源、供应者与消费者的接力协调

它们都是同一件事的不同切面:多进程如何安全有效地共享资源、协调操作,各自给出一种避免竞争条件、死锁和饥饿的设计视角。

六、盲点

  1. P 操作顺序是送命题:同步信号量的 P 在前,互斥信号量的 P 在后;先锁后等 = 拿着资源等资源,死锁预备役
  2. 信号量 S.value 的含义:>0 剩余资源数;<0 其绝对值 = 排队等待的进程数(这个负数含义选择题常考)
  3. 记录型信号量才满足"让权等待",整型信号量是忙等
  4. 管程里的条件变量没有值——值在共享数据结构里;信号量的值反映剩余资源
  5. 单标志法违反"空闲让进"(强制交替);双标志先检查违反"忙则等待"(都能进);双标志后检查违反"空闲让进+有限等待"(互相谦让全饿着);Peterson 全满足但忙等
  6. 硬件方法互斥绝对可靠,但都忙等,且随机选人可能饥饿
  7. 哲学家问题与贪心相反:能拿不一定拿,先想"能否一次拿齐"
  8. 练习题有 30 多页……先把大概知识点过一遍,二轮再来细究——本篇先立框架,题感靠二轮

资源管不好会怎样?极端情况就是死锁 → 05-死锁

⬅️ 🏠 00-操作系统总览 ➡️ 05-死锁