# 条件变量机制 - Open-Source-OS-Training-Camp-2022 文档

Hide navigation sidebar
Hide table of contents sidebar
Toggle table of contents sidebar

# 条件变量机制

## 本节导读

到目前为止，我们已经了解了操作系统提供的互斥锁和信号量。但应用程序在使用这两者时需要非常小心， 如果使用不当，就会产生效率低下、竞态条件、死锁或者其他一些不可预测的情况。为了简化编程、避免错误， 计算机科学家针对某些情况设计了一种更高层的同步互斥原语。具体而言，在有些情况下， 线程需要检查某一条件（condition）满足之后，才会继续执行。

我们来看一个例子，有两个线程 first 和 second 在运行，线程 first 会把全局变量 A 设置为 1，而线程 second 在 `A != 0` 的条件满足后，才能继续执行，如下面的伪代码所示：

     1static mut A: usize = 0;
     2unsafe fn first() -> ! {
     3    A=1;
     4    ...
     5}
     6
     7unsafe fn second() -> ! {
     8    while A==0 {
     9      // 忙等或睡眠等待 A==1
    10    };
    11    //继续执行相关事务
    12}

在上面的例子中，如果线程 second 先执行，会忙等在 while 循环中，在操作系统的调度下，线程 first 会执行并把 A 赋值为 1 后，然后线程 second 再次执行时，就会跳出 while 循环，进行接下来的工作。 配合互斥锁，可以正确完成上述带条件的同步流程，如下面的伪代码所示：

     1static mut A: usize = 0;
     2unsafe fn first() -> ! {
     3    mutex.lock();
     4    A=1;
     5    mutex.unlock();
     6    ...
     7}
     8
     9unsafe fn second() -> ! {
    10    mutex.lock();
    11    while A==0 {
    12        mutex.unlock();
    13        // give other thread a chance to lock
    14        mutex.lock();
    15    };
    16    mutex.unlock();
    17    //继续执行相关事务
    18}

这种实现能执行，但效率低下，因为线程 second 会忙等检查，浪费处理器时间。我们希望有某种方式让线程 second 休眠，直到等待的条件满足，再继续执行。于是，我们可以写出如下的代码：

     1static mut A: usize = 0;
     2unsafe fn first() -> ! {
     3    mutex.lock();
     4    A=1;
     5    wakup(second);
     6    mutex.unlock();
     7    ...
     8}
     9
    10unsafe fn second() -> ! {
    11    mutex.lock();
    12    while A==0 {
    13       wait();
    14    };
    15    mutex.unlock();
    16    //继续执行相关事务
    17}

粗略地看，这样就可以实现睡眠等待了。但请同学仔细想想，当线程 second 在睡眠的时候， `mutex` 是否已经上锁了？ 确实，线程 second 是带着上锁的 `mutex` 进入等待睡眠状态的。 如果这两个线程的调度顺序是先执行线程 second，再执行线程first，那么线程 second 会先睡眠且拥有 `mutex` 的锁；当线程 first 执行时，会由于没有 `mutex` 的锁而进入等待锁的睡眠状态。 结果就是两个线程都睡了，都执行不下去，这就出现了 死锁 。

这里需要解决的两个关键问题： 如何等待一个条件？ 和 在条件为真时如何向等待线程发出信号 。 我们的计算机科学家给出了 管程（Monitor） 和 条件变量（Condition Variables） 这种巧妙的方法。接下来，我们就会深入讲解条件变量的设计与实现。

## 条件变量的基本思路

管程有一个很重要的特性，即任一时刻只能有一个活跃线程调用管程中的过程， 这一特性使线程在调用执行管程中过程时能保证互斥，这样线程就可以放心地访问共享变量。 管程是编程语言的组成部分，编译器知道其特殊性，因此可以采用与其他过程调用不同的方法来处理对管程的调用. 因为是由编译器而非程序员来生成互斥相关的代码，所以出错的可能性要小。

管程虽然借助编译器提供了一种实现互斥的简便途径，但这还不够，还需要一种线程间的沟通机制。 首先是等待机制：由于线程在调用管程中某个过程时，发现某个条件不满足，那就在无法继续运行而被阻塞。 其次是唤醒机制：另外一个线程可以在调用管程的过程中，把某个条件设置为真，并且还需要有一种机制， 及时唤醒等待条件为真的阻塞线程。为了避免管程中同时有两个活跃线程， 我们需要一定的规则来约定线程发出唤醒操作的行为。目前有三种典型的规则方案：

- Hoare 语义：线程发出唤醒操作后，马上阻塞自己，让新被唤醒的线程运行。注：此时唤醒线程的执行位置还在管程中。

- Hansen 语义：是执行唤醒操作的线程必须立即退出管程，即唤醒操作只可能作为一个管程过程的最后一条语句。 注：此时唤醒线程的执行位置离开了管程。

- Mesa 语义：唤醒线程在发出行唤醒操作后继续运行，并且只有它退出管程之后，才允许等待的线程开始运行。 注：此时唤醒线程的执行位置还在管程中。

一般开发者会采纳 Brinch Hansen 的建议，因为它在概念上更简单，并且更容易实现。这种沟通机制的具体实现就是 条件变量 和对应的操作：wait 和 signal。线程使用条件变量来等待一个条件变成真。 条件变量其实是一个线程等待队列，当条件不满足时，线程通过执行条件变量的 wait 操作就可以把自己加入到等待队列中，睡眠等待（waiting）该条件。另外某个线程，当它改变条件为真后， 就可以通过条件变量的 signal 操作来唤醒一个或者多个等待的线程（通过在该条件上发信号），让它们继续执行。

早期提出的管程是基于 Concurrent Pascal 来设计的，其他语言如 C 和 Rust 等，并没有在语言上支持这种机制。 我们还是可以用手动加入互斥锁的方式来代替编译器，就可以在 C 和 Rust 的基础上实现原始的管程机制了。 在目前的 C 语言应用开发中，实际上也是这么做的。这样，我们就可以用互斥锁和条件变量， 来重现上述的同步互斥例子：

     1static mut A: usize = 0;
     2unsafe fn first() -> ! {
     3    mutex.lock();
     4    A=1;
     5    condvar.wakup();
     6    mutex.unlock();
     7    ...
     8}
     9
    10unsafe fn second() -> ! {
    11    mutex.lock();
    12    while A==0 {
    13       condvar.wait(mutex); //在睡眠等待之前，需要释放mutex
    14    };
    15    mutex.unlock();
    16    //继续执行相关事务
    17}

有了上面的介绍，我们就可以实现条件变量的基本逻辑了。下面是条件变量的 wait 和 signal 操作的伪代码：

    1fn wait(mutex) {
    2    mutex.unlock();
    3    <block and enqueue the thread>;
    4    mutex.lock();
    5}
    6
    7fn signal() {
    8    <unblock a thread>;
    9}

条件变量的wait操作包含三步，1. 释放锁；2. 把自己挂起；3. 被唤醒后，再获取锁。条件变量的 signal 操作只包含一步：找到挂在条件变量上睡眠的线程，把它唤醒。

注意，条件变量不像信号量那样有一个整型计数值的成员变量，所以条件变量也不能像信号量那样有读写计数值的能力。 如果一个线程向一个条件变量发送唤醒操作，但是在该条件变量上并没有等待的线程，则唤醒操作实际上什么也没做。

## 实现条件变量

### 使用 condvar 系统调用

我们通过例子来看看如何实际使用条件变量。下面是面向应用程序对条件变量系统调用的简单使用， 可以看到对它的使用与上一节介绍的信号量系统调用类似。 在这个例子中，主线程先创建了初值为 1 的互斥锁和一个条件变量，然后再创建两个线程 First 和 Second。线程 First 会先睡眠 10ms，而当线程 Second 执行时，会由于条件不满足执行条件变量的 wait 操作而等待睡眠；当线程 First 醒来后，通过设置 A 为 1，让线程 second 等待的条件满足，然后会执行条件变量的 signal 操作，从而能够唤醒线程 Second。 这样线程 First 和线程 Second 就形成了一种稳定的同步与互斥关系。

     1static mut A: usize = 0;   //全局变量
     2
     3const CONDVAR_ID: usize = 0;
     4const MUTEX_ID: usize = 0;
     5
     6unsafe fn first() -> ! {
     7    sleep(10);
     8    println!("First work, Change A --> 1 and wakeup Second");
     9    mutex_lock(MUTEX_ID);
    10    A=1;
    11    condvar_signal(CONDVAR_ID);
    12    mutex_unlock(MUTEX_ID);
    13    ...
    14}
    15unsafe fn second() -> ! {
    16    println!("Second want to continue,but need to wait A=1");
    17    mutex_lock(MUTEX_ID);
    18    while A==0 {
    19        condvar_wait(CONDVAR_ID, MUTEX_ID);
    20    }
    21    mutex_unlock(MUTEX_ID);
    22    ...
    23}
    24pub fn main() -> i32 {
    25    // create condvar & mutex
    26    assert_eq!(condvar_create() as usize, CONDVAR_ID);
    27    assert_eq!(mutex_blocking_create() as usize, MUTEX_ID);
    28    // create first, second threads
    29    ...
    30}
    31
    32pub fn condvar_create() -> isize {
    33    sys_condvar_create(0)
    34}
    35pub fn condvar_signal(condvar_id: usize) {
    36    sys_condvar_signal(condvar_id);
    37}
    38pub fn condvar_wait(condvar_id: usize, mutex_id: usize) {
    39    sys_condvar_wait(condvar_id, mutex_id);
    40}

- 第 26 行，创建了一个 ID 为 `CONDVAR_ID` 的条件量，对应第 33 行 `SYSCALL_CONDVAR_CREATE` 系统调用；

- 第 19 行，线程 Second 执行条件变量 `wait` 操作（对应第 39 行 `SYSCALL_CONDVAR_WAIT` 系统调用）， 该线程将释放 `mutex` 锁并阻塞；

- 第 5 行，线程 First 执行条件变量 `signal` 操作（对应第 36 行 `SYSCALL_CONDVAR_SIGNAL` 系统调用）， 会唤醒等待该条件变量的线程 Second。

### 实现 condvar 系统调用

操作系统如何实现条件变量系统调用呢？在线程的眼里，条件变量是一种每个线程能看到的共享资源， 且在一个进程中，可以存在多个不同条件变量资源，所以我们可以把所有的条件变量资源放在一起让进程来管理， 如下面代码第9行所示。这里需要注意的是： `condvar_list: Vec<Option<Arc<Condvar>>>` 表示的是条件变量资源的列表。而 `Condvar` 是条件变量的内核数据结构，由等待队列组成。 操作系统需要显式地施加某种控制，来确定当一个线程执行 `wait` 操作和 `signal` 操作时， 如何让线程睡眠或唤醒线程。在这里， `wait` 操作是由 `Condvar` 的 `wait` 方法实现，而 `signal` 操作是由 `Condvar` 的 `signal` 方法实现。

     1pub struct ProcessControlBlock {
     2    // immutable
     3    pub pid: PidHandle,
     4    // mutable
     5    inner: UPSafeCell<ProcessControlBlockInner>,
     6}
     7pub struct ProcessControlBlockInner {
     8    ...
     9    pub condvar_list: Vec<Option<Arc<Condvar>>>,
    10}
    11pub struct Condvar {
    12    pub inner: UPSafeCell<CondvarInner>,
    13}
    14pub struct CondvarInner {
    15    pub wait_queue: VecDeque<Arc<TaskControlBlock>>,
    16}
    17impl Condvar {
    18    pub fn new() -> Self {
    19        Self {
    20            inner: unsafe { UPSafeCell::new(
    21                CondvarInner {
    22                    wait_queue: VecDeque::new(),
    23                }
    24            )},
    25        }
    26    }
    27    pub fn signal(&self) {
    28        let mut inner = self.inner.exclusive_access();
    29        if let Some(task) = inner.wait_queue.pop_front() {
    30            add_task(task);
    31        }
    32    }
    33    pub fn wait(&self, mutex:Arc<dyn Mutex>) {
    34        mutex.unlock();
    35        let mut inner = self.inner.exclusive_access();
    36        inner.wait_queue.push_back(current_task().unwrap());
    37        drop(inner);
    38        block_current_and_run_next();
    39        mutex.lock();
    40    }
    41}

首先是核心数据结构：

- 第 9 行，进程控制块中管理的条件变量列表。

- 第 15 行，条件变量的核心数据成员：等待队列。

然后是重要的三个成员函数：

- 第 18 行，创建条件变量，即创建了一个空的等待队列。

- 第 27 行，实现 `signal` 操作，将从条件变量的等待队列中弹出一个线程放入线程就绪队列。

- 第 33 行，实现 `wait` 操作，释放 `mutex` 互斥锁，将把当前线程放入条件变量的等待队列， 设置当前线程为挂起状态并选择新线程执行。在恢复执行后，再加上 `mutex` 互斥锁。

Hansen, Per Brinch (1993). “Monitors and concurrent Pascal: a personal history”. HOPL-II: The second ACM SIGPLAN conference on History of programming languages. History of Programming Languages. New York, NY, USA: ACM. pp. 1–35. doi:10.1145/155360.155361. ISBN 0-89791-570-4.