线程的切换

优质
小牛编辑
128浏览
2023-12-01

线程的切换

回答一下前一节的思考题:当发生中断时,在 __restore 时,a0 寄存器的值是 handle_interrupt 函数的返回值。也就是说,如果我们令 handle_interrupt 函数返回另一个线程的 *mut Context,就可以在时钟中断后跳转到这个线程来执行。

修改中断处理

在线程切换时(即时钟中断时),handle_interrupt 函数需要将上一个线程的 Context 保存起来,然后将下一个线程的 Context 恢复并返回。

注 1:为什么不直接 in-place 修改 Context 呢?这是因为 handle_interrupt 函数返回的 Context 指针除了存储上下文以外,还提供了内核栈的地址。这个会在后面详细阐述。

注 2:在 Rust 中,引用 &mut 和指针 *mut 只是编译器的理解不同,其本质都是一个存储对象地址的寄存器。这里返回值使用指针而不是引用,是因为其指向的位置十分特殊,其生命周期在这里没有意义。

os/src/interrupt/handler.rs

/// 中断的处理入口
#[no_mangle]
pub fn handle_interrupt(context: &mut Context, scause: Scause, stval: usize) -> *mut Context {
    /* ... */
}

/// 处理 ebreak 断点
fn breakpoint(context: &mut Context) -> *mut Context {
    println!("Breakpoint at 0x{:x}", context.sepc);
    context.sepc += 2;
    context
}

/// 处理时钟中断
fn supervisor_timer(context: &mut Context) -> *mut Context {
    timer::tick();
    PROCESSOR.lock().park_current_thread(context);
    PROCESSOR.lock().prepare_next_thread()
}

可以看到,当发生断点中断时,直接返回原来的上下文(修改一下 sepc);而如果是时钟中断的时候,我们执行了两个函数得到的返回值作为上下文,那它又是怎么工作的呢?

线程切换

让我们看一下 Processor 中的这两个方法是如何实现的。

(调度器 scheduler 会在后面的小节中讲解,我们只需要知道它能够返回下一个等待执行的线程。)

os/src/process/processor.rs: impl Processor

/// 保存当前线程的 `Context`
pub fn park_current_thread(&mut self, context: &Context) {
    self.current_thread().park(*context);
}

/// 在一个时钟中断时,替换掉 context
pub fn prepare_next_thread(&mut self) -> *mut Context {
    // 向调度器询问下一个线程
    if let Some(next_thread) = self.scheduler.get_next() {
        // 准备下一个线程
        let context = next_thread.prepare();
        self.current_thread = Some(next_thread);
        context
    } else {
        // 没有活跃线程
        if self.sleeping_threads.is_empty() {
            // 也没有休眠线程,则退出
            panic!("all threads terminated, shutting down");
        } else {
            // 有休眠线程,则等待中断
            /* ... */
        }
    }
}

上下文 Context 的保存和取出

在线程切换时,我们需要保存前一个线程的 Context,为此我们实现 Thread::park 函数。

os/src/process/thread.rs: impl Thread

/// 发生时钟中断后暂停线程,保存状态
pub fn park(&self, context: Context) {
    // 检查目前线程内的 context 应当为 None
    assert!(self.inner().context.is_none());
    // 将 Context 保存到线程中
    self.inner().context.replace(context);
}

然后,我们需要取出下一个线程的 Context,为此我们实现 Thread::prepare。不过这次需要注意的是,启动一个线程除了需要 Context,还需要切换页表。这个操作我们也在这个方法中完成。

os/src/process/thread.rs: impl Thread

/// 准备执行一个线程
///
/// 激活对应进程的页表,并返回其 Context
pub fn prepare(&self) -> *mut Context {
    // 激活页表
    self.process.inner().memory_set.activate();
    // 取出 Context
    let parked_frame = self.inner().context.take().unwrap();
    // 将 Context 放至内核栈顶
    unsafe { KERNEL_STACK.push_context(parked_frame) }
}

思考:在 run 函数中,我们在一开始就激活了页表,会不会导致后续流程无法正常执行?

内核栈?

现在,线程保存 Context 都是根据 sp 指针,在栈上压入一个 Context 来存储。但是,对于一个用户线程而言,它在用户态运行时用的是位于用户空间的用户栈。而它在用户态运行中如果触发中断,sp 指针指向的是用户空间的某地址,但此时 RISC-V CPU 会切换到内核态继续执行,就不能再用这个 sp 指针指向的用户空间地址了。这样,我们需要为 sp 指针准备好一个专门用于在内核态执行函数的内核栈。所以,为了不让一个线程的崩溃导致操作系统的崩溃,我们需要提前准备好内核栈,当线程发生中断时可用来存储线程的 Context。在下一节我们将具体讲解该如何做。

小结

为了实现线程的切换,我们让 handle_interrupt 返回一个 *mut Context。如果需要切换线程,就将前一个线程的 Context 保存起来换上新的线程的 Context。而如果不需要切换,那么直接返回原本的 Context 即可。