rCore OS Note - Chapter 8

第八章:并发, 阅读 rCore tutorial book v3 的笔记以及实践部分的实现与记录。

这个章节整理完 Tutorial 还有一个与 I/O 设备管理相关的第九章, 准备之后慢慢琢磨然后不断扩展内核功能, 连续一个月高强度 rCore 的学习心理上有些浮躁了。 最近想把多年未竟的 FOC 项目捡起来好好研究一下, 顺带用上 FreeRTOS 用另一个角度看 OS。

0. 资料汇总

1. Thread Coroutine OS

Thread Coroutine OS 增加了在用户态管理的用户态线程/用户态协程, 以及在内核态管理的用户态线程。

Thread Coroutine OS details, rCore
Thread Coroutine OS details, rCore

1.1 线程

线程 能够将一个进程内多个可并行执行的任务通过能细粒度的方式被操作系统调度, 实现进程内的并发执行。 进程是线程的资源容器, 线程成为了程序的基本执行实体。 一个进程可以包含多个线程, 同属于一个进程的线程能够共享进程的资源, 如地址空间, 文件等。 线程基本上由线程ID、 执行状态、 当前指令指针(PC)、 寄存器集合和栈组成。

线程能被操作系统或用户态调度器独立调度(Scheduling), 分派(Dispatch), 执行(Perform), 并且由于同一个进程下多个线程同属于一块地址空间并共享资源, 相比较进程之间的 IPC 机制通信, 线程可以通过 共享内存 进程数据交互, 之前在做 CUDA 课程的大作业的时候深有感触, 用好线程 归约 能让计算效率几何倍提升。

基于 CUDA 语言的热扩散仿真模型: CUDA-HeatTransfer-3D, 有兴趣的同学可以看看 CUDA By Example 这本书了解 CUDA 并行计算。

但线程最大的问题就是需要 保持共享资源数据一致性, 修改数据可能会因为多个线程执行顺序的不可预知而产生 竞态条件(Race Condition), 这是调度的不可控导致的, 而进程独享一片地址空间而不会与其他进程产生数据的地址交叉, 虽然也会被操作系统调度, 但不会遇到这样的问题。

为了解决访问与改写共享资源带来的问题, 操作系统需要提供一些保障机制, 如 互斥锁(Mutex Lock)信号量(Semaphore)条件变量(Condition Variable) 等, 这也是这个章节 rCore 所需要解决的第二个问题 —— 为线程提供上述保障。

1.2 用户态的线程管理

用户态的线程管理不受操作系统的约束, 这种线程一般被称为 Green Threads, 这与协作式调度非常类似, 线程的管理权限全都交由用户。 rCore 这样设计了线程的基本结构:

  • 线程 ID: 用以标识与区分不同的线程。
  • 执行状态: 同样分为 空闲(Available)Ready(就绪), 以及 Running(正在执行)
  • : 为分配每个线程的运行函数栈。
  • 上下文: 根据 RISC-V 的函数调用约定需要保存 Callee 寄存器, 同时需要保存 PC 指针以保证执行地址的正常切换。
struct Task {
    id: usize,            // 线程ID
    stack: Vec<u8>,       // 栈
    ctx: TaskContext,     // 当前指令指针(PC)和通用寄存器集合
    state: State,         // 执行状态
}

struct TaskContext {
    // 15 u64
    x1: u64,  //ra: return address,即当前正在执行线程的当前指令指针(PC)
    x2: u64,  //sp
    x8: u64,  //s0,fp
    x9: u64,  //s1
    x18: u64, //x18-27: s2-11
    x19: u64,
    ...
    x27: u64,
    nx1: u64, //new return address, 即下一个要执行线程的当前指令指针(PC)
}

enum State {
    Available, // 初始态:线程空闲,可被分配一个任务去执行
    Running,   // 运行态:线程正在执行
    Ready,     // 就绪态:线程已准备好,可恢复执行
}

rCore 对用户态的线程管理通过如下几个函数完成:

  • 线程初始化
    • Runtime::new: 创建当前处于 Running 的一个主线程, 并将剩余的线程资源初始化为 Available 以向量的形式存储在 Runtime 结构体中方便后续的资源线程创建和管理。
    • Runtime::init: 传递 Runtime 对象的地址至全局变量 RUNTIME, 方便其他函数获取相应的资源线程。
  • 线程创建
    • Runtime::spawn: 在存储线程资源的向量中查找一个状态为 Available 的空闲线程, 为调度准备 old return address 以及 new return address 并分配新的空间并设置栈顶的位置, 在返回前将该线程状态设置为 Ready

      实际上这里的 old return address 被设置为了 guard 函数的地址, 该函数内部调用了 t_return 将当线程的状态重新设置为 Available 后会调用 t_yield 进行线程的切换。 这种设计的作用在讲述 线程切换 后再详细说明。

  • 线程切换 线程切换应当是调度中最重要的一个模块, 主要功能由 Runtime::t_yield 以及 switch 这两个函数提供。 之前提及的 Runtime::t_returnRuntime::guard 以及 Runtime::yield_task 都封装了 Runtime::t_yield 以完成相应的调度的功能。

    • Runtime::t_yield: 从当前位置开始查找一个状态为 Ready 的线程, 没找到会返回 false。 如果当前的线程不是 Available(说明在 Running) 就将当前的线程重新放到 Ready 数组中, 另外就是将找到的 Ready 线程切换为 Running。 最后调用 switch 交换新旧线程的上下文。
    • switch: 完成对 Runtime::t_yield 未进行切换的 当前指令指针(PC)、 通用寄存器集合和栈 的切换, 关键的任务切换在于如何对 PC 指针进行切换。 x1(ra) 寄存器保存着当前 switch 函数的返回地址, rCore 设计从 offset 为 0x00 到 0x68 的寄存器都按序存储, 其中也包括了 ra。 而在 offset 0x70 的位置又存储了一次 ra, 其作用也很明显, 后续 ld t0, 0x70(a1) 这句指令载入了新的 Context 中的 ra, 通过 jr t0 就能在 switch 之后直接跳转到新的指令运行的位置。

      #[naked]
      #[no_mangle]
      unsafe fn switch(old: *mut TaskContext, new: *const TaskContext) {
          // a0: _old, a1: _new
          asm!(
              "
              sd x1, 0x00(a0)
              ...
              sd x1, 0x70(a0)
      
              ld x1, 0x00(a1)
              ...
              ld t0, 0x70(a1)
      
              jr t0
          ",
              options(noreturn)
          );
      }
      
  • 线程执行
    • Runtime::run: 用一个 while 循环调用 t_yield 进行调度, 所有线程执行完后会退出。

从整体的 rCore 线程设计可以看出, 线程的调度几乎就是按序执行, 所以 Runtime::spawn 会放入一个 guard 函数地址作为 old return address 以释放线程资源, 因为当下一次线程位置又切换到这个线程的时候, 实际上该线程已处理完了。 这个设计只能等当前运行的线程主动让出处理器使用权后, 线程管理运行时才能切换检查。

具体实现细节不表了, rCore 在 user/src/bin/ch8b_stackful_coroutine.rs 中实现了对用户态的线程管理。

1.3 内核态的线程管理

内核态的线程管理扩展了时钟中断, 能基于时钟中断来直接打断当前用户态线程的运行, 实现对线程的调度和切换等。 rCore 在该章节对进程的结构进行了细化, 通过线程来表示对处理器的虚拟化, 使得进程成为了管理线程的容器。 虽然没有父子关系, 但多个线程中的第一个创建的线程一般被称为主线程, 并被分配 0 号进程标识符(TID)。

除了主线程仍然是从程序入口点开始执行, 其余的 线程的生命周期都与一个函数进行一次绑定, 从这个角度而言, 主线程其实和进程的声明周期绑定了。

除了具备前述用户态线程的基本特性, rCore 设计的线程模型额外具备如下功能:

  • 线程有三种状态:就绪态、 运行态和阻塞态(阻塞这个知识点解答了我对 poll 函数的疑惑!);
  • 线程可以被操作系统调度以分时占用 CPU;
  • 线程可动态创建与退出;
  • 线程能通过系统调用获得操作系统的服务, 但 进程系统调用线程系统调用 不能混用。

rCore 线程模型与重要系统调用 章节对理解线程模型和设计非常关键。

1.3.1 线程系统调用

  • 线程创建 的系统调用 sys_thread_create 通过 TID(Thread Identifier) 区分不同线程, 与进程的 PID 设计很类似, 内核会为每个线程分配专属资源: 用户栈、 Trap 上下文还有内核栈, 前两个在进程地址空间中, 内核栈在内核地址空间中。

      pub fn sys_thread_create(entry: usize, arg: usize) -> isize;
    
  • 线程管理 通过 TID, gettid 系统调用能获取当前线程的 TID。
  • 线程退出 通过 exit 系统调用完成, 约定线程从绑定的函数返回的时候都需要调用 exit 通知操作系统进行资源回收, 更细粒度的线程取代了进程的作为操作系统的调度单元。
  • 线程资源回收 通过 waittid 回收收线程占用的用户态和内核态资源。 这个工作一般是进程/主线程完成, 但若是 主线程 调用了 exit 其子线程 都会全部退出, 被 父进程 回收资源。

rCore 之前描述的线程模型提到不能混用进程和线程的系统调用, 原因在于其设计对线程和进程进行了分离。 若是调用了 sys_thread_create 生成子线程就只考虑多个线程在一个进程内的交互, 如果涉及到父子进程的交互, 就等价为进程模型。

1.3.2 软硬件资源管理

主要干了三件事儿。

  1. 把原来分配 PID 的数据结构用更通用的 RecycleAllocator 描述, 能为 PID 以及 TID 分配提供描述结构。 PID 的分配还是延续之前章节, 使用 PID_ALLOCATOR 这个全局变量。 而线程归属于进程容器, 所以在每个进程控制块的内部可变结构 ProcessControlBlockInner 中占据一个变量位置, 由进程控制块 ProcessControlBlock 进行管理。

     // os/src/task/process.rs
    
     pub struct ProcessControlBlock {
         // immutable
         pub pid: PidHandle,
         // mutable
         inner: UPSafeCell<ProcessControlBlockInner>,
     }
    
     pub struct ProcessControlBlockInner {
         ...
         pub task_res_allocator: RecycleAllocator,
         ...
     }
    

    除了 TID 之外, 每个线程都有自己独立的用户栈和 Trap 上下文, 且它们在所属进程的地址空间中的位置可由 TID 计算得到。 新的进程地址空间如下所示:

    Process address space with threads, rCore
    Process address space with threads, rCore

    这个就和第三章的结构非常类似了, 为此 rCore 提供了 trap_cx_bottom_from_tid 以及 ustack_bottom_from_tid 这两个函数索引每个线程的用户栈以及 Trap 上下文的具体位置。

  2. 线程资源整合 TaskUserRes, 将线程的 TID 、用户栈和 Trap 上下文与线程打包, 由于声明周期一致, 能够进行统一的资源分配和回收。

  3. 内核栈不再与 PID 或 TID 挂钩, 而与 kstack_id 这个新的内核标识符相关。 需要增加一个 KSTACK_ALLOCATOR 的通用资源分配器对内核标识符进行分配。

1.3.3 进程和线程控制块

对进程和线程两块资源进行分离, 线程已经成为了 CPU 资源的调度单位, 因而与代码执行相关的内容则分配至 TaskControlBlock 中。

// os/src/task/task.rs

pub struct TaskControlBlock {
    // immutable
    pub process: Weak<ProcessControlBlock>,
    pub kstack: KernelStack,
    // mutable
    inner: UPSafeCell<TaskControlBlockInner>,
}

pub struct TaskControlBlockInner {
    pub res: Option<TaskUserRes>,
    pub trap_cx_ppn: PhysPageNum,
    pub task_cx: TaskContext,
    pub task_status: TaskStatus,
    pub exit_code: Option<i32>,
}

进程控制块中则保留进程内所有线程共享的资源:

// os/src/task/process.rs

pub struct ProcessControlBlock {
    // immutable
    pub pid: PidHandle,
    // mutable
    inner: UPSafeCell<ProcessControlBlockInner>,
}

pub struct ProcessControlBlockInner {
    pub is_zombie: bool,
    pub memory_set: MemorySet,
    pub parent: Option<Weak<ProcessControlBlock>>,
    pub children: Vec<Arc<ProcessControlBlock>>,
    pub exit_code: i32,
    pub fd_table: Vec<Option<Arc<dyn File + Send + Sync>>>,
    pub signals: SignalFlags,
    pub tasks: Vec<Option<Arc<TaskControlBlock>>>,
    pub task_res_allocator: RecycleAllocator,
    ... // 其他同步互斥相关资源
}

1.3.4 结语

任务管理器 TaskManager 与处理器管理结构 Processor 仅在接口上有所改变。 线程相关的数据结构的实现可以直接阅读 rCore 线程管理机制的设计与实现 章节因而不赘述了。 若是从 chapter7 过渡到 chapter8, 到此时的改写还不足以支撑操作系统的正常运行, 还需添加线程管理的相关机制。

2. Sync Mutex OS

Sync Mutex OS 增加了 互斥锁(Mutex)信号量(Semaphore)条件变量(Condvar) 这三种资源, 并提供了与这三种同步互斥资源相关的系统调用。 这样多线程应用就可以使用这三种同步互斥机制来解决各种同步互斥问题, 如生产者消费者问题、 哲学家问题、 读者写者问题等。 在完成对操作系统的线程改写之后, 其实就遇到了引入线程带来的共享资源的访问问题, Thread Coroutine OS 与 Sync Mutex OS 耦合关系较强, 后者是前者功能性的延续。

Sync Mutex OS details, rCore
Sync Mutex OS details, rCore

共享资源 (Shared Resources) 是多个线程均能够访问的资源, 线程对于共享资源进行操作的那部分代码被称为 临界区 (Critical Section), 多线程访问共享资源要求这种访问是互斥的。 这与 Rust 的引用是非常类似的, 可变引用只能同时间有一个, 这是因为改引用具备 写权限, 而不可变引用仅有 读权限, 可以说, 写操作 才是导致共享资源需要互斥访问的根源。

现代指令集架构提供了 原子指令(Atomic Instruction) 保护单内存位置的简单操作, 原子指令涉及到数据的 读,改,写, 只是将临界区的范围缩小为一条指令, 这意味着原子指令无法被打断, 这是由硬件层面提供相应的保障。 但对于复杂的数据结构, 简单的原子指令就无能为力了。

2.1 互斥锁

是附加在一种共享资源上的一种标记, 具有 上锁空闲 这两种状态。 对共享资源的访问采用锁的机制后, 在进入临界区之前需要获取锁, 之后再访问临界区的共享资源, 在离开临界区时释放锁, 这三个步骤中如何获取锁是关键。 rCore 用以下指标权衡锁的实现:

  1. 忙则等待: 当一个线程占有锁之后也就占有了共享资源, 其他线程必须等待该线程释放锁才能有机会获取锁, 以进入临界区。
  2. 空闲则入: 资源空闲且有线程尝试进入临界区, 则操作系统能在一定时间内选择一个线程。
  3. 有界等待: 要求每个线程在较大时间尺度上对锁的占有是公平的, 每个线程都有获取锁的机会而不会因为获取不到锁进入 饥饿(Starvation) 态。

2.1.1 软件锁的尝试

rCore 尝试在用户态通过软件的办法实现锁, 如下代码所示尝试使用 OCCUPIED 这个全局变量表示锁。 可以看到没有获取到锁之前 CPU 会在 while 循环内忙等, 这种策略被称为 自旋, 在正确的实现中自旋锁会保证该过程不会被操作系统的调度打断。 在单核操作系统上, 自旋会造成极大的资源浪费, 在分配的一个时间片中, 第一次执行判断后陷入自旋, 由于没有外部量对这个判断条件中的数据进行更改, 自旋的条件始终成立, 相当于这个时间片都会被自旋所浪费。

在该实现中, OCCUPIED 实际成了一种共享资源, 操作系统可以随时打断当前线程的运行调度到另一个线程, 可以发现这样一种情况: T0 线程发现 OCCUPIED = false 后准备将 OCCUPIED 修改为 true (5 - 6 行之间), 此时突然被操作系统打断调度到 T1 线程, 会导致 T1 也认为其具备进入临界区的权限进而尝试修改 OCCUPIED 的值。

// user/src/bin/adder_simple_spin.rs
static mut OCCUPIED: bool = false;

unsafe fn lock() {
    while vload!(&OCCUPIED) {}
    OCCUPIED = true;
}

unsafe fn unlock() {
    OCCUPIED = false;
}

为了解决这个问题, rCore 详细介绍了适用于单核的 Peterson 多标记算法保证两个线程之间的互斥访问, 除此之外还有适用双线程的 Dekkers 算法, 以及多线程的 Eisenberg & McGuire 算法。 但这些算法都具有时代的局限性, 在当前多核系统上使用需要付出极大的资源代价, 并且还很难理解。

2.1.2 硬件锁机制

事实上, 硬件层面为我们提供了锁的机制的支持, 包括 关闭中断原子指令

关闭中断 在 MCU 裸机环境下经常会用到, 操作系统的抢占式调度是由时钟中断触发, 因而关闭中断能够防止线程在临界区被操作系统调度。 但是, 下放中断权限可能导致恶意的用户资源占用(恶意永久关闭中断); 且对于 RISC-V 多特权级架构而言, 用户态对 S-Mode 和 M-Mode 的中断修改会导致异常; 另外, 关闭中断在多核 CPU 上仅仅是关闭了当前 CPU 的中断, 并不能阻止其他 CPU 的线程进入临界区。

原子指令 在之前提到过, 它的执行无法被操作系统打断, 原子指令是整个计算机系统中最根本的原子性和互斥性的来源。 比较常见的指令有 CAS(Compare-And-Swap) 以及 TAS(Test-And-Set)。 这两条是我之前一直没太理解的, 还有 RISC-V 的 (Load Reserved / Store Conditional) LR/SC 原子指令, rCore 对此解释的非常清晰。

  • CAS(Compare-And-Swap): 其基本用法是 CAS rd, rs1, rs2, rs3rs1 中是内存存放的某个值 source, 这个值会与 rs2 中的 expected 值进行比较, 若 source == expected 则将 rs1 的值替换为 rs3 中的 new 值。 rs1 最开始存储的 source 值会被存入 rd 目标寄存器, 这不受比较结果影响。

    具体的使用例程可以参考 常用的 CAS 和 TAS 指令 部分 adder_atomic.rs 部分。

  • TAS(Test-And-Set): 其基本用法是 TAS rd, rs1, rs2rs1 中是内存存放的某个值 source, 他会被直接设置为 rs2 中的 expected 值, 而 rd 返回 rs1 中原来的 source 值。 这个设计也能实现自旋锁, 以如下伪代码为例, 如果有多个线程执行 TAS 原子指令, 仅有一个线程的输入 OCCUPIED 的值为 0, 那么 TAS 返回会为 0, 该线程就能退出循环进入后续的临界区, 而其他的线程只会看到 OCCUPIED 值为 1 而陷入 while 的自旋中。

      static mut OCCUPIED: i32 = 0;
      unsafe fn lock() {
          while (TAS(&mut OCCUPIED, 1) == 1) {}
      }
    
  • LR(Load Reserved): 其基本用法是 LR rd, rs1, 将 rs1 内存中存放的值写入 rd 寄存器中。 该指令不能单独使用, 需要配合 SC 指令。

  • SC(Store Conditional): 其基本用法是 SC rd, rs1, rs2, 将 rs1 的值 source 修改为 rs2 中存放的 new 值, 若修改成功 rd 返回 0, 否则 rd 返回任意值。 修改的前提是 LR 和 SC 指令之间, 之前 LR 指令中 rs1 内存的值没有改变, 这通过 RISC-V 框架下的 保留集(Reservation Set) 进行判断。

在 RISC-V 架构中可以通过 LR/SC 指令是实现 CAS/TAS 指令的功能。

2.1.3 让权等待

在涉及到上述这种某种条件不满足就需要在原地 等待 的情况, 操作系统设计了多种等待方式, 包括 忙等yield 调度阻塞

  • 忙等
    • 优点: 在忙等有意义的前提下, 忙等的优势是在条件成立的第一时间就能够进行响应, 对于事件的响应延迟更低, 实时性更好, 而且不涉及开销很大的上下文切换。
    • 缺点: 它的缺点则是不可避免的会浪费一部分 CPU 资源在忙等上, 且单核环境下会浪费一整个时间片。
  • yield 调度
    • 优点: 让出 CPU 资源, 避免忙等对 CPU 的占用。
    • 缺点: 增加了上下文切换的次数, 而上下文切换开销很大, 并且会破坏缓存资源(刷新 TLB)造成缓存命中率降低, 若 yield 非常频繁这种情况更会加剧。 另外, yeild 可能会导致当前事件的响应延时过久造成响应时间不可接收的局面。
  • 阻塞
    • 优点: 操作系统可标记需要等待事件的线程为 阻塞态, 并将该线程从调度器队列移除, 而在等待事件到来后唤醒该线程将其加入就绪队列, 操作系统可以定义唤醒后的线程在队列中的优先级进行优先资源配置。 由于不参与调度, 因此阻塞不会浪费时间片, 以及造成上下文开销。
    • 缺点: 机制比较复杂, 并且难以避免地产生两次上下文切换(调度器移除与移入), 在事件产生频率较高的时候不如忙等来的划算。

rCore 实现了 Sync Mutex OS 中的 阻塞 - 唤醒 机制, 具体原理参考 实现阻塞与唤醒机制。 但需要注意的是, rCore 的实现是在单核 CPU 上, 并且 RISC-V 架构规定从用户态陷入内核态之后所有(内核态)中断默认被自动屏蔽, 因而在内核中实现 Mutex 仅需要用单标记而无需使用原子指令。

2.2 信号量机制

信号量机制能够满足线程的同步要求, 即 A 线程执行到某个阶段后, B 线程通过信号量可以继续向下执行, 或者, 可以通过信号量使 N 个线程具备在临界区中访问共享资源的需求, 这些都是纯粹的 Mutex 互斥锁无法做到的。 实际上, 信号量在实现的时候还会用到 互斥锁和原子指令, 它是更高一级的同步互斥机制, 在特殊情况下, 信号量甚至与互斥锁等价。

信号量支持两种操作:P 操作(Proberen, 表示尝试)V 操作(Verhogen ,表示增加),P 操作表示线程尝试占用一个资源, 而与之匹配的 V 操作表示线程将占用的资源归还, 这同样基于 阻塞 - 唤醒 机制实现。

  • 当进行 P 操作的时候, 如果此时没有可用的资源, 则当前线程会被阻塞;
  • 当进行 V 操作的时候, 如果返还之后有了可用的资源, 且此时有线程被阻塞, 那么就可以考虑唤醒它们。

信号量的初始资源可用数量 N 是一个非负整数,它决定了信号量的用途。

  • N > 0, 称其为 计数信号量 (Counting Semaphore)一般信号量 (General Semaphore), 当 N = 1 时, 称其为 二值信号量 (Binary Semaphore), 其与互斥锁等价。
  • N = 0, 信号量与资源管理无关, 但可用作同步量。 如下图, 在线程 A 需要等待的时候可以对该信号量进行 P 操作, 于是线程会被阻塞。 在线程 B 执行完指定阶段之后再对该信号量进行 V 操作就能够唤醒线程 A 向下执行。 但需要注意 唤醒丢失 的问题。

    Semaphore sync, rCore
    Semaphore sync, rCore

除了作为同步原语解决同步问题, rCore 还介绍了一个基于有限缓冲协作的 生产者与消费者 问题, 其实就是利用了信号量共享资源计数, 在无资源能进行阻塞的特点, 但还需注意的是, 共享资源仍需要通过互斥锁进行保护。 详见 生产者和消费者问题。 虽然信号量解决了条件同步和生产者与消费者问题, 但对程序员而言要求较高, 其开发和阅读都具备一定难度。

2.3 条件变量机制

信号量与互斥锁的组合虽然能解决一些问题, 但其仍存在局限性:

  • 信号量本质上为一个整数, 无法描述所有类型的等待事件或等待条件;
  • 信号量需要 P 和 V 组合使用, 还容易导致死锁问题。

rCore 实现了 条件变量 + 互斥锁 这样一个组合来模拟 管程 的实现, 以打破上述组合的局限性。 管程 (Monitor) 最早在 Pascal 语言中实现, 现在的 C 以及 Rust 并没有相关的实现机制, 由过程(现在的函数)、共享变量及数据结构等组成。 程序员可以自定义管程的过程和共享资源, 通过过程间接访问这些共享资源。

管程仍是通过互斥锁满足 互斥访问 的需求, 另外还支持线程间的同步机制, 通过 阻塞-唤醒 实现条件同步。 阻塞和唤醒就是所谓的 条件变量, 而在管程的概念中将条件变量的阻塞和唤醒操作分别叫做 waitsignal 。 一个管程中可以有多个不同的条件变量, 每个条件变量代表多线程并发执行中需要等待的一种特定的条件, 并保存所有阻塞等待该条件的线程。

这里的 waitsignal 有比较特殊的设计:

  • waitwait 操作会阻塞当前的线程, 但 不能在持有锁的情况下陷入阻塞, 在管程过程中线程是持有锁的, 因而 wait 需要做两件事: 释放锁, 然后阻塞线程。
  • signalsignal 则是唤醒当前的线程, 处于互斥访问的需求, 线程被唤醒后需要获取锁, 然后再 wait 返回继续执行。

由于互斥锁的存在, signal 操作也不只是简单的唤醒操作。 rCore 介绍了基于线程优先级条件的三种唤醒操作语义: Hoare, Hansen, Mesa, 详情可见 管程与条件变量。 在 rCore 内核中实现的是 Mesa 语义, Mesa 语义涉及到锁的竞争, 因而 wait 操作返回之时不见得线程等待的条件一定成立, 有必要重复检查确认之后再继续执行。

另外, rCore 介绍了条件变量的两种用法 条件同步问题 以及 同步屏障问题, 尤其同步屏障问题在多线程编程中会经常遇到, 深有体会。 对此可详见 条件变量的使用方法 学习。

TODO…