定义内核态中任务的数据结构,基于共享调度器对任务进行调度,在内核执行器中对任务进行执行。
前一章共享调度器设计与实现中讲到,共享调度器中共享的是任务的指针,而不是任务的本身。 因此在不同场景中我们可以自由定义任务的内容,这样的设计可以带来一些兼容性上的好处。
下面是飓风内核中表示内核任务的结构体:
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
。