文章目录
【本实验目标】
- 在进程内实现多个控制流(线程/协程)的执行
- 在用户态或内核态管理多个控制流(线程/协程)
- 在多线程中支持对共享资源的同步互斥访问
【基础概念】
【线程】
线程是进程的组成部分,进程可包含1 – n个线程,属于同一个进程的线程共享进程的资源。基本的线程由线程ID、执行状态、当前指令指针 (PC)、寄存器集合和栈组成。 线程是可以被操作系统或用户态调度器独立调度(Scheduling)和分派(Dispatch)的基本单位。进程是线程的资源容器, 线程成为了程序的基本执行实体。
【同步互斥】
当多个线程共享同一进程的地址空间时, 每个线程都可以访问属于这个进程的数据(全局变量)。如果每个线程使用到的变量都是其他线程不会读取或者修改的话, 那么就不存在一致性问题。如果变量是只读的,多个线程读取该变量也不会有一致性问题。但是,当一个线程修改变量时, 其他线程在读取这个变量时,可能会看到一个不一致的值,这就是数据不一致性的问题。
【并发相关术语】
- 共享资源(shared resource):不同的线程/进程都能访问的变量或数据结构。
- 临界区(critical section):访问共享资源的一段代码。
- 竞态条件(race condition):多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果。
- 不确定性(indeterminate): 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行, 即执行结果不确定,而开发者期望得到的是确定的结果。
- 互斥(mutual exclusion):一种操作原语,能保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的执行结果。
- 原子性(atomic):一系列操作要么全部完成,要么一个都没执行,不会看到中间状态。在数据库领域, 具有原子性的一系列操作称为事务(transaction)。
- 同步(synchronization):多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。
- 死锁(dead lock):一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程 (包括他自身)才能引发的事件,这种情况就是死锁。
- 饥饿(hungry):指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。
【代码架构】
.
├── bootloader
│ ├── rustsbi-k210.bin
│ └── rustsbi-qemu.bin
├── dev-env-info.md
├── Dockerfile
├── easy-fs
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ ├── bitmap.rs
│ ├── block_cache.rs
│ ├── block_dev.rs
│ ├── efs.rs
│ ├── layout.rs
│ ├── lib.rs
│ └── vfs.rs
├── easy-fs-fuse
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ └── main.rs
├── LICENSE
├── Makefile
├── os
│ ├── build.rs
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── last-qemu
│ ├── Makefile
│ └── src
│ ├── config.rs
│ ├── console.rs
│ ├── drivers
│ │ ├── block
│ │ │ ├── mod.rs
│ │ │ ├── sdcard.rs
│ │ │ └── virtio_blk.rs
│ │ └── mod.rs
│ ├── entry.asm
│ ├── fs
│ │ ├── inode.rs
│ │ ├── mod.rs
│ │ ├── pipe.rs
│ │ └── stdio.rs
│ ├── lang_items.rs
│ ├── link_app.S
│ ├── linker-k210.ld
│ ├── linker-qemu.ld
│ ├── loader.rs
│ ├── main.rs
│ ├── mm
│ │ ├── address.rs
│ │ ├── frame_allocator.rs
│ │ ├── heap_allocator.rs
│ │ ├── memory_set.rs
│ │ ├── mod.rs
│ │ └── page_table.rs
│ ├── sbi.rs
│ ├── sync
│ │ ├── mod.rs
│ │ ├── mutex.rs
│ │ ├── semaphore.rs
│ │ └── up.rs
│ ├── syscall
│ │ ├── fs.rs
│ │ ├── mod.rs
│ │ ├── process.rs
│ │ ├── sync.rs
│ │ └── thread.rs
│ ├── task
│ │ ├── context.rs
│ │ ├── id.rs
│ │ ├── manager.rs
│ │ ├── mod.rs
│ │ ├── processor.rs
│ │ ├── process.rs
│ │ ├── switch.rs
│ │ ├── switch.S
│ │ └── task.rs
│ ├── timer.rs
│ └── trap
│ ├── context.rs
│ ├── mod.rs
│ └── trap.S
├── pushall.sh
├── README.md
├── rust-toolchain
└── user
├── Cargo.lock
├── Cargo.toml
├── Makefile
└── src
├── bin
│ ├── cat.rs
│ ├── cmdline_args.rs
│ ├── exit.rs
│ ├── fantastic_text.rs
│ ├── filetest_simple.rs
│ ├── forktest2.rs
│ ├── forktest.rs
│ ├── forktest_simple.rs
│ ├── forktree.rs
│ ├── hello_world.rs
│ ├── huge_write.rs
│ ├── initproc.rs
│ ├── matrix.rs
│ ├── mpsc_sem.rs
│ ├── phil_din_mutex.rs
│ ├── pipe_large_test.rs
│ ├── pipetest.rs
│ ├── race_adder_atomic.rs
│ ├── race_adder_loop.rs
│ ├── race_adder_mutex_blocking.rs
│ ├── race_adder_mutex_spin.rs
│ ├── race_adder.rs
│ ├── run_pipe_test.rs
│ ├── sleep.rs
│ ├── sleep_simple.rs
│ ├── stack_overflow.rs
│ ├── threads_arg.rs
│ ├── threads.rs
│ ├── user_shell.rs
│ ├── usertests.rs
│ └── yield.rs
├── console.rs
├── lang_items.rs
├── lib.rs
├── linker.ld
└── syscall.rs
【用户态的线程管理】
实现多线程不一定需要操作系统的支持,完全可以在用户态实现。本节的主要目标是理解线程的基本要素、多线程应用的执行方式以及如何在用户态构建一个多线程的的基本执行环境 -> (线程管理运行时, Thread Manager Runtime)
【多线程的基本执行环境 -> user/src/bin/stackful_coroutine.rs】
线程的运行需要一个执行环境,这个执行环境可以是操作系统内核,也可以是更简单的用户态的一个线程管理运行时库。
由于是在用户态进行线程的创建,调度切换等,这就意味着我们不需要操作系统提供进一步的支持,即操作系统不需要感知到这种线程的存在。
如果一个线程A想要运行,它只有等到目前正在运行的线程B主动交出处理器的使用权,从而让线程管理运行时库有机会得到处理器的使用权,且线程管理运行时库通过调度,选择了线程A,再完成线程B和线程A的线程上下文切换后,线程A才能占用处理器并运行。
// user/src/bin/stackful_coroutine.rs
// we porting below codes to Rcore Tutorial v3
// https://cfsamson.gitbook.io/green-threads-explained-in-200-lines-of-rust/
// https://github.com/cfsamson/example-greenthreads
#![no_std]
#![no_main]
#![feature(naked_functions)]
extern crate alloc;
#[macro_use]
extern crate user_lib;
use alloc::vec;
use alloc::vec::Vec;
use core::arch::asm;
use user_lib::exit;
// In our simple example we set most constraints here.
const DEFAULT_STACK_SIZE: usize = 4096; //128 got SEGFAULT, 256(1024, 4096) got right results.
const MAX_TASKS: usize = 5;
static mut RUNTIME: usize = 0;
pub struct Runtime {
tasks: Vec<Task>,
current: usize,
}
#[derive(PartialEq, Eq, Debug)]
enum State {
Available,
Running,
Ready,
}
struct Task {
id: usize,
stack: Vec<u8>,
ctx: TaskContext,
state: State,
}
#[derive(Debug, Default)]
#[repr(C)] // not strictly needed but Rust ABI is not guaranteed to be stable
pub struct TaskContext {
// 15 u64
x1: u64, //ra: return addres
x2: u64, //sp
x8: u64, //s0,fp
x9: u64, //s1
x18: u64, //x18-27: s2-11
x19: u64,
x20: u64,
x21: u64,
x22: u64,
x23: u64,
x24: u64,
x25: u64,
x26: u64,
x27: u64,
nx1: u64, //new return addres
}
impl Task {
fn new(id: usize) -> Self {
// We initialize each task here and allocate the stack. This is not neccesary,
// we can allocate memory for it later, but it keeps complexity down and lets us focus on more interesting parts
// to do it here. The important part is that once allocated it MUST NOT move in memory.
Task {
id,
stack: vec![0_u8; DEFAULT_STACK_SIZE],
ctx: TaskContext::default(),
state: State::Available,
}
}
}
impl Runtime {
pub fn new() -> Self {
// This will be our base task, which will be initialized in the `running` state
let base_task = Task {
id: 0,
stack: vec![0_u8; DEFAULT_STACK_SIZE],
ctx: TaskContext::default(),
state: State::Running,
};
// We initialize the rest of our tasks.
let mut tasks = vec![base_task];
let mut available_tasks: Vec<Task> = (1..MAX_TASKS).map(|i| Task::new(i)).collect();
tasks.append(&mut available_tasks);
Runtime { tasks, current: 0 }
}
/// This is cheating a bit, but we need a pointer to our Runtime stored so we can call yield on it even if
/// we don't have a reference to it.
pub fn init(&self) {
unsafe {
let r_ptr: *const Runtime = self;
RUNTIME = r_ptr as usize;
}
}
/// This is where we start running our runtime. If it is our base task, we call yield until
/// it returns false (which means that there are no tasks scheduled) and we are done.
pub fn run(&mut self) {
while self.t_yield() {}
println!("All tasks finished!");
}
/// This is our return function. The only place we use this is in our `guard` function.
/// If the current task is not our base task we set its state to Available. It means
/// we're finished with it. Then we yield which will schedule a new task to be run.
fn t_return(&mut self) {
if self.current != 0 {
self.tasks[self.current].state = State::Available;
self.t_yield();
}
}
/// This is the heart of our runtime. Here we go through all tasks and see if anyone is in the `Ready` state.
/// If no task is `Ready` we're all done. This is an extremely simple scheduler using only a round-robin algorithm.
///
/// If we find a task that's ready to be run we change the state of the current task from `Running` to `Ready`.
/// Then we call switch which will save the current context (the old context) and load the new context
/// into the CPU which then resumes based on the context it was just passed.
///
/// NOITCE: if we comment below `#[inline(never)]`, we can not get the corrent running result
#[inline(never)]
fn t_yield(&mut self) -> bool {
let mut pos = self.current;
while self.tasks[pos].state != State::Ready {
pos += 1;
if pos == self.tasks.len() {
pos = 0;
}
if pos == self.current {
return false;
}
}
if self.tasks[self.current].state != State::Available {
self.tasks[self.current].state = State::Ready;
}
self.tasks[pos].state = State::Running;
let old_pos = self.current;
self.current = pos;
unsafe {
switch(&mut self.tasks[old_pos].ctx, &self.tasks[pos].ctx);
}
// NOTE: this might look strange and it is. Normally we would just mark this as `unreachable!()` but our compiler
// is too smart for it's own good so it optimized our code away on release builds. Curiously this happens on windows
// and not on linux. This is a common problem in tests so Rust has a `black_box` function in the `test` crate that
// will "pretend" to use a value we give it to prevent the compiler from eliminating code. I'll just do this instead,
// this code will never be run anyways and if it did it would always be `true`.
self.tasks.len() > 0
}
/// While `yield` is the logically interesting function I think this the technically most interesting.
///
/// When we spawn a new task we first check if there are any available tasks (tasks in `Parked` state).
/// If we run out of tasks we panic in this scenario but there are several (better) ways to handle that.
/// We keep things simple for now.
///
/// When we find an available task we get the stack length and a pointer to our u8 bytearray.
///
/// The next part we have to use some unsafe functions. First we write an address to our `guard` function
/// that will be called if the function we provide returns. Then we set the address to the function we
/// pass inn.
///
/// Third, we set the value of `sp` which is the stack pointer to the address of our provided function so we start
/// executing that first when we are scheuled to run.
///
/// Lastly we set the state as `Ready` which means we have work to do and is ready to do it.
pub fn spawn(&mut self, f: fn()) {
let available = self
.tasks
.iter_mut()
.find(|t| t.state == State::Available)
.expect("no available task.");
let size = available.stack.len();
unsafe {
let s_ptr = available.stack.as_mut_ptr().offset(size as isize);
// make sure our stack itself is 8 byte aligned - it will always
// offset to a lower memory address. Since we know we're at the "high"
// memory address of our allocated space, we know that offsetting to
// a lower one will be a valid address (given that we actually allocated)
// enough space to actually get an aligned pointer in the first place).
let s_ptr = (s_ptr as usize & !7) as *mut u8;
available.ctx.x1 = guard as u64; //ctx.x1 is old return address
available.ctx.nx1 = f as u64; //ctx.nx2 is new return address
available.ctx.x2 = s_ptr.offset(-32) as u64; //cxt.x2 is sp
}
available.state = State::Ready;
}
}
/// This is our guard function that we place on top of the stack. All this function does is set the
/// state of our current task and then `yield` which will then schedule a new task to be run.
fn guard() {
unsafe {
let rt_ptr = RUNTIME as *mut Runtime;
(*rt_ptr).t_return();
};
}
/// We know that Runtime is alive the length of the program and that we only access from one core
/// (so no datarace). We yield execution of the current task by dereferencing a pointer to our
/// Runtime and then calling `t_yield`
pub fn yield_task() {
unsafe {
let rt_ptr = RUNTIME as *mut Runtime;
(*rt_ptr).t_yield();
};
}
/// So here is our inline Assembly. As you remember from our first example this is just a bit more elaborate where we first
/// read out the values of all the registers we need and then sets all the register values to the register values we
/// saved when we suspended exceution on the "new" task.
///
/// This is essentially all we need to do to save and resume execution.
///
/// Some details about inline assembly.
///
/// The assembly commands in the string literal is called the assemblt template. It is preceeded by
/// zero or up to four segments indicated by ":":
///
/// - First ":" we have our output parameters, this parameters that this function will return.
/// - Second ":" we have the input parameters which is our contexts. We only read from the "new" context
/// but we modify the "old" context saving our registers there (see volatile option below)
/// - Third ":" This our clobber list, this is information to the compiler that these registers can't be used freely
/// - Fourth ":" This is options we can pass inn, Rust has 3: "alignstack", "volatile" and "intel"
///
/// For this to work on windows we need to use "alignstack" where the compiler adds the neccesary padding to
/// make sure our stack is aligned. Since we modify one of our inputs, our assembly has "side effects"
/// therefore we should use the `volatile` option. I **think** this is actually set for us by default
/// when there are no output parameters given (my own assumption after going through the source code)
/// for the `asm` macro, but we should make it explicit anyway.
///
/// One last important part (it will not work without this) is the #[naked] attribute. Basically this lets us have full
/// control over the stack layout since normal functions has a prologue-and epilogue added by the
/// compiler that will cause trouble for us. We avoid this by marking the funtion as "Naked".
/// For this to work on `release` builds we also need to use the `#[inline(never)] attribute or else
/// the compiler decides to inline this function (curiously this currently only happens on Windows).
/// If the function is inlined we get a curious runtime error where it fails when switching back
/// to as saved context and in general our assembly will not work as expected.
///
/// see: https://github.com/rust-lang/rfcs/blob/master/text/1201-naked-fns.md
/// see: https://doc.rust-lang.org/nightly/reference/inline-assembly.html
/// see: https://doc.rust-lang.org/nightly/rust-by-example/unsafe/asm.html
#[naked]
#[no_mangle]
unsafe fn switch(old: *mut TaskContext, new: *const TaskContext) {
// a0: _old, a1: _new
asm!(
"
sd x1, 0x00(a0)
sd x2, 0x08(a0)
sd x8, 0x10(a0)
sd x9, 0x18(a0)
sd x18, 0x20(a0)
sd x19, 0x28(a0)
sd x20, 0x30(a0)
sd x21, 0x38(a0)
sd x22, 0x40(a0)
sd x23, 0x48(a0)
sd x24, 0x50(a0)
sd x25, 0x58(a0)
sd x26, 0x60(a0)
sd x27, 0x68(a0)
sd x1, 0x70(a0)
ld x1, 0x00(a1)
ld x2, 0x08(a1)
ld x8, 0x10(a1)
ld x9, 0x18(a1)
ld x18, 0x20(a1)
ld x19, 0x28(a1)
ld x20, 0x30(a1)
ld x21, 0x38(a1)
ld x22, 0x40(a1)
ld x23, 0x48(a1)
ld x24, 0x50(a1)
ld x25, 0x58(a1)
ld x26, 0x60(a1)
ld x27, 0x68(a1)
ld t0, 0x70(a1)
jr t0
",
options(noreturn)
);
}
#[no_mangle]
pub fn main() {
println!("stackful_coroutine begin...");
println!("TASK 0(Runtime) STARTING");
let mut runtime = Runtime::new();
runtime.init();
runtime.spawn(|| {
println!("TASK 1 STARTING");
let id = 1;
for i in 0..4 {
println!("task: {} counter: {}", id, i);
yield_task();
}
println!("TASK 1 FINISHED");
});
runtime.spawn(|| {
println!("TASK 2 STARTING");
let id = 2;
for i in 0..8 {
println!("task: {} counter: {}", id, i);
yield_task();
}
println!("TASK 2 FINISHED");
});
runtime.spawn(|| {
println!("TASK 3 STARTING");
let id = 3;
for i in 0..12 {
println!("task: {} counter: {}", id, i);
yield_task();
}
println!("TASK 3 FINISHED");
});
runtime.spawn(|| {
println!("TASK 4 STARTING");
let id = 4;
for i in 0..16 {
println!("task: {} counter: {}", id, i);
yield_task();
}
println!("TASK 4 FINISHED");
});
runtime.run();
println!("stackful_coroutine PASSED");
exit(0);
}
-
第 24 - 27 行:定义了线程管理运行时结构体,线程管理运行时负责整个应用中的线程管理。
- 第 25 行:
tasks
段表明目前有哪些线程。 - 第 26 行:
current
表明了当前运行的是哪个线程。
- 第 25 行:
-
第 30 - 34 行:定义了当前线程的状态,也即线程在执行过程中的动态执行特征。
- 第 31 行:
Available
初始态,表明线程空闲,可被分配一个任务去执行 - 第 32 行:
Running
运行态,表明线程正在执行。 - 第 33 行:
Ready
就绪态,表明线程已准备好,可恢复执行。
- 第 31 行:
-
第 36 - 41 行:定义了线程的结构,也简称为线程控制块。
- 第 37 行:
id
为线程的ID
- 第 38 行:
stack
为栈。 - 第 39 行:
ctx
为当前指令指针(PC)和通用寄存器集合 - 第 40 行:
state
表明了当前线程的执行状态。
- 第 37 行:
-
第 44 - 62 行:定义了线程上下文。其内部保存了一些通用寄存器和栈指针,还有当前正在运行的指令指针和下一个执行线程的指令指针。
- 第 47 行:
x1
为当前正在执行线程的当前指令指针(PC
)。 - 第 61 行:
nx1
为下一个要执行线程的当前指令指针(PC
)
- 第 47 行:
-
第 78 - 203 行:为
Runtime
实现了其内部方法。- 第 79 - 94 行:
Runtime::new()
方法创建一个线程管理运行时。(创建一个运行时线程并依据他为0
号线程创建Runtime
)- 第 81 - 86 行:创建一个应用主线程控制块,并将其
TID
初始化为0
,并设置其状态为Running
状态。所以0
号线程就是我们的Runtime
线程。 - 第 89 行:初始化
tasks
线程控制块向量。 - 第 90 行:创建空闲线程控制块。
- 第 91 行:在
tasks
线程控制块向量中加入空闲线程控制块。 - 第 93 行:包含
tasks
线程控制块向量和current
当前线程id
(初始值为0
, 表示当前正在运行的线程是应用主线程),来建立Runtime
变量;
- 第 81 - 86 行:创建一个应用主线程控制块,并将其
- 第 98 - 103 行:
Runtime::init()
方法把线程管理运行时的Runtime
自身的地址指针赋值给全局可变变量RUNTIME
。
在应用的 main() 函数中,首先会依次调用Runtime::new()、Runtime::init()两个函数。这样线程管理运行时会附在TID为 0 的应用主线程上,处于运行正在运行的 Running 状态。而且,线程管理运行也建立好了空闲线程控制块向量。后续创建线程时,会从此空闲线程控制块向量中找到一个空闲线程控制块,来绑定要创建的线程,并进行后续的管理。
-
第 107 - 110 行:
Runtime::run()
方法,该方法将切换线程管理运行时所在的应用主线程到另外一个处于Ready
状态的线程,让那个线程开始执行。当所有的线程都执行完毕后,会回到runtime.run()
函数,通过打印日志来通知整个应用的运行就结束了。 -
第 115 - 120 行:
Runtime::t_return()
方法,该函数会暂停当前运行的线程运行跳转运行下一个线程。- 第 116 行:判断当前线程是否是
0
号线程,也就是Runtime
线程。 - 第 117 行:不是的话讲当前线程的状态修改为
Available
。 - 第 118 行:
Runtime
调用t_yield
方法切换下一个线程运行。
- 第 116 行:判断当前线程是否是
-
第 130 - 161 行:
Runtime::t_yield()
方法实现了应用的线程切换。- 第 132 - 141 行:在线程向量中从当前线程号开始往后寻找到下一个线程状态为
Ready
状态的线程。 - 第 143 - 145 行:把当前运行的线程的状态改为
Ready
。 - 第 147 行:将新就绪线程的状态修改为
Running
状态。 - 第 149 行:把
runtime
的current
设置为这个新线程控制块的id
。 - 第 152 行:调用汇编代码写的函数
switch
,完成两个线程的栈和上下文的切换。
- 第 132 - 141 行:在线程向量中从当前线程号开始往后寻找到下一个线程状态为
-
第 179 - 202 行:
Runtime::spawn()
方法会创建一个线程。但是只是创建了线程并没有开始执行线程,要去执行线程需要使用run
方法。-
第 180 - 184 行:在线程向量中查找一个状态为
Available
的空闲线程控制块。 -
第 186 - 201 行:初始化该空闲线程的线程控制块。
-
第 197 行:
x1
寄存器存储老的返回地址(guard
函数地址) -
第 198 行:
nx1
寄存器存储新的返回地址(输入参数f
函数地址) -
第 199 行:
x2
寄存器存储新的栈地址(available.stack+size
)
-
线程的创建就是在线程管理运行时向量中找到一个空闲的控制块进行分配,并将其
ctx
结构体进行初始化。 -
- 第 79 - 94 行:
-
第 207 - 212 行:定义了
guard()
函数,该函数为Runtime::t_return()
方法的外部封装。 -
第 217 - 222 行:定义了
yield_task()
函数,该函数为Runtime::t_yield()
方法的外部封装。 -
第 258 - 300 行:定义了
switch
函数主要完成的就是完成剩下的(指令指针(PC)、通用寄存器集合、栈)三部分的切换- 第 264、278、280、294 行:完成当前指令指针(PC)的切换。
- 第 266、282 行:完成栈指针的切换。
- 第 267 - 277、283 - 293:完成通用寄存器集合的切换。
这里需要注意两个细节:
- 第一个是寄存器集合的保存数量。在保存通用寄存器集合时,并没有保存所有的通用寄存器,其原因是根据
RISC-V
的函数调用约定,有一部分寄存器是由调用函数Caller
来保存的,所以就不需要被调用函数switch
来保存了。 - 第二个是当前指令指针(
PC
)的切换。在具体切换过程中,是基于函数返回地址来进行切换的。即首先把switch
的函数返回地址ra
(即x1
)寄存器保存在TaskContext
中,在此函数的倒数第二步,恢复切换后要执行线程的函数返回地址,即ra
寄存器到t0
寄存器,然后调用jr t0
即完成了函数的返回。
-
第 303 - 347 行:为我们的
main
函数- 第 304 - 305 行:打印相关的应用信息。
- 第 306 行:创建
Runtime
线程。 - 第 307 行:对
0
号线程进行初始化。 - 第 308 - 316 行:创建第一个应用线程,循环打印消息,打印一次消息调用一次
yield_task
函数。 - 第 317 - 325 行:创建第二个应用线程,循环打印消息,打印一次消息调用一次
yield_task
函数。 - 第 326 - 334 行:创建第三个应用线程,循环打印消息,打印一次消息调用一次
yield_task
函数。 - 第 335 - 343 行:创建第四个应用线程,循环打印消息,打印一次消息调用一次
yield_task
函数。 - 第 344 行:通过
run
方法开始循环切换线程。
用户态的线程管理小结:用户态的线程管理并不牵扯到内核态。只是在用户态自己定义的一种线程管理方式。在一个进程中通过用户态的线程管理方法可以使得这个进程又细化了好几个线程。
线程与线程之间区别在于栈和上下文的不同,每个线程都有其自己对应的
id
和状态。线程通过Runtime
进行统一的管理。线程的切换类似于进程的切换,只不过目前这个切换完全发生在用户态,
Runtime
首先对线程的上下文进行切换给运行下一个线程提供环境,其次再将pc
指针指到下一个线程的返回地址便可以从当前线程转到下一个线程了。所有线程都是通过状态来判断其当前的情况的,Runtime
循环调用处于Ready
态的线程,线程运行一次后主动切换到下一个线程让下一个线程运行直到没有处于Ready
态的线程为止,则表明所有线程都执行完全了。
- 首先创建每一个线程
- 通过
runtime.run()
开始执行第一个处于Ready
的线程- 由于每个线程内部都写了
yield_task
函数,所以每个线程不会一直占用着资源,在运行一次后通过yield_task
函数主动让出资源切换到下个一处于Ready
的线程。- 直到没有处于
Ready
的线程则表明都运行结束了则exit(0)
。
【内核态的线程管理】
在用户态进行线程管理,具有一个潜在不足没法让线程管理运行时直接切换线程,只能等当前运行的线程主动让出处理器使用权后,线程管理运行时才能切换检查。
如果扩展一下对线程的管理,那就可以基于时钟中断来直接打断当前用户态线程的运行,实现对线程的调度和切换等。
我们首先分析如何扩展现有的进程,以支持线程管理。然后设计线程的总体结构、管理线程执行的线程控制块数据结构、以及对线程管理相关的重要函数:线程创建和线程切换。并最终合并到现有的进程管理机制中。
- 首先分析用户态与内核态交互的关于线程的接口
- 其次分析用户态应用程序是如何使用这些系统调用接口来使用线程的。
- 最后分析线程内核态的设计机制。
【线程概念】
操作系统让进程拥有相互隔离的虚拟的地址空间,让进程感到在独占一个虚拟的处理器。其实这只是操作系统通过时分复用和空分复用技术来让每个进程复用有限的物理内存和物理CPU。
线程是在进程内中的一个新的抽象。在没有线程之前,一个进程在一个时刻只有一个执行点(即程序计数器 (PC)寄存器保存的要执行指令的指针)。但线程的引入把进程内的这个单一执行点给扩展为多个执行点,即在进程中存在多个线程,每个线程都有一个执行点。而且这些线程共享进程的地址空间,所以可以不必采用相对比较复杂的IPC
机制(一般需要内核的介入),而可以很方便地直接访问进程内的数据。
由于把进程的结构进行了细化,通过线程来表示对处理器的虚拟化,使得进程成为了管理线程的容器。在进程中的线程没有父子关系,大家都是兄弟,但还是有个老大。这个代表老大的线程其实就是创建进程(比如通过 fork
系统调用创建进程)时,建立的第一个线程,它的线程标识符(TID
)为 0
。
【通用操作系统多线程应用程序示例】
Rust
应用开发中,有比较完善的线程API
来支持多线程应用的开发,当然这需要底层的操作系统(如Linux
)的支持。Rust
语言标准库中的 std::thread
模块提供很很多方法用于创建线程、管理线程和结束线程,以支持多线程编程。
spawn()
:创建一个新线程。join()
:把子线程加入主线程等待队列,等待子线程结束。
// thread.rs
use std::thread;
fn mythread(args: i64) -> i64 {
println!("{}", args);
return args + 1;
}
fn main() {
let handle = thread::spawn(|| mythread(100));
let rvalue = handle.join().unwrap();
println!("{}", rvalue);
}
$ rustc thread.rs
$ ./thread
100
101
【线程模型与重要系统调用(中间接口) -> user/src/syscall.rs】
user/src/syscall.rs
在原来的基础上增加了关于线程的系统调用接口,使得应用程序可以通过相应的接口创建线程并等待线程。
// user/src/syscall.rs
pub const SYSCALL_THREAD_CREATE: usize = 460;
pub const SYSCALL_WAITTID: usize = 462;
/// 功能:当前进程创建一个新的线程
/// 参数:entry 表示线程的入口函数地址
/// 参数:arg:表示线程的一个参数
pub fn sys_thread_create(entry: usize, arg: usize) -> isize {
syscall(SYSCALL_THREAD_CREATE, [entry, arg, 0])
}
/// 参数:tid表示线程id
/// 返回值:如果线程不存在,返回-1;如果线程还没退出,返回-2;其他情况下,返回结束线程的退出码
pub fn sys_waittid(tid: usize) -> isize {
syscall(SYSCALL_WAITTID, [tid, 0, 0])
}
-
第 9 - 11 行:定义了
sys_thread_create
系统调用函数,为当前进程创建一个新的线程。 -
第 15 - 17 行:定义了
sys_waittid
系统调用函数,等待它创建出来的线程(不是主线程)结束并回收它们在内核中的资源(如线程的内核栈、线程控制块等)。当一个线程执行完代表它的功能后,会通过
exit
系统调用退出。内核在收到线程发出的exit
系统调用后,会回收线程占用的部分资源,即用户态用到的资源,比如用户态的栈,用于系统调用和异常处理的跳板页等。而该线程的内核态用到的资源,比如内核栈等,需要通过进程/主线程调用waittid
来回收了,这样整个线程才能被彻底销毁。
【应用程序示例(用户态)】
【系统调用封装 -> user/src/lib.rs】
在 user/src/syscall.rs
中看到以 sys_*
开头的系统调用的函数原型,它们后续还会在 user/src/lib.rs
中被封装成方便应用程序使用的形式。
// user/src/lib.rs
pub fn thread_create(entry: usize, arg: usize) -> isize {
sys_thread_create(entry, arg)
}
pub fn waittid(tid: usize) -> isize {
loop {
match sys_waittid(tid) {
-2 => {
yield_();
}
exit_code => return exit_code,
}
}
}
- 第 3 - 5 行:将
sys_thread_create
系统调用函数封装为thread_create
函数。 - 第 7 - 16 行:将
sys_waittid
系统调用函数封装成waittid
函数,waittid
等待一个线程标识符的值为tid
的线程结束。当sys_waittid
返回值为-2
,即要等待的线程存在但它却尚未退出的时候,主线程调用yield_
主动交出CPU
使用权,待下次CPU
使用权被内核交还给它的时候再次调用sys_waittid
查看要等待的线程是否退出。
【多线程应用程序 -> user/src/bin/threads.rs】
// user/src/bin/threads.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
extern crate alloc;
use alloc::vec;
use user_lib::{exit, thread_create, waittid};
pub fn thread_a() -> ! {
let mut t = 2i32;
for _ in 0..1000 {
print!("a");
for __ in 0..5000 {
t = t * t % 10007;
}
}
println!("{}", t);
exit(1)
}
pub fn thread_b() -> ! {
let mut t = 2i32;
for _ in 0..1000 {
print!("b");
for __ in 0..5000 {
t = t * t % 10007;
}
}
println!("{}", t);
exit(2)
}
pub fn thread_c() -> ! {
let mut t = 2i32;
for _ in 0..1000 {
print!("c");
for __ in 0..5000 {
t = t * t % 10007;
}
}
println!("{}", t);
exit(3)
}
#[no_mangle]
pub fn main() -> i32 {
let v = vec![
thread_create(thread_a as usize, 0),
thread_create(thread_b as usize, 0),
thread_create(thread_c as usize, 0),
];
for tid in v.iter() {
let exit_code = waittid(*tid as usize);
println!("thread#{} exited with code {}", tid, exit_code);
assert_eq!(*tid, exit_code);
}
println!("main thread exited.");
println!("threads test passed!");
0
}
- 第 12 - 22 行:创建第一个线程的处理函数
thread_a
,循环打印,退出码为1。 - 第 24 - 34 行:创建第二个线程的处理函数
thread_b
,循环打印,退出码为2。 - 第 36 - 46 行:创建第三个线程的处理函数
thread_c
,循环打印,退出码为3。 - 第 49 - 63 行:为
main
主函数。先调用thread_create
创建了三个线程,加上进程自带的主线程,其实一共有四个线程。每个线程在打印了1000
个字符后,会执行exit
退出。进程通过waittid
等待这三个线程结束后,最终结束进程的执行。
【线程管理的核心数据结构】
为了在现有进程管理的基础上实现线程管理,把进程中与处理器相关的部分分拆出来,形成线程相关的部分。
- 任务控制块
TaskControlBlock
:表示线程的核心数据结构。 - 任务管理器
TaskManager
:管理线程集合的核心数据结构。 - 处理器管理结构
Processor
:用于线程调度,维护线程的处理器状态。
【线程控制块 -> os/src/task/task.rs】
在内核中,每个线程的执行状态和线程上下文等均保存在一个被称为任务控制块 (TCB, Task Control Block
) 的结构中,它是内核对线程进行管理的核心数据结构。在内核看来,它就等价于一个线程。(所以现在最小的可以执行调度的单位为线程)
// os/src/task/task.rs
//! Types related to task management & Functions for completely changing TCB
use super::id::TaskUserRes;
use super::{kstack_alloc, KernelStack, ProcessControlBlock, TaskContext};
use crate::trap::TrapContext;
use crate::{mm::PhysPageNum, sync::UPSafeCell};
use alloc::sync::{Arc, Weak};
use core::cell::RefMut;
/// Task control block structure
///
/// Directly save the contents that will not change during running
pub struct TaskControlBlock {
// immutable
pub process: Weak<ProcessControlBlock>,
/// Kernel stack corresponding to TID
pub kernel_stack: KernelStack,
// mutable
inner: UPSafeCell<TaskControlBlockInner>,
}
/// Structure containing more process content
///
/// Store the contents that will change during operation
/// and are wrapped by UPSafeCell to provide mutual exclusion
pub struct TaskControlBlockInner {
/// The physical page number of the frame where the trap context is placed
pub trap_cx_ppn: PhysPageNum,
/// Save task context
pub task_cx: TaskContext,
/// Maintain the execution status of the current process
pub task_status: TaskStatus,
/// It is set when active exit or execution error occurs
pub exit_code: Option<i32>,
/// Tid and ustack will be deallocated when this goes None
pub res: Option<TaskUserRes>,
}
/// Simple access to its internal fields
impl TaskControlBlockInner {
/*
pub fn get_task_cx_ptr2(&self) -> *const usize {
&self.task_cx_ptr as *const usize
}
*/
pub fn get_trap_cx(&self) -> &'static mut TrapContext {
self.trap_cx_ppn.get_mut()
}
#[allow(unused)]
fn get_status(&self) -> TaskStatus {
self.task_status
}
}
impl TaskControlBlock {
pub fn new(
process: Arc<ProcessControlBlock>,
ustack_base: usize,
alloc_user_res: bool,
) -> Self {
let res = TaskUserRes::new(Arc::clone(&process), ustack_base, alloc_user_res);
let trap_cx_ppn = res.trap_cx_ppn();
let kernel_stack = kstack_alloc();
let kstack_top = kernel_stack.get_top();
Self {
process: Arc::downgrade(&process),
kernel_stack,
inner: unsafe {
UPSafeCell::new(TaskControlBlockInner {
res: Some(res),
trap_cx_ppn,
task_cx: TaskContext::goto_trap_return(kstack_top),
task_status: TaskStatus::Ready,
exit_code: None,
})
},
}
}
/// Get the mutex to get the RefMut TaskControlBlockInner
pub fn inner_exclusive_access(&self) -> RefMut<'_, TaskControlBlockInner> {
let inner = self.inner.exclusive_access();
// if self.process.upgrade().unwrap().pid.0 > 1 {
// if let Some(res) = inner.res.as_ref() {
// println!("t{}i", res.tid);
// }
// }
inner
}
pub fn get_user_token(&self) -> usize {
let process = self.process.upgrade().unwrap();
let inner = process.inner_exclusive_access();
inner.memory_set.token()
}
pub fn create_kthread(f: fn()) -> Self {
use crate::mm::PhysAddr;
let process = ProcessControlBlock::kernel_process();
let process = Arc::downgrade(&process);
let kernelstack = crate::task::id::KStack::new();
let kstack_top = kernelstack.top();
let mut context = TaskContext::zero_init();
let context_addr = &context as *const TaskContext as usize;
let pa = PhysAddr::from(context_addr);
let context_ppn = pa.floor();
context.ra = f as usize;
context.sp = kstack_top;
//println!("context ppn :{:#x?}", context_ppn);
Self {
process,
kernel_stack: KernelStack(kstack_top),
//kstack,
inner: unsafe {
UPSafeCell::new(TaskControlBlockInner {
res: None,
trap_cx_ppn: context_ppn,
task_cx: context,
task_status: TaskStatus::Ready,
exit_code: None,
})
},
}
}
}
#[derive(Copy, Clone, PartialEq)]
/// task status: UnInit, Ready, Running, Exited
pub enum TaskStatus {
UnInit,
Ready,
Running,
Blocking,
}
- 第 15 - 22 行:定义了任务控制块结构体
TaskControlBlock
,任务控制块就是线程控制块。- 第 17 行:
process
元素代表了该线程所属的进程。 - 第 19 行:
kernel_stack
元素代表了该线程的内核栈。 - 第 21 行:
inner
元素保存了在运行过程中可能发生变化的元数据。
- 第 17 行:
- 第 28 - 39 行:定义了
TaskControlBlockInner
结构体,与线程相关的大部分的细节放在TaskControlBlockInner
中。- 第 30 行:
trap_cx_ppn
指出了Trap
上下文被放在的物理页帧的物理页号。 - 第 32 行:
task_cx
保存任务上下文,用于任务切换。 - 第 34 行:
task_status
维护当前线程的执行状态。 - 第 36 行:
exit_code
保存了线程退出码。 - 第 38 行:
res
指出了用户态的线程代码执行需要的信息,这些在线程初始化之后就不再变化。
- 第 30 行:
【包含线程的进程控制块 -> os/src/task/process.rs】
把线程相关数据单独组织成数据结构后,进程的结构也需要进行一定的调整。
// os/src/task/process.rs
use super::id::RecycleAllocator;
use super::{add_task, pid_alloc, PidHandle, TaskControlBlock};
use crate::fs::{File, Stdin, Stdout};
use crate::mm::{translated_refmut, MemorySet, KERNEL_SPACE};
use crate::sync::{Condvar, Mutex, Semaphore, UPSafeCell};
use crate::trap::{trap_handler, TrapContext};
use alloc::string::String;
use alloc::sync::{Arc, Weak};
use alloc::vec;
use alloc::vec::Vec;
use core::cell::RefMut;
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 tasks: Vec<Option<Arc<TaskControlBlock>>>,
pub task_res_allocator: RecycleAllocator,
pub mutex_list: Vec<Option<Arc<dyn Mutex>>>,
pub semaphore_list: Vec<Option<Arc<Semaphore>>>,
pub condvar_list: Vec<Option<Arc<Condvar>>>,
}
impl ProcessControlBlockInner {
#[allow(unused)]
pub fn get_user_token(&self) -> usize {
self.memory_set.token()
}
pub fn alloc_fd(&mut self) -> usize {
if let Some(fd) = (0..self.fd_table.len()).find(|fd| self.fd_table[*fd].is_none()) {
fd
} else {
self.fd_table.push(None);
self.fd_table.len() - 1
}
}
pub fn alloc_tid(&mut self) -> usize {
self.task_res_allocator.alloc()
}
pub fn dealloc_tid(&mut self, tid: usize) {
self.task_res_allocator.dealloc(tid)
}
pub fn thread_count(&self) -> usize {
self.tasks.len()
}
pub fn get_task(&self, tid: usize) -> Arc<TaskControlBlock> {
self.tasks[tid].as_ref().unwrap().clone()
}
}
impl ProcessControlBlock {
pub fn inner_exclusive_access(&self) -> RefMut<'_, ProcessControlBlockInner> {
self.inner.exclusive_access()
}
// LAB5 HINT: How to initialize deadlock data structures?
pub fn new(elf_data: &[u8]) -> Arc<Self> {
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, ustack_base, entry_point) = MemorySet::from_elf(elf_data);
// allocate a pid
let pid_handle = pid_alloc();
let process = Arc::new(Self {
pid: pid_handle,
inner: unsafe {
UPSafeCell::new(ProcessControlBlockInner {
is_zombie: false,
memory_set,
parent: None,
children: Vec::new(),
exit_code: 0,
fd_table: vec![
// 0 -> stdin
Some(Arc::new(Stdin)),
// 1 -> stdout
Some(Arc::new(Stdout)),
// 2 -> stderr
Some(Arc::new(Stdout)),
],
tasks: Vec::new(),
task_res_allocator: RecycleAllocator::new(),
mutex_list: Vec::new(),
semaphore_list: Vec::new(),
condvar_list: Vec::new(),
})
},
});
// create a main thread, we should allocate ustack and trap_cx here
let task = Arc::new(TaskControlBlock::new(
Arc::clone(&process),
ustack_base,
true,
));
// prepare trap_cx of main thread
let task_inner = task.inner_exclusive_access();
let trap_cx = task_inner.get_trap_cx();
let ustack_top = task_inner.res.as_ref().unwrap().ustack_top();
let kernel_stack_top = task.kernel_stack.get_top();
drop(task_inner);
*trap_cx = TrapContext::app_init_context(
entry_point,
ustack_top,
KERNEL_SPACE.exclusive_access().token(),
kernel_stack_top,
trap_handler as usize,
);
// add main thread to the process
let mut process_inner = process.inner_exclusive_access();
process_inner.tasks.push(Some(Arc::clone(&task)));
drop(process_inner);
// add main thread to scheduler
add_task(task);
process
}
// LAB5 HINT: How to initialize deadlock data structures?
/// Load a new elf to replace the original application address space and start execution
/// Only support processes with a single thread.
pub fn exec(self: &Arc<Self>, elf_data: &[u8], args: Vec<String>) {
assert_eq!(self.inner_exclusive_access().thread_count(), 1);
// memory_set with elf program headers/trampoline/trap context/user stack
let (memory_set, ustack_base, entry_point) = MemorySet::from_elf(elf_data);
let new_token = memory_set.token();
// substitute memory_set
self.inner_exclusive_access().memory_set = memory_set;
// then we alloc user resource for main thread again
// since memory_set has been changed
let task = self.inner_exclusive_access().get_task(0);
let mut task_inner = task.inner_exclusive_access();
task_inner.res.as_mut().unwrap().ustack_base = ustack_base;
task_inner.res.as_mut().unwrap().alloc_user_res();
task_inner.trap_cx_ppn = task_inner.res.as_mut().unwrap().trap_cx_ppn();
// push arguments on user stack
let mut user_sp = task_inner.res.as_mut().unwrap().ustack_top();
user_sp -= (args.len() + 1) * core::mem::size_of::<usize>();
let argv_base = user_sp;
let mut argv: Vec<_> = (0..=args.len())
.map(|arg| {
translated_refmut(
new_token,
(argv_base + arg * core::mem::size_of::<usize>()) as *mut usize,
)
})
.collect();
*argv[args.len()] = 0;
for i in 0..args.len() {
user_sp -= args[i].len() + 1;
*argv[i] = user_sp;
let mut p = user_sp;
for c in args[i].as_bytes() {
*translated_refmut(new_token, p as *mut u8) = *c;
p += 1;
}
*translated_refmut(new_token, p as *mut u8) = 0;
}
// make the user_sp aligned to 8B for k210 platform
user_sp -= user_sp % core::mem::size_of::<usize>();
// initialize trap_cx
let mut trap_cx = TrapContext::app_init_context(
entry_point,
user_sp,
KERNEL_SPACE.exclusive_access().token(),
task.kernel_stack.get_top(),
trap_handler as usize,
);
trap_cx.x[10] = args.len();
trap_cx.x[11] = argv_base;
*task_inner.get_trap_cx() = trap_cx;
}
// LAB5 HINT: How to initialize deadlock data structures?
/// Fork from parent to child
/// Only support processes with a single thread.
pub fn fork(self: &Arc<Self>) -> Arc<Self> {
let mut parent = self.inner_exclusive_access();
assert_eq!(parent.thread_count(), 1);
// clone parent's memory_set completely including trampoline/ustacks/trap_cxs
let memory_set = MemorySet::from_existed_user(&parent.memory_set);
// alloc a pid
let pid = pid_alloc();
// copy fd table
let mut new_fd_table: Vec<Option<Arc<dyn File + Send + Sync>>> = Vec::new();
for fd in parent.fd_table.iter() {
if let Some(file) = fd {
new_fd_table.push(Some(file.clone()));
} else {
new_fd_table.push(None);
}
}
// create child process pcb
let child = Arc::new(Self {
pid,
inner: unsafe {
UPSafeCell::new(ProcessControlBlockInner {
is_zombie: false,
memory_set,
parent: Some(Arc::downgrade(self)),
children: Vec::new(),
exit_code: 0,
fd_table: new_fd_table,
tasks: Vec::new(),
task_res_allocator: RecycleAllocator::new(),
mutex_list: Vec::new(),
semaphore_list: Vec::new(),
condvar_list: Vec::new(),
})
},
});
// add child
parent.children.push(Arc::clone(&child));
// create main thread of child process
let task = Arc::new(TaskControlBlock::new(
Arc::clone(&child),
parent
.get_task(0)
.inner_exclusive_access()
.res
.as_ref()
.unwrap()
.ustack_base(),
// here we do not allocate trap_cx or ustack again
// but mention that we allocate a new kernel_stack here
false,
));
// attach task to child process
let mut child_inner = child.inner_exclusive_access();
child_inner.tasks.push(Some(Arc::clone(&task)));
drop(child_inner);
// modify kernel_stack_top in trap_cx of this thread
let task_inner = task.inner_exclusive_access();
let trap_cx = task_inner.get_trap_cx();
trap_cx.kernel_sp = task.kernel_stack.get_top();
drop(task_inner);
// add this thread to scheduler
add_task(task);
child
}
pub fn getpid(&self) -> usize {
self.pid.0
}
pub fn kernel_process() -> Arc<Self> {
let memory_set = MemorySet::kernel_copy();
let process = Arc::new(ProcessControlBlock {
pid: super::pid_alloc(),
inner: unsafe {
UPSafeCell::new(ProcessControlBlockInner {
is_zombie: false,
memory_set: memory_set,
parent: None,
children: Vec::new(),
exit_code: 0,
fd_table: Vec::new(),
tasks: Vec::new(),
task_res_allocator: RecycleAllocator::new(),
mutex_list: Vec::new(),
semaphore_list: Vec::new(),
condvar_list: Vec::new(),
})
},
});
process
}
}
- 第 14 - 19 行:为修改后的进程控制块,包括进程
ID
和inner
两个段。 - 第 21 - 33 行:定义了与进程相关的大部分的细节。
- 第 28 行:
tasks
字段为线程控制块向量,这就自然对应到多个线程的管理上了。 - 第 30 行:
mutex_list: Vec<Option<Arc<dyn Mutex>>>
表示的是实现了Mutex
trait 的一个“互斥资源”的向量。 - 第 31 行:
semaphore_list: Vec<Option<Arc<Semaphore>>>
表示的是信号量资源的列表。 - 第 32 行:
condvar_list: Vec<Option<Arc<Condvar>>>
表示的是条件变量资源的列表。
- 第 28 行:
- 第 289 行:
task_res_allocator
是之前的PidAllocator
的一个升级版,即一个相对通用的资源分配器,可用于分配进程标识符(PID
)和线程的内核栈。
【线程管理机制的设计与实现(内核态)】
- 线程创建、线程退出与等待线程结束
- 线程执行中的特权级切换
- 进程管理中与线程相关的处理
【线程创建 -> os/src/syscall/thread.rs】
一个进程执行中发出了创建线程的系统调用 sys_thread_create
后,操作系统就需要在当前进程的基础上创建一个线程了。
- 线程的用户态栈:确保在用户态的线程能正常执行函数调用;
- 线程的内核态栈:确保线程陷入内核后能正常执行函数调用;
- 线程的跳板页:确保线程能正确的进行用户态<–>内核态切换;
- 线程上下文:即线程用到的寄存器信息,用于线程切换。
os/src/syscall/thread.rs
该文件保存了与线程相关系统调用的内核态处理。
// os/src/syscall/thread.rs
use crate::{
mm::kernel_token,
task::{add_task, current_task, TaskControlBlock},
trap::{trap_handler, TrapContext},
};
use alloc::sync::Arc;
pub fn sys_thread_create(entry: usize, arg: usize) -> isize {
let task = current_task().unwrap();
let process = task.process.upgrade().unwrap();
// create a new thread
let new_task = Arc::new(TaskControlBlock::new(
Arc::clone(&process),
task.inner_exclusive_access()
.res
.as_ref()
.unwrap()
.ustack_base,
true,
));
let new_task_inner = new_task.inner_exclusive_access();
let new_task_res = new_task_inner.res.as_ref().unwrap();
let new_task_tid = new_task_res.tid;
let new_task_trap_cx = new_task_inner.get_trap_cx();
*new_task_trap_cx = TrapContext::app_init_context(
entry,
new_task_res.ustack_top(),
kernel_token(),
new_task.kernel_stack.get_top(),
trap_handler as usize,
);
(*new_task_trap_cx).x[10] = arg;
let mut process_inner = process.inner_exclusive_access();
// add new thread to current process
let tasks = &mut process_inner.tasks;
while tasks.len() < new_task_tid + 1 {
tasks.push(None);
}
tasks[new_task_tid] = Some(Arc::clone(&new_task));
// add new task to scheduler
add_task(Arc::clone(&new_task));
new_task_tid as isize
}
pub fn sys_gettid() -> isize {
current_task()
.unwrap()
.inner_exclusive_access()
.res
.as_ref()
.unwrap()
.tid as isize
}
/// thread does not exist, return -1
/// thread has not exited yet, return -2
/// otherwise, return thread's exit code
pub fn sys_waittid(tid: usize) -> i32 {
let task = current_task().unwrap();
let process = task.process.upgrade().unwrap();
let task_inner = task.inner_exclusive_access();
let mut process_inner = process.inner_exclusive_access();
// a thread cannot wait for itself
if task_inner.res.as_ref().unwrap().tid == tid {
return -1;
}
let mut exit_code: Option<i32> = None;
let waited_task = process_inner.tasks[tid].as_ref();
if let Some(waited_task) = waited_task {
if let Some(waited_exit_code) = waited_task.inner_exclusive_access().exit_code {
exit_code = Some(waited_exit_code);
}
} else {
// waited thread does not exist
return -1;
}
if let Some(exit_code) = exit_code {
// dealloc the exited thread
process_inner.tasks[tid] = None;
exit_code
} else {
// waited thread has not exited
-2
}
}
- 第 9 - 45 行:实现了
sys_thread_create
系统调用创建一个线程。- 第 10 - 11 行:找到当前正在执行的线程
task
和此线程所属的进程process
。 - 第 13 - 21 行:调用
TaskControlBlock::new
方法,创建一个新的线程new_task
,在创建过程中,建立与进程process
的所属关系,分配了线程用户态栈、内核态栈、用于异常/中断的跳板页。 - 第 26 - 33 行:初始化位于该线程在用户态地址空间中的 Trap 上下文:设置线程的函数入口点和用户栈,使得第一次进入用户态时能从线程起始位置开始正确执行;设置好内核栈和陷入函数指针
trap_handler
,保证在Trap
的时候用户态的线程能正确进入内核态。 - 第 37 - 40 行:判断线程向量表中是否还有足够的空间放下这个新的线程。
- 第 41 行:把线程接入到所需进程的线程列表
tasks
中。 - 第 43 - 44 行:将新加入的线程带入到调度队列中,并返回新创建线程的
tid
。
- 第 10 - 11 行:找到当前正在执行的线程
- 第 60 - 87 行:实现了
sys_waittid
系统调用,主线程通过系统调用sys_waittid
来等待其他线程的结束。- 第 66 - 68 行:如果是线程等自己,返回错误。
- 第 69 行:清空退出码。
- 第 70 - 78 行:如果找到
tid
对应的退出线程,则收集该退出线程的退出码exit_tid
,否则返回错误(退出线程不存在)。 - 第 79 - 86 行:如果退出码存在,则清空进程中对应此退出线程的线程控制块(至此,线程所占资源算是全部清空了),否则返回错误(线程还没退出)。
【线程退出 -> os/src/syscall/process.rs -> os/src/task/mod.rs】
当一个非主线程的其他线程发出 sys_exit
系统调用时,内核会调用 exit_current_and_run_next
函数退出当前线程并切换到下一个线程,但不会导致其所属进程的退出。当主线程即进程发出这个系统调用,当内核收到这个系统调用后,会回收整个进程(这包括了其管理的所有线程)资源,并退出。
// os/src/syscall/process.rs
pub fn sys_exit(exit_code: i32) -> ! {
// debug!("[kernel] Application exited with code {}", exit_code);
exit_current_and_run_next(exit_code);
panic!("Unreachable in sys_exit!");
}
- 第 2 - 6 行:实现了
sys_exit
系统调用,该系统调用具体实现为exit_current_and_run_next
函数。
os/src/task/mod.rs
该文件中的exit_current_and_run_next
函数进行了修改,对于不同的线程处理有所不同。
/// os/src/task/mod.rs
/// Exit current task, recycle process resources and switch to the next task
pub fn exit_current_and_run_next(exit_code: i32) {
// take from Processor
let task = take_current_task().unwrap();
// **** access current TCB exclusively
let mut task_inner = task.inner_exclusive_access();
let process = task.process.upgrade().unwrap();
let tid = task_inner.res.as_ref().unwrap().tid;
// Record exit code
task_inner.exit_code = Some(exit_code);
task_inner.res = None;
// here we do not remove the thread since we are still using the kstack
// it will be deallocated when sys_waittid is called
drop(task_inner);
drop(task);
// debug!("task {} dropped", tid);
if tid == 0 {
let mut process_inner = process.inner_exclusive_access();
// mark this process as a zombie process
process_inner.is_zombie = true;
// record exit code of main process
process_inner.exit_code = exit_code;
// do not move to its parent but under initproc
// debug!("reparent");
// ++++++ access initproc PCB exclusively
{
let mut initproc_inner = INITPROC.inner_exclusive_access();
for child in process_inner.children.iter() {
child.inner_exclusive_access().parent = Some(Arc::downgrade(&INITPROC));
initproc_inner.children.push(child.clone());
}
}
let mut recycle_res = Vec::<TaskUserRes>::new();
// debug!("deallocate user res");
// deallocate user res (including tid/trap_cx/ustack) of all threads
// it has to be done before we dealloc the whole memory_set
// otherwise they will be deallocated twice
for task in process_inner.tasks.iter().filter(|t| t.is_some()) {
let task = task.as_ref().unwrap();
let mut task_inner = task.inner_exclusive_access();
if let Some(res) = task_inner.res.take() {
recycle_res.push(res);
}
}
drop(process_inner);
recycle_res.clear();
let mut process_inner = process.inner_exclusive_access();
// debug!("deallocate pcb res");
process_inner.children.clear();
// deallocate other data in user space i.e. program code/data section
process_inner.memory_set.recycle_data_pages();
// drop file descriptors
process_inner.fd_table.clear();
}
// debug!("pcb dropped");
// ++++++ release parent PCB
drop(process);
// we do not have to save task context
let mut _unused = TaskContext::zero_init();
schedule(&mut _unused as *mut _);
}
- 第 5 - 17 行:回收线程的各种资源。
- 第 20 - 60 行:如果是主线程发出的退去请求,则回收整个进程的部分资源,并退出进程。
- 第 21 行:获取该进程的
inner
权限。 - 第 23 - 25 行:将当前进程设置为僵尸进程并保存退出码。
- 第 31 - 37 行:将当前进程的所有子进程挂在初始进程
INITPROC
下面,其做法是遍历每个子进程,修改其父进程为初始进程,并加入初始进程的孩子向量中。 - 第 44 - 52 行:将当前进程的孩子向量清空,清空其res字段。
- 第 21 行:获取该进程的
- 第 67 - 68 行:进行线程调度切换。
内核态的线程管理小结:在这里已经认为
TCB
是进行调度的单位了,一个PCB
管理许多的TCB
。
PCB -> PCBInner -> TCB -> TCBInner
。内核在创建进程的时候便已经创建了
0
号线程了,所以我们可以认为进程是线程的资源管理。
- 刚开始内核创建进程执行应用,同时也创建0号线程。
- 0号线程通过系统调用创建1号线程、2号线程等,他们都处于同一个进程中。
- 0号线程通过
waittid
进入内核态等待他所创建的线程结束。加入没结束当前应用则执行yield
主动释放CPU
资源的消耗也就执行了线程的切换,或者通过时钟中断等方式进行线程的切换,这中间的切换需要有内核的参与。- 等结束了则进行资源的释放。
可以理解为从此开始
TCB
代替了PCB
,PCB
除了0
号线程还具有原来进行占用CPU
的机会,其余只是进行了资源的隔离,方便了线程之间的通信与资源共享。其与用户态的线程管理的区别在于线程的创建、管理、调度、释放等都需要内核的参与。
【锁机制】
锁机制确保无论操作系统如何抢占线程,调度和切换线程的执行, 都可以保证对拥有锁的线程,可以独占地对共享数据进行读写,从而能够得到正确的共享数据结果。这种机制的能力来自于处理器的指令、操作系统系统调用的基本支持。
互斥性也是一种原子性, 即线程在临界区的执行过程中,不会出现只执行了一部分,就被打断并切换到其他线程执行的情况。即, 要么线程执行的这一系列操作/指令都完成,要么这一系列操作/指令都不做,不会出现指令序列执行中被打断的情况。
【锁的基本思路】
保证多线程并发执行中的临界区的代码具有互斥性或原子性,我们可以建立一种锁,只有拿到锁的线程才能在临界区中执行。
lock(mutex); // 尝试取锁
a=a+1; // 临界区,访问临界资源 a
unlock(mutex); // 是否锁
... // 剩余区
通过设置一种所有线程能看到的标记, 在一个能进入临界区的线程设置好这个标记后,其他线程都不能再进入临界区了。总体上对临界区的访问过程分为四个部分:
- 尝试取锁: 查看锁是否可用,即临界区是否可访问(看占用临界区标志是否被设置),如果可以访问, 则设置占用临界区标志(锁不可用)并转到步骤 2 ,否则线程忙等或被阻塞;
- 临界区: 访问临界资源的系列操作
- 释放锁: 清除占用临界区标志(锁可用),如果有线程被阻塞,会唤醒阻塞线程;
- 剩余区: 与临界区不相关部分的代码
根据上面的步骤,可以看到锁机制有两种:让线程忙等的忙等锁(spin lock
),以及让线程阻塞的睡眠锁 (sleep lock
)。锁的实现大体上基于三类机制:用户态软件、机器指令硬件、内核态操作系统。 一般我们需要关注锁的三种属性:
- 互斥性(
mutual exclusion
),即锁是否能够有效阻止多个线程进入临界区,这是最基本的属性。 - 公平性(
fairness
),当锁可用时,每个竞争线程是否有公平的机会抢到锁。 - 性能(
performance
),即使用锁的时间开销。
【用户态软件级方法实现锁】
我们可以用一个变量来表示锁的状态:已占用临界区为1,未占用临界区为0。
static mut mutex :i32 = 0;
fn lock(mutex: i32) {
while (mutex);
mutex = 1;
}
fn unlock(mutex: i32){
mutex = 0;
}
【问题】:mutex
其实也是一个全局共享变量,它也会让多个线程访问,在多个线程执行 lock
函数的时候,其实不能保证 lock
函数本身的互斥性。这就会带来问题,这样到第11
步,两个线程都能够继续执行,并进入临界区,我们期望的互斥性并没有达到。
时间 | T0 | T1 | OS | 共享变量mutex |
---|---|---|---|---|
1 | L4 | – | – | 0 |
2 | – | – | 切换 | 0 |
3 | – | L4 | – | 0 |
4 | – | – | 切换 | 0 |
5 | L5(赋值1之前) | – | – | 0 |
6 | – | – | 切换 | 0 |
7 | – | L5(赋值1之前) | – | 0 |
8 | – | – | 切换 | 0 |
9 | L5(赋值1之后) | – | – | 1 |
10 | – | – | 切换 | 1 |
11 | – | L5(赋值1之后) | – | 1 |
【进化 -> Peterson算法】:在用户态用软件方法实现锁,单靠一个 mutex
变量无法阻止线程在操作系统任意调度的情况下,越过 while
这个阻挡的判断循环。我们需要新的全局变量来帮忙:
static mut flag : [i32;2] = [0,0]; // 哪个线程想拿到锁?
static mut turn : i32 = 0; // 排号:轮到哪个线程? (线程 0 or 1?)
fn lock() {
flag[self] = 1; // 设置自己想取锁 self: 线程 ID
turn = 1 - self; // 设置另外一个线程先排号
while ((flag[1-self] == 1) && (turn == 1 - self)); // 忙等
}
fn unlock() {
flag[self] = 0; // 设置自己放弃锁
}
- 变量
turn
表示哪个线程可以进入临界区。即如果turn == i
,那么线程Ti
允许在临界区内执行。 - 数组
flag[i]
表示哪个线程准备进入临界区。即如果flag[i]
为1
,那么线程Ti
准备进入临界区,否则表示线程Ti
不打算进入临界区。
【思想】:为了进入临界区,线程 Ti
首先设置 flag[i]
的值为 1
;并且设置 turn
的值为 j
,从而表示如果另一个线程 Tj
希望进入临界区,那么 Tj
能够进入。如果两个线程同时试图进入,那么 turn
会几乎在同时设置成 i
或 j
。但只有一个赋值语句的结果会保持;另一个也会设置,但会立即被重写。变量 turn
的最终值决定了哪个线程允许先进入临界区。
【保证互斥思想】:
如果两个线程同时在临界区内执行,那么 flag[0]==flag[1]==true
。意味着线程T0
和 T1
不可能同时成功地执行它们的 while
语句,因为 turn
的值只可能为 0
或 1
(目前只有两个线程0
和1
),而不可能同时为两个值。如果turn
的值为j
, 那么只有一个线程 Tj
能成功跳出 while
语句,而另外一个线程 Ti
不得不再次陷入判断(“turn == j”
)的循环而无法跳出。
只要在临界区内,flag[j]==true 和 turn==j 就同时成立。这就保证了只有一个线程能进入临界区的互斥性。
个人理解:前面方法不行的原因是在于你修改标志位状态后后面会有其他的线程进行修改导致两者都可以通过判断进入临界区。而这种孔融让梨式的方法保证了你的线程如果想要进入临界区则另外一个线程必须已经修改标志位成功了,并且当前线程能够到达这里判断也已经赋值成功了,相当于等最后一个赋值成功的人,按照他所写的线程开始优先执行。而这个时候就不会有其他线程修改导致两者都进入临界区的情况。
【机器指令硬件级方法实现锁】
多线程结果不确定的一个重要因素是操作系统随时有可能切换线程。如果操作系统在临界区执行时,无法进行线程调度和切换,就可以解决结果不确定的问题了。
操作系统能抢占式调度的一个前提是硬件中断机制,如时钟中断能确保操作系统按时获得对处理器的控制权。如果应用程序能够控制中断的打开/使能与关闭/屏蔽,那就能提供互斥解决方案了。没有中断,线程可以确信它的代码会继续执行下去,不会被其他线程干扰。
fn lock() {
disable_Interrupt(); //屏蔽中断的机器指令
}
fn unlock() {
enable_Interrupt(); 使能中断的机器指令
}
【不足】
- 它给了用户态程序执行特权操作的能力。如果用户态线程在执行过程中刻意关闭中断,它就可以独占处理器,让操作系统无法获得对处理器的控制权。
- 这种方法不支持多处理器。如果多个线程运行在不同的处理器上,每个线程都试图进入同一个临界区,它关闭的中断只对其正在运行的处理器有效,其他线程可以运行在其他处理器上,还是能够进入临界区,无法保证互斥性。
【CAS原子指令和TAS原子指令】
读写一个变量的两个操作是一条不会被操作系统打断的机器指令来执行,那我们就可以很容易实现锁机制了,这种机器指令我们称为原子指令。
处理器体系结构提供了一条原子指令:比较并交换(Compare-And-Swap,简称CAS)指令,即比较一个寄存器中的值和另一个寄存器中的内存地址指向的值,如果它们相等,将第三个寄存器中的值和内存中的值进行交换。
fn CompareAndSwap(ptr: *i32, expected: i32, new: i32) -> i32 {
let actual :i32 = *ptr;
if actual == expected {
*ptr = new;
}
actual
}
fn lock(mutex : *i32) {
while (CompareAndSwap(mutex, 0, 1) == 1);
}
fn unlock((mutex : *i32){
*mutex = 0;
}
基本思路是检测ptr
指向的实际值是否和expected
相等;如果相等,更新ptr
所指的值为new
值;最后返回该内存地址之前指向的实际值。有了比较并交换指令,就可以实现对锁读写的原子操作了。在lock
函数中,检查锁标志是否为0
,如果是,原子地交换为1
,从而获得锁。锁被持有时,竞争锁的线程会忙等在while
循环中。
处理器体系结构提供了另外一条原子指令:测试并设置(Test-And-Set
,简称 TAS
)。因为既可以得到旧值,又可以设置新值,所以我们把这条指令叫作“测试并设置”。
fn TestAndSet(old_ptr: &mut i32, new:i32) -> i32 {
let old :i32 = *old_ptr; // 取得 old_ptr 指向内存单元的旧值 old
*old_ptr = new; // 把新值 new 存入到 old_ptr 指向的内存单元中
old // 返回旧值 old
}
static mut mutex :i32 = 0;
fn lock(mutex: &mut i32) {
while (TestAndSet(mutex, 1) == 1);
}
fn unlock(mutex: &mut i32){
*mutex = 0;
}
TAS
原子指令完成返回old_ptr
指向的旧值,同时更新为new
的新值这一整个原子操作。
- 在一开始时,假设一个线程在运行,调用
lock()
,没有其他线程持有锁,所以mutex
为0
。当调用TestAndSet(&mutex, 1)
函数后,返回0
,线程会跳出while
循环,获取锁。同时也会原子的设置mutex
为1
,标志锁已经被持有。当线程离开临界区,调用unlock(mutex)
将mutex
设置为0
,表示没有线程在临界区,其他线程可以尝试获取锁。 - 如果当某线程已经持有锁(即
mutex
为1
),而另外的线程调用lock(mutex)
函数,然后调用TestAndSet(&mutex, 1)
函数,这一次将返回1
,导致该线程会一直执行while
循环。只要某线程一直持有锁,TestAndSet()
会重复返回1
,导致另外的其他线程会一直自旋忙等。当某线程离开临界区时,会调用unlock(mutex)
函数把mutex
改为0
,这之后另外的其他一个线程会调用TestAndSet()
,返回0
并且原子地设置为1
,从而获得锁,进入临界区。这样进行了对临界区的互斥保证。
【RISC-V的AMO指令与LR/SC指令】
RISC-V
指令集虽然没有TAS
指令和CAS
指令,但它提供了一个可选的原子指令集合,主要有两类:
- 内存原子操作(
AMO
):对内存中的操作数执行一个原子的读写操作,并将目标寄存器设置为操作前的内存值。 - 加载保留/条件存储(
Load Reserved / Store Conditional
,检查LR/SC
):LR
指令读取一个内存字,存入目标寄存器中,并留下这个字的保留记录。而如果SC
指令的目标地址上存在保留记录,它就把字存入这个地址。
我们可以用LR/SC
来实现上面基于TAS
原子指令或CAS
原子指令的锁机制:
# RISC-V sequence for implementing a TAS at (s1)
li t2, 1 # t2 <-- 1
Try: lr t1, s1 # t1 <-- mem[s1] (load reserved)
bne t1, x0, Try # if t1 != 0, goto Try:
sc t0, s1, t2 # mem[s1] <-- t2 (store conditional)
bne t0, x0, Try # if t0 !=0 ('sc' Instr failed), goto Try:
Locked:
... # critical section
Unlock:
sw x0,0(s1) # mem[s1] <-- 0
机器指令硬件级实现锁机制总结:其主要思路是通过机器指令级别的原子操作,导致在进行数据读取与存储的时候是一次完成的并不会被其他的线程进行干扰导致读取的数据有误。通过原子操作保证了在进行标志位读写的过程中不会有其他线程打断,则就不会发生不确定性。
【内核态操作系统级方法实现锁】
【实现锁:mutex系统调用】
实现轻量的可睡眠锁:让等待锁的线程睡眠,让释放锁的线程显式地唤醒等待锁的线程。如果有多个等待锁的线程,可以全部释放,让大家再次竞争锁;也可以只释放最早等待的那个线程。
user/src/bin/race_adder_mutex_blocking.rs
为多线程的应用程序
// user/src/bin/race_adder_mutex_blocking.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
extern crate alloc;
use alloc::vec::Vec;
use user_lib::{exit, get_time, thread_create, waittid};
use user_lib::{mutex_create, mutex_lock, mutex_unlock};
static mut A: usize = 0;
const PER_THREAD: usize = 1000;
const THREAD_COUNT: usize = 16;
unsafe fn f() -> ! {
let mut t = 2usize;
for _ in 0..PER_THREAD {
mutex_lock(0);
let a = &mut A as *mut usize;
let cur = a.read_volatile();
for _ in 0..500 {
t = t * t % 10007;
}
a.write_volatile(cur + 1);
mutex_unlock(0);
}
exit(t as i32)
}
#[no_mangle]
pub fn main() -> i32 {
let start = get_time();
assert_eq!(mutex_create(), 0);
let mut v = Vec::new();
for _ in 0..THREAD_COUNT {
v.push(thread_create(f as usize, 0) as usize);
}
let mut time_cost = Vec::new();
for tid in v.iter() {
time_cost.push(waittid(*tid));
}
println!("time cost is {}ms", get_time() - start);
assert_eq!(unsafe { A }, PER_THREAD * THREAD_COUNT);
println!("race adder using spin mutex test passed!");
0
}
- 第 20 行:尝试获取锁(对应的是
SYSCALL_MUTEX_LOCK
系统调用),如果取得锁,将继续向下执行临界区代码;如果没有取得锁,将阻塞。 - 第 27 行:释放锁(对应的是
SYSCALL_MUTEX_UNLOCK
系统调用),如果有等待在该锁上的线程,则唤醒这些等待线程。 - 第 34 行:获取当前的时间。
- 第 35 行:创建了一个
ID
为0
的互斥锁。 - 第 36 - 39 行:创建一个线程向量堆,线程执行的函数为
f
。 - 第 40 - 43 行:等待所有的线程退出。
- 第 44 行:打印出所有线程总共的运行时间。
以上应用层所使用的系统调用在usr/src/syscall.rs
中。
// usr/src/syscall.rs
pub fn sys_mutex_create(blocking: bool) -> isize {
syscall(SYSCALL_MUTEX_CREATE, [blocking as usize, 0, 0])
}
pub fn sys_mutex_lock(id: usize) -> isize {
syscall(SYSCALL_MUTEX_LOCK, [id, 0, 0])
}
pub fn sys_mutex_unlock(id: usize) -> isize {
syscall(SYSCALL_MUTEX_UNLOCK, [id, 0, 0])
}
在线程的眼里,互斥是一种每个线程能看到的资源,且在一个进程中,可以存在多个不同互斥资源,所以我们可以把所有的互斥资源放在一起让进程来管理。
os/src/sync/mutex.rs
下定义了关于互斥相关的结构和方法。
// os/src/sync/mutex.rs
use super::UPSafeCell;
use crate::task::TaskControlBlock;
use crate::task::{add_task, current_task};
use crate::task::{block_current_and_run_next, suspend_current_and_run_next};
use alloc::{collections::VecDeque, sync::Arc};
pub trait Mutex: Sync + Send {
fn lock(&self);
fn unlock(&self);
}
pub struct MutexSpin {
locked: UPSafeCell<bool>,
}
impl MutexSpin {
pub fn new() -> Self {
Self {
locked: unsafe { UPSafeCell::new(false) },
}
}
}
impl Mutex for MutexSpin {
fn lock(&self) {
loop {
let mut locked = self.locked.exclusive_access();
if *locked {
drop(locked);
suspend_current_and_run_next();
continue;
} else {
*locked = true;
return;
}
}
}
fn unlock(&self) {
let mut locked = self.locked.exclusive_access();
*locked = false;
}
}
pub struct MutexBlocking {
inner: UPSafeCell<MutexBlockingInner>,
}
pub struct MutexBlockingInner {
locked: bool,
wait_queue: VecDeque<Arc<TaskControlBlock>>,
}
impl MutexBlocking {
pub fn new() -> Self {
Self {
inner: unsafe {
UPSafeCell::new(MutexBlockingInner {
locked: false,
wait_queue: VecDeque::new(),
})
},
}
}
}
impl Mutex for MutexBlocking {
fn lock(&self) {
let mut mutex_inner = self.inner.exclusive_access();
if mutex_inner.locked {
mutex_inner.wait_queue.push_back(current_task().unwrap());
drop(mutex_inner);
block_current_and_run_next();
} else {
mutex_inner.locked = true;
}
}
fn unlock(&self) {
let mut mutex_inner = self.inner.exclusive_access();
assert!(mutex_inner.locked);
if let Some(waking_task) = mutex_inner.wait_queue.pop_front() {
add_task(waking_task);
} else {
mutex_inner.locked = false;
}
}
}
- 第 8 - 11 行:定义了
Mutex trait
,该trait
有两个方法分别是加锁和去锁。 - 第 46 - 48 行:
MutexBlocking
是会实现Mutex trait
的内核数据结构,它就是我们提到的 互斥资源 即 互斥锁 。 - 第 50 - 54 行:互斥锁的成员变量有两个分别表示是否锁上的
locked
和管理等待线程的等待队列wait_queue
。 - 第 68 - 89 行:为
MutexBlocking
实现了Mutex trait
,包含lock
和unlock
。- 第 69 - 78 行:实现了
lock
方法。- 第 70 行:获取
inner
的权限。 - 第 71 行:如果互斥锁
mutex
已经被其他线程获取了。 - 第 72 行:将把当前线程放入等待队列中。
- 第 74 行:并让当前线程处于等待状态,并调度其他线程执行。
- 第 76 行:如果互斥锁
mutex
还没被获取,那么当前线程会获取给互斥锁,并返回系统调用。
- 第 70 行:获取
- 第 80 - 89 行:实现了
unlock
方法。- 第 81 行:获取
inner
的权限。 - 第 83 - 85 行:如果有等待的线程,唤醒等待最久的那个线程。
- 第 86 行:如果没有等待的线程则释放锁。
- 第 81 行:获取
- 第 69 - 78 行:实现了
// os/src/syscall/sync.rs
// 下面是 SYSCALL_MUTEX_CREATE 系统调用创建互斥锁的函数
pub fn sys_mutex_create(blocking: bool) -> isize {
let process = current_process();
let mutex: Option<Arc<dyn Mutex>> = if !blocking {
Some(Arc::new(MutexSpin::new()))
} else {
Some(Arc::new(MutexBlocking::new()))
};
let mut process_inner = process.inner_exclusive_access();
if let Some(id) = process_inner
.mutex_list
.iter()
.enumerate()
.find(|(_, item)| item.is_none())
.map(|(id, _)| id)
{
process_inner.mutex_list[id] = mutex;
id as isize
} else {
process_inner.mutex_list.push(mutex);
process_inner.mutex_list.len() as isize - 1
}
}
// SYSCALL_MUTEX_LOCK 系统调用的 sys_mutex_lock
pub fn sys_mutex_lock(mutex_id: usize) -> isize {
let process = current_process();
let process_inner = process.inner_exclusive_access();
let mutex = Arc::clone(process_inner.mutex_list[mutex_id].as_ref().unwrap());
drop(process_inner);
drop(process);
mutex.lock();
0
}
// SYSCALL_MUTEX_UNLOCK 系统调用的 sys_mutex_unlock
pub fn sys_mutex_unlock(mutex_id: usize) -> isize {
let process = current_process();
let process_inner = process.inner_exclusive_access();
let mutex = Arc::clone(process_inner.mutex_list[mutex_id].as_ref().unwrap());
drop(process_inner);
drop(process);
mutex.unlock();
0
}
- 第 27 - 35 行:为
sys_mutex_lock
系统调用的实现。其主要工作是,在锁已被其他线程获取的情况下,把当前线程放到等待队列中,并调度一个新线程执行。- 第 33 行,调用
ID
为mutex_id
的互斥锁mutex
的lock
方法,具体工作由lock
方法来完成的。
- 第 33 行,调用
- 第 38 - 46 行:为
sys_mutex_unlock
系统调用的实现。其主要工作是,如果有等待在这个互斥锁上的线程,需要唤醒最早等待的线程。- 第8行,调用ID为mutex_id的互斥锁mutex的unlock方法,具体工作由unlock方法来完成的。
- 第16行,释放锁。
- 第17-18行,如果有等待的线程,唤醒等待最久的那个线程。
内核态锁:通过对线程的管理达到防止资源访问冲突的情况,创建等待队列,将等待同一个锁的线程都睡眠,一个用完唤醒下一个,这种方法也达到了最初的目的。
锁机制小结:目前的锁机制有三种方法可以实现,分别对应用户态,内核态,机器指令级别。用户态的锁实现是通过管理两个临时变量达到防止两个线程访问同一个临界区的目的。内核态是通过对线程的调度层面,创建等待队列,让线程有序的一个个进行临界区的访问。机械指令则是通过原子操作,使得线程访问共享变量的时候不会被其他的线程打断达到防止两个线程访问同一个临界区的目的。虽然各个层面实现的方法不一样,但其最终实现的效果都达到了临界区访问的不冲突。
【信号量机制】
信号量的实现需要互斥锁和处理器原子指令的支持,它是一种更高级的同步互斥机制。
【信号量的起源和基本思路】
信号(Semphore
)是一种变量或抽象数据类型,用于控制多个线程对共同资源的访问。能够让这些松耦合的顺序代码执行流能进行同步操作并能对共享资源进行互斥访问。
Dijkstra
对信号量设立两种操作:P
(Proberen
(荷兰语),尝试)操作和V
(Verhogen
(荷兰语),增加)操作。
- P操作是检查信号量的值是否大于
0
,若该值大于0
,则将其值减1
并继续(表示可以进入临界区了);若该值为0
,则线程将睡眠。注意,此时P
操作还未结束。而且由于信号量本身是一种临界资源(可回想一下上一节的锁,其实也是一种临界资源),所以在P
操作中,检查/修改信号量值以及可能发生的睡眠这一系列操作是一个不可分割的原子操作过程。通过原子操作才能保证一旦P
操作开始,则在该操作完成或阻塞睡眠之前,其他线程均不允许访问该信号量。(lock
) - V操作会对信号量的值加
1
,然后检查是否有一个或多个线程在该信号量上睡眠等待。如有,则选择其中的一个线程唤醒并允许该线程继续完成它的P
操作;如没有,则直接返回。注意,信号量的值加1
,并可能唤醒一个线程的一系列操作同样也是不可分割的原子操作过程。不会有某个进程因执行v
操作而阻塞。(unlock
)
互斥锁的初始值一般设置为 1
的整型变量, 表示临界区还没有被某个线程占用。互斥锁用 0
表示临界区已经被占用了,用 1
表示临界区为空。再通过 lock/unlock
操作来协调多个线程轮流独占临界区执行。
信号量的初始值可设置为 N
的整数变量,如果 N
大于 0
,表示最多可以有N
个线程进入临界区执行,如果 N
小于等于 0
,表示不能有线程进入临界区了,必须在后续操作中让信号量的值加 1
,才能唤醒某个等待的线程。
- 如果信号量是一个任意的整数,通常被称为计数信号量(
Counting Semaphore
),或一般信号量(General Semaphore
)。 - 如果信号量只有
0
或1
的取值,则称为二值信号量(Binary Semaphore
)。
互斥锁只是信号量的一种特例 - 二值信号量,信号量很好地解决了最多只允许N个线程访问临界资源的情况。
信号量实现与运用的伪代码:
fn P(S) {
if S >= 1
S = S - 1;
else
<block and enqueue the thread>;
}
fn V(S) {
if <some threads are blocked on the queue>
<unblock a thread>;
else
S = S + 1;
}
let static mut S: semaphore = 1;
// Thread i
fn foo() {
...
P(S);
execute Cricital Section;
V(S);
...
}
信号量的另一种用途是用于实现同步(synchronization
):比如,把信号量的初始值设置为 0
,当一个线程A
对此信号量执行一个P
操作,那么该线程立即会被阻塞睡眠。之后有另外一个线程B
对此信号量执行一个V
操作,就会将线程A
唤醒。这样线程B
中执行V
操作之前的代码序列B-stmts
和线程A
中执行P
操作之后的代码A-stmts
序列之间就形成了一种确定的同步执行关系,即线程B
的B-stmts
会先执行,然后才是线程A
的A-stmts
开始执行。
let static mut S: semaphore = 0;
//Thread A
...
P(S);
Label_2:
A-stmts after Thread B::Label_1;
...
//Thread B
...
B-stmts before Thread A::Label_2;
Label_1:
V(S);
...
【实现信号量】
user/src/bin/sync_sem.rs
为应用层信号量实现的一个例子
// user/src/bin/sync_sem.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
extern crate alloc;
use alloc::vec;
use user_lib::exit;
use user_lib::{semaphore_create, semaphore_down, semaphore_up};
use user_lib::{sleep_blocking, thread_create, waittid};
const SEM_SYNC: usize = 0;//信号量ID
unsafe fn first() -> ! {
sleep_blocking(10);
println!("First work and wakeup Second");
semaphore_up(SEM_SYNC);//信号量V操作
exit(0)
}
unsafe fn second() -> ! {
println!("Second want to continue,but need to wait first");
semaphore_down(SEM_SYNC);//信号量P操作
println!("Second can work now");
exit(0)
}
#[no_mangle]
pub fn main() -> i32 {
// create semaphores
assert_eq!(semaphore_create(0) as usize, SEM_SYNC);
// create threads
let threads = vec![
thread_create(first as usize, 0),
thread_create(second as usize, 0),
];
// wait for all threads to complete
for thread in threads.iter() {
waittid(*thread as usize);
}
println!("sync_sem passed!");
0
}
- 第 17 - 22 行:定义了第一个线程的处理函数。
- 第 20 行:线程
First
执行信号量V
操作, 会唤醒等待该信号量的线程Second
。
- 第 20 行:线程
- 第 24 - 29 行:定义了第二个线程的处理函数。
- 第 26 行:线程
Second
执行信号量P
操作,由于信号量初值为0
,该线程将阻塞。
- 第 26 行:线程
- 第 32 - 46 行:定义了
main
函数。- 第 34 行:创建了一个初值为 0 ,ID 为
SEM_SYNC
的信号量。 - 第 36 - 39 行:创建了两个线程并加入到队列中去,其线程对应的函数分别为
first
和second
。 - 第 41 - 43 行:等待两个线程的回收。
- 第 34 行:创建了一个初值为 0 ,ID 为
对于支持应用层的系统调用接口:
pub fn sys_semaphore_create(res_count: usize) -> isize {
syscall(SYSCALL_SEMAPHORE_CREATE, [res_count, 0, 0])
}
pub fn sys_semaphore_up(sem_id: usize) -> isize {
syscall(SYSCALL_SEMAPHORE_UP, [sem_id, 0, 0])
}
pub fn sys_semaphore_down(sem_id: usize) -> isize {
syscall(SYSCALL_SEMAPHORE_DOWN, [sem_id, 0, 0])
}
操作系统实现 semaphore
系统调用:还是采用通常的分析做法,数据结构+方法, 即首先考虑一下与此相关的核心数据结构,然后考虑与数据结构相关的相关函数/方法的实现。
os/src/sync/semaphore.rs
定义了与信号量相关的数据结构和方法。
// os/src/sync/semaphore.rs
use crate::sync::UPSafeCell;
use crate::task::{add_task, block_current_and_run_next, current_task, TaskControlBlock};
use alloc::{collections::VecDeque, sync::Arc};
pub struct Semaphore {
pub inner: UPSafeCell<SemaphoreInner>,
}
pub struct SemaphoreInner {
pub count: isize,
pub wait_queue: VecDeque<Arc<TaskControlBlock>>,
}
impl Semaphore {
pub fn new(res_count: usize) -> Self {
Self {
inner: unsafe {
UPSafeCell::new(SemaphoreInner {
count: res_count as isize,
wait_queue: VecDeque::new(),
})
},
}
}
pub fn up(&self) {
let mut inner = self.inner.exclusive_access();
inner.count += 1;
if inner.count <= 0 {
if let Some(task) = inner.wait_queue.pop_front() {
add_task(task);
}
}
}
pub fn down(&self) {
let mut inner = self.inner.exclusive_access();
inner.count -= 1;
if inner.count < 0 {
inner.wait_queue.push_back(current_task().unwrap());
drop(inner);
block_current_and_run_next();
}
}
}
- 第 6 - 8 行:
Semaphore
是信号量的内核数据结构包含了SemaphoreInner
- 第 10 - 13 行:
SemaphoreInner
由信号量值和等待队列组成。 - 第 15 - 46 行:为
Semaphore
实现了三个成员函数,分别代表创建信号量、P
操作、V
操作。- 第 16 - 25 行:为创建信号量方法,信号量初值为传入的参数
res_count
。 - 第 27 - 35 行:实现
V
操作的up
函数。- 第 28 行:获取其内部
inner
的权限。 - 第 29 行:将信号量值加一,表明一个线程使用完了,可以使用的加一。
- 第 30 - 34 行:如果当信号量值小于等于
0
时, 将从信号量的等待队列中弹出一个线程放入线程就绪队列。
- 第 28 行:获取其内部
- 第 37 - 46 行:实现
P
操作的down
函数。- 第 38 行:获取其内部
inner
的权限。 - 第 39 行:将信号量值减一,表明一个线程准备使用共享资源,可以使用的减一。
- 第 40 - 44 行:当信号量值小于
0
时, 将把当前线程放入信号量的等待队列,设置当前线程为挂起状态并选择新线程执行。
- 第 38 行:获取其内部
- 第 16 - 25 行:为创建信号量方法,信号量初值为传入的参数
信号量机制小结:信号量机制包含锁机制,信号量机制与锁机制的不同在于刚开始的初值设置不为1:当为0的时候可以起到线程之间的同步效果,当为1的时候就是锁机制,当为N(N>0 && N!=1)的时候就是信号量机制了。信号量机制的内部实现与内核态操作系统的锁机制相似,通过
PV
原语操作维护信号量值可以使得自定的有限个线程使用临界区。
【条件变量机制】
【条件变量的基本思路】
一个管程是一个由过程(procedures,Pascal语言的术语,即函数)、共享变量及数据结构等组成的一个集合。线程可以调用管程中的函数,但线程不能在管程之外声明的函数中直接访问管程内的数据结构(线程可以调用函数,但只能在调用函数的过程中访问其内部的数据结构)。
monitor m1
integer i; //共享变量
condition c; //条件变量
procedure f1();
... //对共享变量的访问,以及通过条件变量进行线程间的通知
end;
procedure f2();
... //对共享变量的访问,以及通过条件变量进行线程间的通知
end;
end monitor
管程有一个很重要的特性,即任一时刻只能有一个活跃线程调用管程中过程,这一特性使线程在调用执行管程中过程时能保证互斥,这样线程就可以放心地访问共享变量。(编译器知道管程特殊性,编译器可以在管程中的每个过程的入口/出口处加上互斥锁的加锁/释放锁的操作。因为是由编译器而非程序员来生成互斥锁相关的代码,所以出错的可能性要小。)
- 等待机制:由于线程在调用管程中某个过程时,发现某个条件不满足,那就在无法继续运行而被阻塞。在阻塞之前,操作系统需要把进入管程的过程入口出的互斥锁给释放掉,这样才能让其他线程有机会调用管程的过程。
- 唤醒机制:另外一个线程可以在调用管程的过程中,把某个条件设置为真,并且还需要有一种机制及时唤醒等待条件为真的阻塞线程。唤醒线程(本身执行位置在管程的过程中)如果把阻塞线程(其执行位置还在管程的过程中)唤醒了,那么需要避免两个活跃的线程都在管程中导致互斥被破坏的情况。为了避免管程中同时有两个活跃线程,我们需要一定的规则来约定线程发出唤醒操作的行为。目前有三种典型的规则方案:
- Hoare语义:线程发出唤醒操作后,马上阻塞自己,让新被唤醒的线程运行,此时唤醒线程的执行位置还在管程中。
- Hansen语义:执行唤醒操作的线程必须立即退出管程,即唤醒操作只可能作为一个管程过程的最后一条语句,此时唤醒线程的执行位置离开了管程。
- Mesa语义:唤醒线程在发出行唤醒操作后继续运行,并且只有它退出管程之后,才允许等待的线程开始运行,此时唤醒线程的执行位置还在管程中。
这种沟通机制的具体实现就是 条件变量 和对应的操作:wait
和 signal
。
- 线程使用条件变量来等待一个条件变成真。条件变量其实是一个线程等待队列,当条件不满足时,线程通过执行条件变量的
wait
操作就可以把自己加入到等待队列中,睡眠等待(waiting
)该条件。 - 当某个线程改变条件为真后,就可以通过条件变量的
signal
操作来唤醒一个或者多个等待的线程(通过在该条件上发信号),让它们继续执行。
fn wait(mutex) {
mutex.unlock();
<block and enqueue the thread>;
mutex.lock();
}
fn signal() {
<unblock a thread>;
}
wait
操作包含三步:1. 释放锁;2. 把自己挂起;3. 被唤醒后,再获取锁。signal
操作只包含一步:找到挂在条件变量上睡眠的线程,把它唤醒。
【实现条件变量】
首先还是过例子来看看如何在用户层实际使用条件变量,user/src/bin/test_condvar.rs
为用户层的应用示例。
// user/src/bin/test_condvar.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
extern crate alloc;
use alloc::vec;
use user_lib::exit;
use user_lib::{
condvar_create, condvar_signal, condvar_wait, mutex_blocking_create, mutex_lock, mutex_unlock,
};
use user_lib::{sleep_blocking, thread_create, waittid};
static mut A: usize = 0;
const CONDVAR_ID: usize = 0;
const MUTEX_ID: usize = 0;
unsafe fn first() -> ! {
sleep_blocking(10);
println!("First work, Change A --> 1 and wakeup Second");
mutex_lock(MUTEX_ID);
A = 1;
condvar_signal(CONDVAR_ID);
mutex_unlock(MUTEX_ID);
exit(0)
}
unsafe fn second() -> ! {
println!("Second want to continue,but need to wait A=1");
mutex_lock(MUTEX_ID);
while A == 0 {
println!("Second: A is {}", A);
condvar_wait(CONDVAR_ID, MUTEX_ID);
}
mutex_unlock(MUTEX_ID);
println!("A is {}, Second can work now", A);
exit(0)
}
#[no_mangle]
pub fn main() -> i32 {
// create condvar & mutex
assert_eq!(condvar_create() as usize, CONDVAR_ID);
assert_eq!(mutex_blocking_create() as usize, MUTEX_ID);
// create threads
let threads = vec![
thread_create(first as usize, 0),
thread_create(second as usize, 0),
];
// wait for all threads to complete
for thread in threads.iter() {
waittid(*thread as usize);
}
println!("test_condvar passed!");
0
}
- 第 22 - 30 行:定义了
first
函数,为第一个线程的执行函数。- 第 23 行:首先会先睡眠
10ms
。 - 第 25 行:首先加锁。
- 第 26 行:设置
A
为1
,让线程second
等待的条件满足。 - 第 27 行:执行条件变量的
signal
操作, 从而能够唤醒线程Second
。
- 第 23 行:首先会先睡眠
- 第 32 - 42 行:定义了
second
函数,为第二个线程的执行函数。- 第 34 行:首先加锁。
- 第 35 - 38 行:判断条件是否满足,刚开始会由于条件不满足执行条件变量的
wait
操作而等待睡眠。
- 第 47 行:创建了一个
ID
为CONDVAR_ID
的条件变量。 - 第 48 行:创建了一个
ID
为MUTEX_ID
的互斥锁。 - 第 50 - 53 行:创建两个线程,分别对应到
first
和second
两个函数。 - 第 55 - 57 行:等待两个线程的执行结束。
其上所对应的系统调用接口如下所示,在user/src/syscall.rs
中:
/// user/src/syscall.rs
pub fn sys_condvar_create(_arg: usize) -> isize {
syscall(SYSCALL_CONDVAR_CREATE, [_arg, 0, 0])
}
pub fn sys_condvar_signal(condvar_id: usize) -> isize {
syscall(SYSCALL_CONDVAR_SIGNAL, [condvar_id, 0, 0])
}
pub fn sys_condvar_wait(condvar_id: usize, mutex_id: usize) -> isize {
syscall(SYSCALL_CONDVAR_WAIT, [condvar_id, mutex_id, 0])
}
我们还是采用通常的分析做法:数据结构+方法,即首先考虑一下与此相关的核心数据结构,然后考虑与数据结构相关的相关函数/方法的实现,与条件变量相关的数据结构在os/src/sync/condvar.rs
中:
// os/src/sync/condvar.rs
use crate::sync::{Mutex, UPSafeCell};
use crate::task::{add_task, block_current_and_run_next, current_task, TaskControlBlock};
use alloc::{collections::VecDeque, sync::Arc};
pub struct Condvar {
pub inner: UPSafeCell<CondvarInner>,
}
pub struct CondvarInner {
pub wait_queue: VecDeque<Arc<TaskControlBlock>>,
}
impl Condvar {
pub fn new() -> Self {
Self {
inner: unsafe {
UPSafeCell::new(CondvarInner {
wait_queue: VecDeque::new(),
})
},
}
}
pub fn signal(&self) {
let mut inner = self.inner.exclusive_access();
if let Some(task) = inner.wait_queue.pop_front() {
add_task(task);
}
}
pub fn wait(&self, mutex: Arc<dyn Mutex>) {
mutex.unlock();
let mut inner = self.inner.exclusive_access();
inner.wait_queue.push_back(current_task().unwrap());
drop(inner);
block_current_and_run_next();
mutex.lock();
}
}
- 第 6 - 8 行:定义了
Condvar
是条件变量的内核数据结构。 - 第 10 - 12 行:定义了
CondvarInner
为Condvar
的内部数据结构,由等待队列组成。 - 第 14 - 40 行:实现了
Condvar
方法,包含new
、signal
、wait
。- 第 15 - 23 行:
new
方法创建条件变量,即创建了一个空的等待队列。 - 第 25 - 30 行:实现
signal
操作,将从条件变量的等待队列中弹出一个线程放入线程就绪队列。 - 第 32 - 39 行:实现
wait
操作,释放mutex
互斥锁,将把当前线程放入条件变量的等待队列,设置当前线程为挂起状态并选择新线程执行。在恢复执行后,再加上mutex
互斥锁。
- 第 15 - 23 行:
条件变量机制与前两种机制相似,但是通过管城的协助可以使得加锁去锁的操作更加的安全,其具体实现类似于前面的信号量与锁机制,将原来的信号量值变成了条件变量的情况。
ThreadControl & SynchronousMutexOS 小结:
刚开始从线程的创建开始,到共享内存的安全访问问题,由此引出了三种解决机制,分别对应为锁机制、信号量机制、条件变量机制。
线程的存在替代了原本最小的调度单位进程,同时也改变了进程原本的价值变成了资源管理单元。
通过锁机制、信号量机制、条件变量机制可以使得线程之间对于临界区的访问达到一种安全的效果,防止你所取得数据因为线程之间的掉的等情况并不是预期的结果。使得对临界区的访问有序进行。
到此也是本次
rCore-Tutorial
的结尾,通过这么多的OS
迭代,让原本扑朔迷离的操作系统内部渐渐变得清晰与明朗起来,总体来讲是次非常nice
的旅行,纯粹的兴趣指引使得收获良多哈嘻嘻 😃