Skip to content
This repository has been archived by the owner on Feb 4, 2022. It is now read-only.

Latest commit

 

History

History
109 lines (92 loc) · 6.25 KB

第三章-内核任务与内核执行器.md

File metadata and controls

109 lines (92 loc) · 6.25 KB

内核任务与内核执行器

定义内核态中任务的数据结构,基于共享调度器对任务进行调度,在内核执行器中对任务进行执行。

内核任务

前一章共享调度器设计与实现中讲到,共享调度器中共享的是任务的指针,而不是任务的本身。 因此在不同场景中我们可以自由定义任务的内容,这样的设计可以带来一些兼容性上的好处。

下面是飓风内核中表示内核任务的结构体:

struct KernelTask {
    /// 任务的编号
    pub id: TaskId,
    /// 任务所属的进程
    pub process: Arc<Process>,
    /// 任务信息的可变部分
    pub inner: Mutex<TaskInner>,
    /// 任务的内容
    pub future: Mutex<Pin<Box<dyn Future<Output = ()> + 'static + Send + Sync>>>, // 用UnsafeCell代替Mutex会好一点
}

这里我们只暂时关注KernelTask中的future位(事实上该结构体的设计还没稳定,一些成员是为了预留将来的设计),它的类型可以简单理解为将Future trait对象用Mutex<Pin<Box<T>>>包起来的一个类型,使用Box<T>是因为我们想要把trait对象的数据放在堆上,使用Pin<T>是因为我们需要把这块结构的内存“钉住”,使用Pin<T>结构的详细原因可以参考Rust异步编程。使用Mutex<T>是因为我们需要保证并发场景下的数据一致性和提供内部可变性。事实上如果不使用Mutex<T>这部分代码在后面接入异步运行时的时候将无法编译。

可以使用以下的方法将Future打包成KernelTask:

impl KernelTask {
    // 这里忽略了一些成员的初始化
    pub fn new(
        future: impl Future<Output = ()> + 'static + Send + Sync
    ) -> KernelTask {
        future: Mutex::new(Box::pin(future))
    }
}

然后是转成任务指针,该任务指针可以被放进共享调度器中进行调度:

impl KernelTask {
    // 这里需要获取[`Arc<KernelTask>`]的所有权
    pub unsafe fn task_repr(self: Arc<Self>) -> usize {
        // note(unsafe): 这里消耗了一个`Arc`的所有权,返回一个裸指针,
        // 但放在堆上的数据没有释放。
        //
        // 该裸指针需要重新转换回[`Arc<KernelTask>`],避免内存泄漏
        Arc::into_raw(self) as usize
    }
}

将任务指针重新实例化为任务:

pub unsafe fn convert_back(task_repr: usize) -> Arc<KernelTask> {
    // 重新实例化为任务的`Arc`指针,避免了内存泄漏,同时重新获得所有权
    Arc::from_raw(task_repr as *mut _)
}

ps: 很多朋友和我们谈论Rust语言的时候会问:“Rust里面是不是可以写unsafe代码?这样不就是和C/C++差不多了吗?”。其实从上面的例子可以看到,在Rust里面使用unsafe代码并不代表完全脱离所有权系统,编写者需要十分清楚Rust里面的内存管理模型才能写出正确执行的unsafe代码。

内核执行器

到这里可能很多朋友有疑问:执行器是什么?内核执行器又是什么?

在Rust异步编程模型中,执行器指的是任务的具体执行者,而执行操作通常可以理解为对future进行poll操作。同时执行器会根据poll操作的返回值(Ready/Pending)进行处理。

在共享调度器的语境中,在不同的地址空间需要分别有独立的执行器,内核空间作为特殊的0地址空间,其中的执行器就是内核执行器。内核执行器调用共享调度器的接口来实现执行任务的逻辑。

飓风内核的执行器目前的设计是:在一个大循环中,不断从共享调度器中拿到任务,对任务进行poll操作,如果返回Ready,从共享调度器中删除该任务,如果返回Pending,将该任务设置为睡眠状态。如果共享调度器中所有任务都已完成,则退出系统。

大致可以使用下面的伪代码呈现:

pub fn run_until_idle() {
    loop {
        let task = peek_task(); // 从共享调度器中拿出下一个任务的指针,不弹出
        match task {
            TaskResult::Task(task_repr: usize) => { // 任务指针
                set_task_state(task_repr, TaskState::Sleeping); // 设置任务的状态为睡眠
                let task: Arc<KernelTask> = unsafe { Arc::from_raw(task_repr as *mut _) };
                // 注册 waker
                let waker = waker_ref(&task);
                let mut context = Context::from_waker(&*waker);
                let ret = task.future.lock().as_mut().poll(&mut context);
                if let Poll::Pending = ret {
                    core::mem::forget(task); // 不要释放task的内存,它将继续保存在内存中被使用
                } else {
                    // 否则,从共享调度器中删除此任务
                    delete_task(task_repr);
                } // 隐含一个drop(task)
            },
            TaskResult::ShouldYield(next_asid: usize) => { // 不同地址空间的任务,需要切换地址空间
                // 这里先忽略
                todo!()
            },
            TaskResult::NoWakeTask => {
                // do nothing
            },
            TaskResult::Finished => break
        }
    }
}

唤醒任务

在Rust异步编程中比较令人费解的一个概念是唤醒,也就是一个任务被poll之后如果返回Pending,它会被设置为睡眠状态,在这种状态下其不会被重新执行poll操作。 只有该任务被唤醒,转为就绪状态之后才可以被再次poll,那么在什么时候唤醒该任务呢?

唤醒机制需要根据具体场景而定,这里假设一个读异步块设备场景:当系统向块设备发起读请求的时候,数据可能还没准备好,这时候执行poll操作会返回Pending,该任务也就被设置成了睡眠状态。当数据准备好的时候,块设备会向CPU发起一个中断,在中断处理函数里面我们会唤醒该任务,于是该任务重新回到就绪状态。当该任务下一次被poll的时候,数据已经准备好,因此返回Ready