这个章节整理完 Tutorial 还有一个与 I/O 设备管理相关的第九章, 准备之后慢慢琢磨然后不断扩展内核功能, 连续一个月高强度 rCore 的学习心理上有些浮躁了。 最近想把多年未竟的 FOC 项目捡起来好好研究一下, 顺带用上 FreeRTOS 用另一个角度看 OS。
0. 资料汇总
- RISC-V
- RISC-V ELF psABI: Processor-specific application binary interface document.
- RISC-V Supervisor Binary Interface: Spec for SBI.
- RISC-V C API: RISC-V-specific predefined macros, function attributes and language extensions.
- RISC-V Assembly Programmer’s Manual: Document for pseudoinstructions and assembly directives.
- RISC-V Specifications:
- RISC-V ACLINT specification: ACLINT (Advanced Core Local Interruptor) specification defines a set of memory mapped devices which provide inter-processor interrupt and timer functionality for each HART of a multi-HART (or multi-processor) RISC-V platform.
- RISC-V Assembly Programmer’s Manual: Provide guidance to assembly programmers targeting the standard RISC-V assembly language.
- rCore
- rCore 第八章相关内容的实现记录在 Github Tag: [ch8]
TODO
- rCore source code of labs for spring 2023: rCore-Tutorial-Guide-2023S Source Code
- rCore Concise Manual: rCore-Tutorial-Guide-2023S
- rCore Detail Book: rCore-Tutorial-Book-v3
- rCore 第八章相关内容的实现记录在 Github Tag: [ch8]
1. Thread Coroutine OS
Thread Coroutine OS 增加了在用户态管理的用户态线程/用户态协程, 以及在内核态管理的用户态线程。
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_return
,Runtime::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 软硬件资源管理
主要干了三件事儿。
-
把原来分配 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这个就和第三章的结构非常类似了, 为此 rCore 提供了
trap_cx_bottom_from_tid
以及ustack_bottom_from_tid
这两个函数索引每个线程的用户栈以及 Trap 上下文的具体位置。 -
线程资源整合
TaskUserRes
, 将线程的 TID 、用户栈和 Trap 上下文与线程打包, 由于声明周期一致, 能够进行统一的资源分配和回收。 -
内核栈不再与 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
共享资源 (Shared Resources) 是多个线程均能够访问的资源, 线程对于共享资源进行操作的那部分代码被称为 临界区 (Critical Section), 多线程访问共享资源要求这种访问是互斥的。 这与 Rust 的引用是非常类似的, 可变引用只能同时间有一个, 这是因为改引用具备 写权限, 而不可变引用仅有 读权限, 可以说, 写操作 才是导致共享资源需要互斥访问的根源。
现代指令集架构提供了 原子指令(Atomic Instruction) 保护单内存位置的简单操作, 原子指令涉及到数据的 读,改,写, 只是将临界区的范围缩小为一条指令, 这意味着原子指令无法被打断, 这是由硬件层面提供相应的保障。 但对于复杂的数据结构, 简单的原子指令就无能为力了。
2.1 互斥锁
锁 是附加在一种共享资源上的一种标记, 具有 上锁 和 空闲 这两种状态。 对共享资源的访问采用锁的机制后, 在进入临界区之前需要获取锁, 之后再访问临界区的共享资源, 在离开临界区时释放锁, 这三个步骤中如何获取锁是关键。 rCore 用以下指标权衡锁的实现:
- 忙则等待: 当一个线程占有锁之后也就占有了共享资源, 其他线程必须等待该线程释放锁才能有机会获取锁, 以进入临界区。
- 空闲则入: 资源空闲且有线程尝试进入临界区, 则操作系统能在一定时间内选择一个线程。
- 有界等待: 要求每个线程在较大时间尺度上对锁的占有是公平的, 每个线程都有获取锁的机会而不会因为获取不到锁进入 饥饿(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, rs3
,rs1
中是内存存放的某个值 source, 这个值会与rs2
中的 expected 值进行比较, 若 source == expected 则将rs1
的值替换为rs3
中的 new 值。rs1
最开始存储的 source 值会被存入rd
目标寄存器, 这不受比较结果影响。具体的使用例程可以参考 常用的 CAS 和 TAS 指令 部分
adder_atomic.rs
部分。 -
TAS(Test-And-Set): 其基本用法是
TAS rd, rs1, rs2
,rs1
中是内存存放的某个值 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
除了作为同步原语解决同步问题, rCore 还介绍了一个基于有限缓冲协作的 生产者与消费者 问题, 其实就是利用了信号量共享资源计数, 在无资源能进行阻塞的特点, 但还需注意的是, 共享资源仍需要通过互斥锁进行保护。 详见 生产者和消费者问题。 虽然信号量解决了条件同步和生产者与消费者问题, 但对程序员而言要求较高, 其开发和阅读都具备一定难度。
2.3 条件变量机制
信号量与互斥锁的组合虽然能解决一些问题, 但其仍存在局限性:
- 信号量本质上为一个整数, 无法描述所有类型的等待事件或等待条件;
- 信号量需要 P 和 V 组合使用, 还容易导致死锁问题。
rCore 实现了 条件变量 + 互斥锁 这样一个组合来模拟 管程 的实现, 以打破上述组合的局限性。 管程 (Monitor) 最早在 Pascal 语言中实现, 现在的 C 以及 Rust 并没有相关的实现机制, 由过程(现在的函数)、共享变量及数据结构等组成。 程序员可以自定义管程的过程和共享资源, 通过过程间接访问这些共享资源。
管程仍是通过互斥锁满足 互斥访问 的需求, 另外还支持线程间的同步机制, 通过 阻塞-唤醒 实现条件同步。 阻塞和唤醒就是所谓的 条件变量, 而在管程的概念中将条件变量的阻塞和唤醒操作分别叫做 wait
和 signal
。 一个管程中可以有多个不同的条件变量, 每个条件变量代表多线程并发执行中需要等待的一种特定的条件, 并保存所有阻塞等待该条件的线程。
这里的 wait
和 signal
有比较特殊的设计:
- wait:
wait
操作会阻塞当前的线程, 但 不能在持有锁的情况下陷入阻塞, 在管程过程中线程是持有锁的, 因而wait
需要做两件事: 释放锁, 然后阻塞线程。 - signal:
signal
则是唤醒当前的线程, 处于互斥访问的需求, 线程被唤醒后需要获取锁, 然后再wait
返回继续执行。
由于互斥锁的存在, signal 操作也不只是简单的唤醒操作。 rCore 介绍了基于线程优先级条件的三种唤醒操作语义: Hoare, Hansen, Mesa, 详情可见 管程与条件变量。 在 rCore 内核中实现的是 Mesa 语义, Mesa 语义涉及到锁的竞争, 因而 wait 操作返回之时不见得线程等待的条件一定成立, 有必要重复检查确认之后再继续执行。
另外, rCore 介绍了条件变量的两种用法 条件同步问题 以及 同步屏障问题, 尤其同步屏障问题在多线程编程中会经常遇到, 深有体会。 对此可详见 条件变量的使用方法 学习。
TODO…