Skip to content

管程与条件变量

字数
4180 字
阅读时间
17 分钟

引言:为何需要超越信号量?

知识回顾:信号量(Semaphore)通过 down()(P操作)和 up()(V操作)为我们提供了强大的并发控制能力。然而,直接使用信号量来编写正确的并发程序是一项极具挑战性的任务。

  • 【重点】 信号量的核心困境
    • 棘手的逻辑 🤔:正确地对多个信号量的 down() 操作进行排序,以避免死锁,是非常困难的。
    • 代码的脆弱性 💔:up()down() 操作在代码中是分离的,程序员的任何疏忽(如忘记 up())都可能导致死锁或互斥失效。
    • 目标模糊 🎯:信号量机制本身并未清晰地区分其用于互斥还是同步

正因为这些困难,计算机科学家们开始寻求一种更高级、更不易出错的抽象机制来管理并发。这便引出了我们的主角——管程(Monitor)


第一章:管程(Monitor)—— 更高级的同步抽象

1.1 🧐 什么是管程?

【重点】 管程是一种高级的同步原语,它将共享资源及其上的操作封装在一个编程结构中,并自动保证了对这些资源的互斥访问

1.1.1 核心思想:封装与互斥

管程的设计借鉴了面向对象编程的思想,其核心在于两点:

  1. 封装 (Encapsulation) 📦:

    • 管程将共享数据(如缓冲区)以及所有能访问这些数据的过程(或方法)打包在一起。
    • 外部线程只能通过调用管程提供的入口方法来访问内部的共享数据,无法直接触及。
  2. 互斥 (Mutual Exclusion) 🛡️:

    • 这是管程最强大的特性。编译器会自动为管程的所有入口方法添加“锁”机制。
    • 当一个线程调用管程的任何入口方法时,它必须首先获取管程的内部互斥锁。
    • 这就从根本上消除了程序员手动加锁、解锁时可能犯的错误。

1.1.2 管程的基本结构

一个管程可以被想象成一个特殊的“房间”,里面保护着共享数据。线程需要通过唯一的入口进入,并且房间里一次只能有一个线程。 ![[Pic/Pasted image 20251015093136.png]] [图 1.1:管程示例结构图,源于原文第4页]

1.2 ✍️ 使用管程解决生产者-消费者问题(初次尝试)

让我们尝试用一个简化的管程来解决经典的生产者-消费者问题。注意,这里的 sleep()wakeup() 还是操作系统提供的基本原语,尚未引入条件变量。

cpp
monitor ProducerConsumer {
    // 共享数据
    private buffer, in, out;

    // 入口方法 send
    public send(message msg) {
        while (in - out == N) { // 如果缓冲区满
            sleep(); // 发送者休眠
        }
        buffer[in % N] = msg;
        in = in + 1;
        if (in == out + 1) { // 如果之前缓冲区是空的
            wakeup(receiverThread); // 唤醒消费者
        }
    }

    // 入口方法 receive
    public message receive() {
        while (in == out) { // 如果缓冲区空
            sleep(); // 消费者休眠
        }
        msg = buffer[out % N];
        out = out + 1;
        if (in - out == N - 1) { // 如果之前缓冲区是满的
            wakeup(senderThread); // 唤醒生产者
        }
        return msg;
    }
}

1.2.1 🚨 问题与陷阱:在管程内休眠

上述实现存在一个致命缺陷。

【核心考点】 考虑当缓冲区已满时,生产者线程调用 send 方法:

  1. 生产者线程进入管程,获得互斥锁。
  2. 它发现缓冲区已满,于是调用 sleep() 使自己进入休眠状态。
  3. 问题来了:由于生产者在管程内部休眠了,它并没有释放管程的互斥锁
  4. 后果:消费者线程即使想进入管程消费数据,也无法获取锁,因此它永远无法进入管程去唤醒沉睡的生产者。这就造成了永久的阻塞

这个问题的根源在于,我们混淆了两种不同的并发需求:互斥(一次一个线程访问)和同步(线程间等待某个条件成立)。管程本身只解决了互斥,我们需要一个新工具来处理同步。


第二章:条件变量(Condition Variable)—— 实现线程的同步与协作

2.1 💡 引入条件变量

为了解决在管程内部等待的问题,我们引入了一个与管程紧密配合的新机制:条件变量 (Condition Variable)

【重点】 条件变量不是锁,它是一个让线程能够原子地释放锁并进入等待状态的队列。它专门用于解决同步问题。

这样,其他线程就可以进入管程,修改共享数据,当条件满足时,再通过该条件变量唤醒等待的线程。

2.1.1 定义与核心操作

每个条件变量通常支持三种原子操作:

  • condition.wait() 😴:

    1. 释放管程的互斥锁。
    2. 将当前线程阻塞,并放入该条件变量的等待队列中。
    3. 当被唤醒后,它会重新尝试获取管程的互斥锁,获取成功后才能继续执行。
  • condition.signal() 📣:

    • 从该条件变量的等待队列中,唤醒一个正在等待的线程(如果有的话)。
    • 被唤醒的线程会从 wait() 的阻塞状态中返回,并尝试重新获取锁。
    • 发出信号的线程并不会立即释放锁。
  • condition.broadcast() 📢:

    • 从该条件变量的等待队列中,唤醒所有正在等待的线程。
    • 这在多个线程可能都满足条件的情况下非常有用。

2.2 ✅ 使用管程与条件变量重解生产者-消费者问题

现在,我们可以用这个强大的工具来完美地解决生产者-消费者问题了。

【核心考点】

cpp
monitor ProducerConsumer {
    // 共享数据
    private buffer, in, out;

    // 两个条件变量
    Condition full;  // 用于表示缓冲区已满
    Condition empty; // 用于表示缓冲区为空

    // 入口方法 send
    public send(message msg) {
        if (in - out == N) { // 如果缓冲区满
            full.wait();     // 等待在 full 条件上 (原子地释放锁并阻塞)
        }
        buffer[in % N] = msg;
        in = in + 1;
        empty.signal();      // 唤醒一个可能在 empty 上等待的消费者
    }

    // 入口方法 receive
    public message receive() {
        if (in == out) {     // 如果缓冲区空
            empty.wait();    // 等待在 empty 条件上 (原子地释放锁并阻塞)
        }
        msg = buffer[out % N];
        out = out + 1;
        full.signal();       // 唤醒一个可能在 full 上等待的生产者
        return msg;
    }
}

代码解析

  • 当生产者发现缓冲区满了,它调用 full.wait()。此时它会释放管程锁,允许消费者进入。
  • 当消费者取走一个数据后,缓冲区不再是满的,它调用 full.signal(),这会唤醒在 full 上等待的生产者。
  • 消费者的逻辑与此对称。

第三章:管程的语义之争 —— Hoare vs. Mesa

3.1 🤔 一个悬而未决的问题:signal 之后谁先运行?

我们刚才的解决方案看似完美,但忽略了一个微妙的细节。假设线程 A (生产者) 在管程中执行了 empty.signal(),唤醒了正在等待的线程 B (消费者)。

此时管程内有两个都准备好运行的线程:A 和 B。但管程的互斥原则决定了只有一个能运行。那么,谁继续运行,谁等待呢?

这个问题引出了管程的两种不同设计语义。

3.2 👨‍🏫 Hoare 语义 (强语义)

由管程的发明者 Tony Hoare 提出。

  • 规则:发出 signal() 的线程 (A) 立即将管程的控制权和锁交给被唤醒的线程 (B),而自己则进入等待状态,直到 B 离开管程或再次等待。
  • 保证:这种语义保证了当被唤醒的线程 (B) 恢复执行时,它等待的那个条件肯定是真的。因为从 A 发出信号到 B 开始运行,中间没有其他线程可以进入管程来改变这个条件。
  • 编码风格:由于有此强保证,我们在 wait() 返回后,可以使用 if 语句来检查条件。

[图 3.1:Hoare 语义流程图,源于原文第23页]

3.3 🛠️ MESA 语义 (弱语义)

在 Xerox PARC 的 Mesa 语言中首次实现,也是现代编程语言(如 Java, C#)普遍采用的语义。

  • 规则:发出 signal() 的线程 (A) 继续持有锁并执行。被唤醒的线程 (B) 只是从等待队列被移动到就绪队列,它必须和其他新来的线程一样,重新竞争管程的锁。
  • 保证:Mesa 语义的唯一保证是线程被唤醒了。它不保证当线程 B 最终获得锁并恢复执行时,它等待的条件仍然成立。因为在 A 释放锁和 B 获得锁之间,可能有其他线程(比如另一个生产者)抢先进入管程,再次把缓冲区填满了。
  • 编码风格:由于没有强保证,我们在 wait() 返回后,必须使用 while 循环来重新检查条件是否满足。

[图 3.2:MESA 语义流程图,源于原文第25页]

3.4 🚨 【核心考点】if vs while:两种语义下的代码差异

Mesa 语义下的生产者-消费者代码必须做出修改:

cpp
// MESA 语义版本
monitor ProducerConsumer {
    ...
    // 入口方法 send
    public send(message msg) {
        // 必须使用 while 循环重新检查条件
        while (in - out == N) {
            full.wait();
        }
        ...
    }

    // 入口方法 receive
    public message receive() {
        // 必须使用 while 循环重新检查条件
        while (in == out) {
            empty.wait();
        }
        ...
    }
}

3.5 ☕ Java 中的管程【了解】

Java 语言内置了管程的概念,但实现方式略有不同:

  • 任何 Java 对象都可以作为管程。使用 synchronized 关键字标记的方法或代码块即为管程的入口。
  • 每个对象只有一个内部锁和一个等待队列。
  • Object.wait()Object.notify()Object.notifyAll() 分别对应 wait()signal()broadcast() 操作。
  • Java 采用的是 MESA 语义

[图 3.3:Java 风格管程示意图,源于原文第33页]


第四章:高级同步问题与实践

4.1 📚 读者-写者问题

【重点】 这是一个经典的并发问题,其约束条件如下:

  • 共享资源:一块可以被多个线程读或写的数据区。
  • 两类线程
    • 读者 (Reader):只读取数据。
    • 写者 (Writer):修改数据。
  • 约束规则
    1. 多个读者可以同时读取资源。
    2. 写者必须独占资源(写的时候不能有其他读者或写者)。

根据读者和写者被饿死的可能性,该问题分为读者优先写者优先读写公平三种策略。

4.1.1 读者优先策略的实现

  • 策略:只要有一个读者正在读,就允许后续的读者进入,写者必须等待所有读者都离开。这种策略可能导致写者被“饿死”。
  • 实现思路(使用信号量):
    • wlock:一个互斥信号量,用于写者之间以及第一个读者与写者之间的互斥。
    • readers:一个计数器,记录当前正在读取的读者数量。
    • rlock:一个互斥信号量,用于保护对 readers 计数器的修改。
    • 关键逻辑
      • 第一个进入的读者需要获取 wlock,从而阻止写者。
      • 最后一个离开的读者需要释放 wlock,从而允许等待的写者进入。

第五章:并发程序中的常见错误与修复

【核心考点】 编写并发程序极易出错。常见的错误可以归为三类:

5.1 💥 原子性错误 (Atomicity Bugs)

  • 定义:本应作为一个原子操作执行的代码序列,被其他线程中断,导致状态不一致。
  • 例子
    c
    // 线程 T1
    if (shared > 23) {
        // 在这里,T1 可能被中断
        printf("Value: %d\n", shared);
    }
    // 线程 T2
    shared = 12;
    如果 T1 在 if 判断后、printf 之前被中断,T2 修改了 shared 的值,那么 T1 恢复后打印出的值就会是 12,这与 if 的判断条件相矛盾。
  • 修复:使用(如互斥信号量 mutex)来保护访问共享资源的临界区,确保检查和使用的操作是原子的。

5.2 ➡️ 违反顺序错误 (Order Violation Bugs)

  • 定义:代码执行的顺序与程序员预期的逻辑顺序不一致。
  • 例子
    c
    // 线程 1
    mThread = CreateThread(...);
    mThread->State = READY;
    // 线程 2
    mState = mThread->State;
    如果线程2在线程1完成 mThread 的初始化之前就执行了,那么 mThread 可能是一个空指针,导致程序崩溃。
  • 修复:使用条件变量来确保某个动作(如初始化)在另一个动作(如使用)发生之前已经完成。线程1完成初始化后 signal,线程2在执行前 wait

5.3 ⛓️ 死锁 (Deadlock)

  • 定义:两个或多个线程相互等待对方持有的资源,导致所有线程都无法继续执行。
  • 经典例子(不一致的加锁顺序):
    c
    // 线程 1
    lock(L1);
    lock(L2);
    // 线程 2
    lock(L2);
    lock(L1);
    如果线程1获得了 L1,同时线程2获得了 L2,那么它们将永远相互等待对方的锁。
  • 修复:确保所有线程都以相同(全局)的顺序来请求锁。

总结

  • 同步原语的演进 🚀:我们从困难且易错的信号量,演进到了更高级、更安全的管程和条件变量。
  • 各有所长 🛠️:自旋锁、信号量、条件变量(配合管程)都能实现同步,但适用场景和性能各不相同。
    • 自旋锁:适用于锁持有时间极短的场景。
    • 信号量:强大的底层工具,但使用复杂。
    • 管程/条件变量:构建复杂同步逻辑的首选,代码更清晰、安全。
  • 并发编程的挑战 🧗:同步问题是并发编程的核心挑战,违反原子性、违反顺序和死锁是必须时刻警惕的三大陷阱。

贡献者

页面历史