0%

第一阶段总结

第一阶段主要考察 Rust 基础编程和数据结构与算法,由于我有一定 Rust 基础,所以前 100 道题比较轻松,但从未使用 Rust 刷过算法题,因此后 10 道题让我学习了如何使用 Rust 编写常见数据结构如链表、栈、堆、图等,以及常见的排序、搜索算法等,有所收获!

第二阶段总结

第二阶段我的学习方法是先看完 v3 book 对应章节,然后再做实验题。v3 book 写得循序渐进,质量上乘,读懂后做实验题都比较轻松。第二阶段的精华都在 v3 book 中,十分建议精读一遍,我自己精读一遍后发现了若干内容和文字错误,还提交了 19 个 pr 修复。这期间我还写了篇博客分析有栈协程示例代码。

五个实验题中最后的死锁检测算法花费的时间要久一点,因为前面章节没有铺垫,直接就是抛出一个算法让实现,且算法只有过程描述,没有原理分析。这个算法似乎很少在生产上见到被实际使用(可能是我孤陋寡闻),我建议换成其他更有意义的实验,比如实现某一种同步互斥原语。

第三阶段总结

第三阶段主要是理解 ArceOS 组件化概念,如何划分组件以及如何将这些组件组装成不同的系统,如 Unikernel/宏内核/hypervisor 等。

在这个阶段,我完成了四个练习(print_with_color/support_hashmap/alt_alloc/simple_hv)和一个挑战任务:针对特定应用实现一个专门优化过的内存分配器。挑战任务并没有花太多时间,只是在 slab 分配器上做了些修改,所以得分不高,刚过及格线,排名第 14。

在这个阶段,我花费了许多时间来学习 RISC-V 的虚拟化扩展,本来是准备在第四阶段选择虚拟化项目的,但最后发现项目并没有采用 RISC-V 指令集(不想再花时间学习另一个指令集的特权架构,最终选择了基于协程异步机制的操作系统/驱动项目)。我研究了 arceos-hypervisor org 下的一些虚拟化项目和 KuangjuX/hypocaust-2 项目,arceos-hypervisor 项目因为需要做到组件复用,包括统一不同指令集架构,所以有很多抽象,学习成本有些高,而 KuangjuX/hypocaust-2 项目目前跑不起来,项目并没有指定 rust-toolchain,而且代码质量感觉不够高。最终我还是决定自己从零开始写一个纯 RISC-V 的一型 hypervisor,因为毕竟花了几个月时间参加训练营,还是想在最后自己能独立编写一个完整的实际项目。项目地址是 https://github.com/systemxlabs/riscv-hypervisor , 目前刚能将 rCore-Tutorial-v3 的第 5 章的内核作为 guest 跑起来,最终目标是能跑起 Linux。RISC-V 虚拟化扩展因为稳定不久,资料很少,所以写的过程中遇到许多困难,而且不好 debug,希望能坚持下去不弃坑~

第四阶段总结

第一周,阅读 200 行 rust 代码实现绿色线程、 200 行 rust 代码实现 futures 以及 blog_os 的 async/await 文章,输出了一篇博客,同时提了 pr 修复 rCore-Tutorial-v3 中 stackful_coroutine 示例代码,自己还将 stackful_coroutine 示例移植到 Linux for RISC-V 上(项目地址:https://github.com/systemxlabs/green-threads-in-200-lines-of-rust ),对原示例代码做了很多重构以便具有更好的可读性。调试自己的riscv hypervisor项目(第三周使用),目前可以运行 rCore-Tutorial-v3 第六章带文件系统的内核。

第二周,主要阅读 smol 生态 crates 源码,着重阅读了 polling / async-io / async-task / async-executor 库源码,理解最重要的 IO 多路复用 / reactor / driver / task / executor 等概念,输出了一篇博客

第三周,编写异步 os(项目地址:https://github.com/systemxlabs/async-os ),本来是想把之前写的 riscv-hypervisor 改成异步,但感觉没有意义,因为 os 上面可能有成千上万的线程,所以将 os 改成异步减少上下文切换是有意义的,但 hypervisor 跟 os 不同,hypervisor 上面没有那么多的 guests,且 hypervisor 为了高性能,通常都尽可能去避免 vm exit,所以我放弃将之前写的 riscv-hypervisor 改成异步,改为实现一个异步 os,参考 phoenix 和 rCore-Tutorial-v3,特点是全隔离内核、内核栈复用、trap return回用户态时无需保存内核执行流。目前已实现多核启动、device tree解析、物理页帧和堆分配器、页表和地址空间、内核和用户态trap handling、异步runtime。

总结

最后这一阶段,鄙人主要围绕rust的异步编程async/await及经典运行时库tokio展开阅读研究。但并没有深入理解其中原理及实现,更多的是比较浅显地了解,像tokio的有些模块并没有看,还又比较多的疑问在里面。如果后续有时间的话,希望能继续阅读并深入。代码实现上,仅在rcore上实现了一个极为简单的用户态运行时,跑了几个异步任务,有点协程的意思了(。但是并没有实现waker机制,后续考虑进一步实现完善

协程

有栈协程

函数运行在调用栈上,把函数作为一个协程,那么协程的上下文就是这个函数及其嵌套函数的栈帧存储的值,以及此时寄存器存储的值。如果我们调度协程,也就是保存当前正在运行的协程上下文,然后恢复下个将要运行的协程的上下文。这样我们就轻松的完成了协程调度。并且因为保存的上下文和普通函数执行的上下文是一样的,所以有栈协程可以在任意嵌套函数中挂起(无栈协程不行)。

有栈协程的优点在易用性上,通常只需要调用对应的方法,就可以切换上下文挂起协程。在有栈协程调度时,需要频繁的切换上下文,开销较大。单从实现上看,有栈协程更接近于内核级线程,都需要为每个线程保存单独的上下文(寄存器、栈等),区别在于有栈协程的调度由应用程序自行实现,对内核是透明的,而内核级线程的调度由系统内核完成,是抢占式的。

无栈协程

相比于有栈协程直接切换栈帧的思路,无栈协程在不改变函数调用栈的情况下,采用类似生成器的思路实现了上下文切换。通过编译器将生成器改写为对应的迭代器类型(内部实现是一个状态机)。

而无栈协程需要在编译器将代码编译为对应的状态机代码,挂起的位置在编译器确定。无栈协程的优点在性能上,不需要保存单独的上下文,内存占用低,切换成本低,性能高。缺点是需要编译器提供语义支持,无栈协程的实现是通过编译器对语法糖做支持,rust的aysnc\await就是语法糖,编译器将带有这些关键字的方法编译为生成器,以及对应的类型作为状态机。

只有状态机的支持才能进行协程调度,例如Rust中的tokio,基于Future的用户态线程,根据poll方法获取Future状态,它不可以在任意嵌套函数中挂起(同步代码未实现状态机)。

tokio

我主要从tokio::main宏出发,一步步分析其中调用关系。主要围绕runtime库的build及Runtime结构体的方法及函数

使用#[tokio::main]宏生成的代码

1
2
3
4
5
6
7
8
9
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
// ...
})
}

结构体Builder用作运行时runtime配置,通过new_current_thread()使用单线程运行时,new_multi_thread()使用多线程运行时,并会返回Builder实例

new_multi_thread

1
2
3
4
5
6
7
8
9
/// Returns a new builder with the multi thread scheduler selected.
///
/// Configuration methods can be chained on the return value.
#[cfg(feature = "rt-multi-thread")]
#[cfg_attr(docsrs, doc(cfg(feature = "rt-multi-thread")))]
pub fn new_multi_thread() -> Builder {
// The number `61` is fairly arbitrary. I believe this value was copied from golang.
Builder::new(Kind::MultiThread, 61)
}

调用new方法返回一个KindMultiThreadBuilder,对于定时器和I/O事件释放CPU之前需要61ticks

enable_all

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// Enables both I/O and time drivers.
///
/// Doing this is a shorthand for calling `enable_io` and `enable_time`
/// individually. If additional components are added to Tokio in the future,
/// `enable_all` will include these future components.
pub fn enable_all(&mut self) -> &mut Self {
#[cfg(any(
feature = "net",
all(unix, feature = "process"),
all(unix, feature = "signal")
))]
self.enable_io();
#[cfg(feature = "time")]
self.enable_time();

self
}

操作成员变量,使能运行时I/O和定时器

build

1
2
3
4
5
6
7
8
9
10
11
12
/// Creates the configured `Runtime`.
///
/// The returned `Runtime` instance is ready to spawn tasks.
pub fn build(&mut self) -> io::Result<Runtime> {
match &self.kind {
Kind::CurrentThread => self.build_current_thread_runtime(),
#[cfg(feature = "rt-multi-thread")]
Kind::MultiThread => self.build_threaded_runtime(),
#[cfg(all(tokio_unstable, feature = "rt-multi-thread"))]
Kind::MultiThreadAlt => self.build_alt_threaded_runtime(),
}
}

运行时创建的核心步骤,创建已经做配置的runtime,并返回Runtime实例准备创建异步任务

1
2
3
4
5
6
7
8
9
10
pub struct Runtime {
/// Task scheduler
scheduler: Scheduler,

/// Handle to runtime, also contains driver handles
handle: Handle,

/// Blocking pool handle, used to signal shutdown
blocking_pool: BlockingPool,
}

scheduler:异步任务调度器

handle:运行时句柄

blocking_pool:阻塞池句柄,用于发出关闭信号

build -> build_threaded_runtime

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
fn build_threaded_runtime(&mut self) -> io::Result<Runtime> {
use crate::loom::sys::num_cpus;
use crate::runtime::{Config, runtime::Scheduler};
use crate::runtime::scheduler::{self, MultiThread};

let core_threads = self.worker_threads.unwrap_or_else(num_cpus);

let (driver, driver_handle) = driver::Driver::new(self.get_cfg(core_threads))?;

// Create the blocking pool
let blocking_pool =
blocking::create_blocking_pool(self, self.max_blocking_threads + core_threads);
let blocking_spawner = blocking_pool.spawner().clone();

// Generate a rng seed for this runtime.
let seed_generator_1 = self.seed_generator.next_generator();
let seed_generator_2 = self.seed_generator.next_generator();

let (scheduler, handle, launch) = MultiThread::new(
core_threads,
driver,
driver_handle,
blocking_spawner,
seed_generator_2,
Config {
before_park: self.before_park.clone(),
after_unpark: self.after_unpark.clone(),
before_spawn: self.before_spawn.clone(),
after_termination: self.after_termination.clone(),
global_queue_interval: self.global_queue_interval,
event_interval: self.event_interval,
local_queue_capacity: self.local_queue_capacity,
#[cfg(tokio_unstable)]
unhandled_panic: self.unhandled_panic.clone(),
disable_lifo_slot: self.disable_lifo_slot,
seed_generator: seed_generator_1,
metrics_poll_count_histogram: self.metrics_poll_count_histogram_builder(),
},
);

let handle = Handle { inner: scheduler::Handle::MultiThread(handle) };

// Spawn the thread pool workers
let _enter = handle.enter();
launch.launch();

Ok(Runtime::from_parts(Scheduler::MultiThread(scheduler), handle, blocking_pool))
}
  1. 引入

num_cpus:用于获取系统的 CPU 核心数量。

Config:运行时的配置项。

MultiThread:多线程调度器,负责在多个线程间调度异步任务。

  1. 确定核心线程数
1
let core_threads = self.worker_threads.unwrap_or_else(num_cpus);
  • self.worker_threads:用户在Builder配置的核心线程数。如果未指定,使用系统的 CPU 核心数作为默认值(num_cpus())。
  • core_threads 是最终确定的核心线程数,表示运行时的调度线程数量。
  1. 初始化驱动
1
let (driver, driver_handle) = driver::Driver::new(self.get_cfg(core_threads))?;
  • driver:负责管理底层 IO 和定时器的核心组件。
  • driver_handle:运行时与驱动交互的句柄,用于调度异步任务和管理定时器。
  • get_cfg(core_threads):返回结构体driver::Cfg,包含驱动的配置项,包含workers等配置。
  1. 创建阻塞任务池
1
2
let blocking_pool = blocking::create_blocking_pool(self, self.max_blocking_threads + core_threads);
let blocking_spawner = blocking_pool.spawner().clone();
  • 阻塞任务池:用于执行需要阻塞运行的任务(例如文件 IO 或计算密集型任务),避免阻塞异步任务调度线程。

    • 线程数 = 用户指定的 max_blocking_threads + 核心线程数。
  • blocking_spawner:用于将任务提交到阻塞任务池的对象。

  1. 生成随机数种子
1
2
let seed_generator_1 = self.seed_generator.next_generator();
let seed_generator_2 = self.seed_generator.next_generator();
  • Tokio 的调度器可能需要随机化任务的分配,例如在多线程调度器中均衡负载。
  • seed_generator_1seed_generator_2 是生成的两个独立随机数种子,分别用于不同的组件。
  1. 初始化调度器
1
2
3
4
5
6
7
8
let (scheduler, handle, launch) = MultiThread::new(
core_threads,
driver,
driver_handle,
blocking_spawner,
seed_generator_2,
Config { ... },
);
  • 调用 MultiThread::new 创建一个多线程调度器,返回:

    • scheduler:核心调度器,管理线程间任务的分配和调度。
    • handle:调度器的句柄,用于外部与调度器交互。
    • launch:启动调度器工作线程的接口。
  • 传递的参数:

    • core_threads:核心线程数。

    • driverdriver_handle:用于与 IO 和定时器交互的驱动组件。

    • blocking_spawner:用于执行阻塞任务的接口。

    • seed_generator_2:随机数种子,用于内部调度逻辑。

    • Config:调度器的配置,包括以下内容:

      • before_parkafter_unpark:线程挂起与唤醒时的回调函数。
      • before_spawnafter_termination:任务生成和结束时的回调函数。
    • global_queue_intervalevent_interval:全局队列检查和事件处理的时间间隔。

      • local_queue_capacity:本地任务队列容量。
    • 其他运行时的定制项。

  1. 创建运行时句柄
1
let handle = Handle { inner: scheduler::Handle::MultiThread(handle) };
  • Handle 是对调度器的抽象封装,用于外部访问运行时和调度器的功能。
  1. 启动工作线程
1
2
let _enter = handle.enter();
launch.launch();
  • handle.enter():将当前线程设置为调度器的上下文,用于初始化调度环境。
  • launch.launch():启动调度器的所有工作线程,使其开始运行。
  1. 返回运行时
1
Ok(Runtime::from_parts(Scheduler::MultiThread(scheduler), handle, blocking_pool))
  • 创建并返回完整的Runtime实例,包括:
    • Scheduler::MultiThread(scheduler):多线程调度器。
    • handle:运行时句柄。
    • blocking_pool:阻塞任务池。

driver::Driver::new方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub(crate) fn new(cfg: Cfg) -> io::Result<(Self, Handle)> {
let (io_stack, io_handle, signal_handle) = create_io_stack(cfg.enable_io, cfg.nevents)?;

let clock = create_clock(cfg.enable_pause_time, cfg.start_paused);

let (time_driver, time_handle) =
create_time_driver(cfg.enable_time, io_stack, &clock, cfg.workers);

Ok((
Self { inner: time_driver },
Handle {
io: io_handle,
signal: signal_handle,
time: time_handle,
clock,
},
))
}
  1. 创建 IO 栈
1
let (io_stack, io_handle, signal_handle) = create_io_stack(cfg.enable_io, cfg.nevents)?;
  • 调用 create_io_stack 创建 IO 栈(底层 IO 相关的资源),返回:

    • io_stack:IO 栈的核心组件,用于管理底层 IO 操作。
    • io_handle:用于和 IO 栈交互的句柄。
    • signal_handle:用于处理信号的句柄(如处理 Ctrl+C)。
  • 参数解释:

    • cfg.enable_io:如果为 true,启用 IO 支持;否则,不创建 IO 栈。
    • cfg.nevents:指定事件队列的大小,用于多路复用器。
  1. 创建时钟
1
let clock = create_clock(cfg.enable_pause_time, cfg.start_paused);
  • 调用 create_clock 创建时钟组件,用于时间相关功能的管理,返回一个 Clock 对象。
  • 参数解释:
    • cfg.enable_pause_time:是否支持暂停时间。
    • cfg.start_paused:是否让时钟在启动时进入暂停状态。

时钟的主要用途:

  • 为定时器、延迟任务提供精确的时间点。
  • 支持测试或调试时暂停时间的功能。
  1. 创建时间驱动器
1
2
let (time_driver, time_handle) =
create_time_driver(cfg.enable_time, io_stack, &clock, cfg.workers);
  • 调用 create_time_driver

    创建时间驱动器,返回:

    • time_driver:管理时间相关任务的核心组件(例如定时器、延迟任务)。
    • time_handle:与时间驱动器交互的句柄。
  • 参数解释:

    • cfg.enable_time:是否启用时间功能。
    • io_stack:IO 栈,与时间驱动器共享底层的事件循环。
    • &clock:前面创建的时钟,提供时间相关支持。
    • cfg.workers:用于分配时间任务的工作线程数。

时间驱动器的主要功能:

  • 实现异步延迟(如 tokio::time::sleep)。
  • 管理基于时间的任务调度。
  1. 返回结果
1
2
3
4
5
6
7
8
9
Ok((
Self { inner: time_driver },
Handle {
io: io_handle,
signal: signal_handle,
time: time_handle,
clock,
},
))
  • 返回一个包含驱动器和其句柄的元组:
    • Self { inner: time_driver }
      • 驱动器的核心部分是时间驱动器,封装到 Self 结构体中。
    • Handle
      • 包含多个句柄,用于驱动器与外部的交互:
        • io_handle:处理 IO 事件的句柄。
        • signal_handle:处理信号的句柄。
        • time_handle:管理时间任务的句柄。
        • clock:提供时间支持的时钟实例。

tokio启动

通过launch.launch();启动所有的 worker 线程:

1
2
3
4
5
pub(crate) fn launch(mut self) {
for worker in self.0.drain(..) {
runtime::spawn_blocking(move || run(worker));
}
}

runtime::spawn_blocking 调用时, || run(worker) 匿名函数会被传进去,这其实就是 worker 线程要执行的逻辑。

如下,匿名函数会被包装为 BlockingTask,并被放在 blocking thread 的 run queue 中,这样当它运行时就会执行这个匿名函数。因为这时没有足够的线程,就会初始化一个新的 OS 线程(如果有 idle 的线程,就会通过 condvar 通知),并开始执行 blocking 线程的逻辑。每个 worker 都占用一个 blocking 线程,并在 blocking 线程中运行直到最后。

1
2
3
4
5
6
7
8
9
10
11
// runtime::spawn_blocking:
let (task, _handle) = task::joinable(BlockingTask::new(func));

let mut shared = self.inner.shared.lock();
shared.queue.push_back(task);

let mut builder = thread::Builder::new(); // Create OS thread
// run worker thread
builder.spawn(move || {
rt.blocking_spawner.inner.run(id);
})

作为常年开发网络程序的我,对异步、并发操作却仅仅停留在使用层面,这对我来说是一件长期的困扰。于是在第四阶段,我毫不犹豫地选择了“基于协程异步机制的OS”方向。在这个方向上,我学到了很多新的知识,也遇到了很多新的问题。

第一周

第一周的任务主要是了解 Rust 中异步的基本概念,通过阅读资料,我了解到了 Rust 中的 asyncawait 关键字背后的原理,并且了解到了如何实现有栈/无栈协程。过去我只了解过有栈协程,而无栈协程对我来说是一个全新的概念,其 LLVM Generator 的实现机制让我感到十分精妙。在这一周中,我主要是通过阅读资料和代码来了解这些概念,对于这些概念的理解还不是很深入。

第二周

第二周主要的工作是阅读 Tokio 代码,我阅读了 Tokio 的 netsignalsync 模块,我将部分代码的分析记录在了共享文档中,这些代码的阅读让我对异步编程有了更深入的理解。

第三周

第三周通过阅读 epollio-uring 等相关资料,我了解到了异步 IO 的原理。通过阅读 async-iopolling 的代码,我了解了 epoll 在 Rust 异步运行时中的运用,而通过阅读 monoiotokio-uring 的代码,我了解了 io-uring 在 Rust 异步运行时中的运用,这些代码的阅读让我对异步 IO 有了更深入的理解。io-uring 的机制起初让我感到困惑,但是在领悟到 io-uring 事实上是一个异步的系统调用框架而不是 epoll 这样的文件描述符复用机制后,我便茅塞顿开。

在阅读了大量相关的资料后,我也着手开始实现一个简单的异步运行时,通过对 mini-rust-runtime 的学习和修改,我将其中使用 polling 实现的基于 epoll 的异步运行时改为了基于 io-uring 的异步运行时,并初步支持了文件的异步读写和 TCP 连接。因为使用 io-uring 必须从系统调用层面进行编程,抽象层次很低,在查阅了大量资料后我才勉强完成了这个十分粗糙的实现。

总结

毫不夸张的说,这短短的三周我学到了近年来对我来说最有价值的知识,使我对异步编程的理解有了质的飞跃,使我未来能更好地开发出高性能的网络程序。在学习的过程中,我也遇到了很多问题,但是通过查阅资料和请教老师,我都得到了解决。这次训练营对我来说是一次非常有意义的经历,我也希望未来能有机会继续参加这样的活动。

第一周:

有栈协程与无栈协程

协程这块的概念可能比较混乱,不太能说得清,但是计科毕竟大多数情况下也不是深究定义的,所以纤程、绿色线程、协程大体上是可以混为一谈的,以下我们统称为协程。
尽管名称可能不同,但它们都可以被划分为两大类,一类是有栈(stackful)协程,一类是无栈(stackless)协程。
此处的有栈和无栈指的不是指协程在运行时是否需要栈,(毕竟不能回到“远古时代”的面条式编程)对于大多数语言来说,一个函数调用另一个函数,总是存在调用栈的;而是指协程是否可以在其任意嵌套函数中被挂起。有栈协程是可以的,而无栈协程则不可以。

有栈协程

实现一个协程的关键点在于如何保存、恢复和切换上下文。
在有栈协程中,我们将函数作为协程,保存上下文也就是保存从这个函数及其嵌套函数的栈帧存储的值。恢复上下文则是将这些值重新写回对应的栈帧和寄存器当中。切换上下文也就是保存当前的上下文,恢复下一个要执行的函数的上下文。有栈协程就是这么地朴素,也是我这种刚听说协程的萌新最易于理解的一种协程实现。

无栈协程

相比于无栈协程直接切换栈帧的思路,无栈协程则没有改变调用栈。而是使用了生成器(一种特殊的迭代器,能够在函数的执行过程中保存状态,并在需要时恢复执行)。这种特性可以用来模拟协程切换的行为,从而实现上下文切换。

无栈协程就是把代码转换成状态机,我们可以将 Future 视为一种协程。

rust的 Future 是通过状态机的形式来实现异步操作的。每个 Future 都是一个状态机,表示一个可能尚未完成的计算。
它通过轮询(polling)的方式推进计算过程,直到完成。
poll 方法会被异步运行时反复调用,当 Future 返回 Poll::Pending 时表示还没准备好,当返回 Poll::Ready 时表示完成。
所以,对future的运算实际上也就可以视为是在执行协程。

它不依赖操作系统或运行时的栈切换,而是通过将状态信息嵌入到 Future 的数据结构中。这样可以在编译时生成高效的代码来管理异步操作。

rust的协程

rust在古早版本(1.0之前)曾经有过一个有栈协程的方案——绿色线程。但是由于不符合零成本抽象的思想被移除了。此外,对于rust而言,绿色线程需要尽可能地减小预分配的堆栈大小,进而降低内存上的开销,毕竟需要比操作系统的线程更加轻量级,否则为什么不直接使用操作系统的线程呢?
之后,rust使用了无栈协程的方案,虽然增加了开发上的复杂度,但是良好地解决了并发问题。

其他

阅读了:

https://os.phil-opp.com/async-await/

两百行实现绿色线程:
https://zhuanlan.zhihu.com/p/100058478

第二周:

基本上就读读tokio和smol的源码,但是说实话,没太看明白,但是smol确实会更简单易懂一点。剩下的就是在读io_uring

io_uring机制

io_uring的设计主要围绕着环形缓冲区实现,SQ和CQ都是环形的,并且大小都是2的n次方(位运算奇技淫巧 index % size == index & (size - 1),当且仅当size为2的n次方时成立)。并且由于 UInt 的回环,所以可以直接tail - head,这种设计的一个优势是可以利用环的完整大小,而无需额外管理“环已满”标志。

应用程序与内核通过io_uring交互的过程大致如下:

应用程序通过提交队列SQ将SQE(IO请求)提交给内核,内核操作完成之后通过完成队列CQ将CQE(完成的IO请求)写回给应用程序,应用程序自行处理已完成的事件。

SQ 和 CQ 是内核与应用程序共享的内存区域,这样子,我们避免了频繁的用户态和内核态之间的切换,并且支持批量地提交请求,也减少了系统调用的次数,提高了性能。此外,由于 io_uring 可以直接访问用户态提供的缓冲区,避免不必要的内存拷贝操作。

其他

看了io_uring的一些资料:

https://kernel.dk/io_uring.pdf

https://zhuanlan.zhihu.com/p/361955546

稍微看了看:

https://tony612.github.io/tokio-internals/01.html

第三周:

实现运行时

大体上是参考了 async-rt-bookmaglev 这两个写出来的

仓库在my_runtime

其他

读了点与异步协程运行时以及IO_Uring有关的一些资料

一个实验性质的运行时: https://github.com/ringbahn/maglev

训练营内的一位同学的博客:https://zhuanlan.zhihu.com/p/12850908116

rust与io_uring: https://zhuanlan.zhihu.com/p/346219893

无栈协程: https://mthli.xyz/coroutines-in-c/

第四阶段选择了基于协程的操作系统,但并没有选择引入协程到操作系统的方向,不过也有相关思考,这段时间主要学习了rust的协程以及异步运行时的功能以及如何设计

协程只是可以挂起和恢复的函数

函数只有2个行为:调用和返回,函数返回后,栈上所拥有的状态会被全部销毁,协程则可以挂起,协程挂起时可以保留协程上下文,恢复则恢复协程的上下文,协程的上下文取决于协程内的局部变量等,反正是比线程上下文小,当协程像函数一样返回,协程也要被销毁

Cpp 协程和Rust协程的对比

cpp和rust同为无栈协程,但设计不同

  • 对于协程的调用者

cpp的做法是协程的调用者会得到来自promise_type结构体内get_return_object方法返回的对象,这里一般通过form_promise构造协程句柄coroutine_handle,调用者可以通过协程句柄resume协程和destroy协程,cpp的协程不是lazy的,这点和rust不一样,相当于拿到future后直接开始poll,不过cpp不需要waker,cpp的handle不需要程序员提供函数也不需要提供指针,已经构造好了,而rust需要rawwaker,需要一个函数表定义行为,再传入任务对象的指针,让waker能够操作任务对象,自然而然就能调度这个任务了,cpp只需要管理好何时resume,但rust是loop poll,程序员管理何时把future重新加入被poll的队列

rust则是每一个async函数都会自动生成并返回一个future对象,通过future trait可以看到主要是通过poll来执行协程内代码以及状态切换,这里贴一下tokio教学文档里的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
impl Future for MainFuture {
type Output = ();

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<()>
{
use MainFuture::*;

loop {
match *self {
State0 => {
let when = Instant::now() +
Duration::from_millis(10);
let future = Delay { when };
*self = State1(future);
}
State1(ref mut my_future) => {
match Pin::new(my_future).poll(cx) {
Poll::Ready(out) => {
assert_eq!(out, "done");
*self = Terminated;
return Poll::Ready(());
}
Poll::Pending => {
return Poll::Pending;
}
}
}
Terminated => {
panic!("future polled after completion")
}
}
}
}
}

rust中.await会变成对其future的poll,而waker则需要在对最外层future的poll时构造进context作为形参,通过poll的结果决定是否挂起,但是rust协程没有恢复这个操作,rust的协程是通过waker把任务重新调度回来再poll

cpp使用Awaiter来控制协程,符合直觉的操作,没有rust那么绕,resume就是直接重新进入协程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
enum State {
Start,
YieldValue,
FinalSuspend,
Done
};

struct CoroutineStateMachine {
State current_state = Start;
int a = 1, b = 1; //

// promise_type的引用,协程的promise接口
promise_type& promise;

void resume() {
try {
switch (current_state) {
case Start:
// 执行 initial_suspend
if (promise.initial_suspend()) {
current_state = YieldValue;
return; // 挂起
}
// 进入协程主体
[[fallthrough]];

case YieldValue:
while (a < 1000000) {
// co_yield a
promise.yield_value(a);
current_state = YieldValue;
std::tie(a, b) = std::make_tuple(b, a + b);
return; // 挂起
}
// co_return
promise.return_void();
current_state = FinalSuspend;
[[fallthrough]];

case FinalSuspend:
// 执行 final_suspend
if (promise.final_suspend()) {
current_state = Done;
return; // 挂起
}
// 结束
[[fallthrough]];

case Done:
return; // 协程结束
}
} catch (...) {
// 异常处理
if (!promise.initial_await_resume_called()) {
promise.unhandled_exception();
}
}
}
};
  • 内存布局

cpp是全都开堆上,而rust的future可自由选择在栈上还是堆上,对于自引用的结构体,当其被协程捕获作为协程的局部变量时,不允许转移所有权,我们使用Pin来进行保障,因为引用所代表的地址已经被标记为无效

异步运行时设计

协程是用户态的任务调度机制,而线程是内核的任务机制,做的事和内核一样,不断执行不同任务,不过是协作式调度,我们需要手动挂起来让其他任务运行,设想单线程环境下的任务调度,我们需要把任务存储在一个集合中,一个接一个运行协程,协程挂起时就再放入集合中。初步想法是这样的,但我们又不在内核态,完全可以把任务交给内核,而不是协程一挂起就重新放回集合,当集合为空时我们的线程就可以休息(多线程环境下其他线程使协程重新加入集合并把休眠的线程唤醒[把阻塞任务丢给了专门的线程])或是主动检查异步任务是否准备完成(阻塞调用不在用户态[需要内核的异步支持])

这样我们的线程可以把阻塞都丢给其他线程或是内核执行,而本身只需要处理更多的任务,提高并发量

和线程一样,我们也需要一个调用在协程里创建一个协程,我们需要spawn,有时候我们需要等待另一个协程的结果(仍然不会阻塞,因为外层的协程也挂起了),我们要为此添加JoinHandle,如果我们持有一个线程池来执行任务,spawn出的协程就需要一个面对线程池调度的waker,当资源准备好时加入线程池所用的任务队列,但当我们对其JoinHandle进行.await的话我们需要把当前协程的waker和其默认的waker进行替换,因为我们需要这个原本自由的协程在我们等待他的协程上恢复而不是在线程池中恢复后,任务的返回值不被关心,等待自然drop,使用线程池我们可以把同步调用异步化,让阻塞在单独的线程上运行,如果使用io_uring的话就更轻松了

io_uring

io_uirng是真正的异步io,通过他我们可以实现上述的第二种方案,当异步任务都处理完了我们的线程就检查完成队列,然后重新加入集合中,进行poll,此时资源已经准备好,不会造成任何阻塞

对协程引入操作系统的想法

考虑多核情况下,每个核心运行一个协程执行器,对于Mutex或信号量如果资源申请失败那么直接把任务挂起,把waker和任务指针记录下来,当资源被释放时,自动寻找等待队列的第一个元素,通过waker提供的函数表操作任务指针,使其重新加入对应核心的任务执行队列中。协程在内核态给我感觉是和序列生成器一般,能在一些部分减少时间片的浪费,提高任务处理的效率

x86 架构 hypervisor SeaBIOS 引导与 Linux 启动实现

1. seabios 工作流程

1
2
3
4
(1) POST( Power On Self Test):上电自检,BIOS 对计算机硬件(CPU、主板、内存等)的检测。
(2) POST 之后的初始化与启动相关硬件(磁盘、键盘控制器等)。
(3) 为 OS 创建一些参数,如 ACPI、E820 表等。
(4) 选择引导设备,从设备中加载 BootLoader,进而启动操作系统。

2. qemu 加载seabios过程

1
2
3
(1) qemu加载 seabios 在地址的 4G 最顶端的 LOW_MMIO 区,以及低 1M 区域各有一份。
(2) cpu 的第一条取指地址为 0xFFFFFFF0,该地址指向贴近 4G 的 BIOS 的最后 16 个字节,这也是 BIOS 的第一条指令。
(3) BIOS 最后 16 个字节处,是一个长跳转指令,目的就是换到低 1M 段空间去执行 entry_post ( ORG 0xe05b )

3. kbuild 使用方法

1
2
3
4
5
6
7
8
参考: https://github.com/Starry-OS/Starry

# To download the tool
$ cargo install kbuild
$ mkdir crates
$ kbuild patch add axstarry
$ kbuild patch remove axstarry
$ kbuild patch list

4. seabios 编译方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cat > .config << EOF
# for qemu machine types 2.0 + newer
CONFIG_QEMU=y
CONFIG_ROM_SIZE=256
CONFIG_ATA_DMA=n

CONFIG_XEN=n

CONFIG_DEBUG_LEVEL=9
CONFIG_DEBUG_SERIAL=y
EOF
echo "CONFIG_DEBUG_LEVEL=9" >> .config

make PYTHON=python3 oldnoconfig
make

5. seabios 反汇编

1
2
3
4
objdump -D -b binary -m i8086 bios.bin
objdump -D -b binary -m i8086 romlayout.o

-M intel : 指定intel格式

6. kvm 中所有 port IO

所谓端口Port IO, x86上使用in out指令进行访问, 和内存的地址空间完全隔离.(ARM上没有PIO) Guest以Linux为例: cat /proc/ioports查看当前OS的所有的ioports :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
  0000-0cf7 : PCI Bus 0000:00
0000-001f : dma1
0020-0021 : pic1
0040-0043 : timer0
0050-0053 : timer1
0060-0060 : keyboard
0064-0064 : keyboard
0070-0077 : rtc0
0080-008f : dma page reg
00a0-00a1 : pic2
00c0-00df : dma2
00f0-00ff : fpu
03c0-03df : vga+
03f8-03ff : serial
0510-051b : QEMU0002:00
0510-051b : fw_cfg_io
0600-067f : 0000:00:1f.0
0600-0603 : ACPI PM1a_EVT_BLK
0604-0605 : ACPI PM1a_CNT_BLK
0608-060b : ACPI PM_TMR
0620-062f : ACPI GPE0_BLK
0630-0633 : iTCO_wdt.0.auto
0630-0633 : iTCO_wdt
0660-067f : iTCO_wdt.0.auto
0660-067f : iTCO_wdt
0700-073f : 0000:00:1f.3
0700-073f : i801_smbus
0cf8-0cff : PCI conf1
0d00-ffff : PCI Bus 0000:00
1000-1fff : PCI Bus 0000:01
2000-2fff : PCI Bus 0000:02
3000-3fff : PCI Bus 0000:03
4000-4fff : PCI Bus 0000:04
5000-5fff : PCI Bus 0000:05
6000-6fff : PCI Bus 0000:06
7000-7fff : PCI Bus 0000:07
c040-c05f : 0000:00:1f.2
c040-c05f : ahci

7. 项目实现总结

项目刚开始, 我把seabios当作 kernel,写了个简单的 bios 来引导 seabios ,seabios成功运行

1
2
3
4
5
6
7
8
9
10
11
12
.section .text
.code16
.global entry16
entry16:
cli
cld

xor ax, ax
mov ds, ax
mov es, ax

ljmp 0xf000, 0xe05b

后面通过学习vmcs的使用方法,增加了CS寄存器的设置后,seabios 可以自启动成功。

1
2
3
4
VmcsGuestNW::RIP.write(entry.as_usize() & 0xffff)?;
VmcsGuest16::CS_SELECTOR.write(((entry.as_usize() >> 4) & 0xf000) as u16)?;
// On Intel requires 'base' to be 'selector * 16' in real mode.
VmcsGuestNW::CS_BASE.write(entry.as_usize() & 0xf0000)?;

对应的linux.tmol修改为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cpu_num = 1
phys_cpu_sets = [1]
entry_point = 0xf_e05b
bios_path = "bios-256k.bin"
bios_load_addr = 0xc_0000
kernel_path = "arceos-x86_64.bin"
kernel_load_addr = 0x100_0000
# ramdisk_path = ""
# ramdisk_load_addr = 0
# disk_path = "disk.img"
# Memory regions with format (`base_paddr`, `size`, `flags`).
memory_regions = [
[0x0000_0000, 0x1_0000, 0x13], # IO Port 64K 0b10011
[0x0001_0000, 0x400_0000, 0x7], # Low RAM 64M 0b111
[0xfec0_0000, 0x1000, 0x17], # IO APIC 4K 0b10111
[0xfee0_0000, 0x1000, 0x17], # Local APIC 4K 0b10111
[0xfed0_0000, 0x1000, 0x17], # HPET 4K 0b10111
]
# Emu_devices
# Name Base-Ipa Ipa_len Alloc-Irq Emu-Type EmuConfig
emu_devices = [
]

Seabios 加载内核流程,seabios加载内核是通过 fw_cfg 的 file 接口,读取 multiboo.bin 当作 rom 来加载的,这个 multiboo.bin是linux内核封装过的带有 0x55aa 标记的可以引导的 rom,seabios读取到 rom后,加载到内存中然后执行。整理需要实现内容如下(“对号” 为截至此笔记已完成的):

    1. seabios第一条指令地址为: 0xf000:0xe05b, 支持设置primary vcpu第一条指令地址 entry_point.
1
2
1. 目前实模式下还不支设置超过0xffff的地址
2. 考虑设置代码段 CS 寄存器
    1. 设置虚拟化需要截获的io端口

      1
      有些端口需要进行截获, 否则会透传到宿主机, 获取宿主机的信息, 例如pci信息, 内存大小信息等
    1. dma 实现支持

      1
      很多数据的传输需要通过 dma 传输
    1. 实现fw_cfg设备模拟
- [x] fw_cfg 实现 pio, 设备地址 [0x510, 0x511]
1
告诉seabios, 虚拟化环境为 “QEMU”
- [ ] fw_cfg 实现 dma, 设备地址 [0x514]
1
用于传输数据, 例如内核data数据等
    1. 实现rtc设备模拟, 设备地址 [0x70, 0x71]
1
在虚拟化环境中, seabios 通过 rtc 几个保留的寄存器获取内存大小信息
    1. multiboot 实现

      1
      seabios通过内核启动是通过multiboot协议启动的, 需要将内核文件进行重新封装
  • 其他 …

修改链接如下:

https://github.com/hbuxiaofei/arceos-umhv/tree/support-seabios

https://github.com/hbuxiaofei/axvcpu/tree/support-seabios

https://github.com/hbuxiaofei/x86_vcpu/tree/support-seabios

https://github.com/hbuxiaofei/axvm/tree/support-seabios

https://github.com/hbuxiaofei/axdevice/tree/support-seabios

运行日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
[  0.307806 0:2 axvm::vm:230] Booting VM[1]
[ 0.308076 0:2 arceos_vmm::vmm:40] VM[1] boot success
[ 0.308390 0:2 axtask::run_queue:393] task block: Task(2, "main")
[ 0.308757 0:3 axtask::task:471] task drop: Task(4, "")
[ 0.309079 0:3 axtask::run_queue:393] task block: Task(3, "gc")
[ 0.309436 0:5 arceos_vmm::vmm::vcpus:240] VM[1] Vcpu[0] waiting for running
[ 0.309852 0:5 arceos_vmm::vmm::vcpus:243] VM[1] Vcpu[0] running...
[ 0.310227 0:5 x86_vcpu::vmx::vcpu:118] VmxVcpu bind to current processor vmcs @ PA:0x5b2000
[ 0.310751 0:5 axvm::vm:258] >>>>> exit_reason IoWrite {
port: 0x70,
width: Byte,
data: 0x8f,
}

[ 0.311332 0:5 axvm::vm:289] IoWrite: 0x70 Byte 0x8f
[ 0.311646 0:5 axdevice::device:96] emu: GPA:0x70..GPA:0x72 handler write port:GPA:0x70 width:1 val:0x8f
[ 0.312180 0:5 axdevice::rtc:54] Rtc select 0xf

[ 0.312482 0:5 axvm::vm:258] >>>>> exit_reason IoRead {
port: 0x71,
width: Byte,
}

[ 0.312984 0:5 axvm::vm:278] IoRead: 0x71 Byte
[ 0.313268 0:5 axdevice::device:79] emu: GPA:0x70..GPA:0x72 handler read port:GPA:0x71 width:1
[ 0.313758 0:5 axdevice::rtc:81] Rtc read addr: GPA:0x71 GPA:0x71

[ 0.314130 0:5 axdevice::rtc:62] Rtc get index: 0xf

[ 0.314454 0:5 axvm::vm:258] >>>>> exit_reason Nothing

[ 0.314787 0:5 x86_vcpu::vmx::vcpu:131] VmxVcpu unbind from current processor vmcs @ PA:0x5b2000
[ 0.315285 0:5 x86_vcpu::vmx::vcpu:118] VmxVcpu bind to current processor vmcs @ PA:0x5b2000
SeaBIOS (version 1.16.0-20241104_115553-centos83-dev)
BUILD: gcc: (GCC) 8.5.0 20210514 (Red Hat 8.5.0-4) binutils: version 2.30-108.el8_5.1
enabling shadow ram
[ 0.316631 0:5 axvm::vm:258] >>>>> exit_reason IoWrite {
port: 0xcf8,
width: Dword,
data: 0x80000000,
}

[ 0.317243 0:5 axvm::vm:289] IoWrite: 0xcf8 Dword 0x80000000
[ 0.317586 0:5 axdevice::device:96] emu: GPA:0xcf8..GPA:0xd00 handler write port:GPA:0xcf8 width:4 val:0x80000000
[ 0.318159 0:5 axdevice::pci:210] >>> axdevice pci write GPA:0xcf8 0x80000000...

[ 0.318592 0:5 axdevice::pci:87] >>> set address 0x0 : device:0x0 : 0x0 : 0x0

[ 0.319020 0:5 axvm::vm:258] >>>>> exit_reason IoRead {
port: 0xcfc,
width: Word,
}
read QEMU_CFG_SIGNATURE 85(U)
Found QEMU fw_cfg
>>> qemu_cfg_read_entry start ...
>>> cfg read qemu_cfg_read over
>>> qemu_cfg_read_entry over ...
QEMU fw_cfg: 956659(0xe98f3) 0x2
QEMU fw_cfg DMA interface supported
>>> qemu_early_e820 call qemu_cfg_read_entry, port:0x19
>>> qemu_cfg_read_entry start ...
>>> cfg read qemu_cfg_dma_transfer 0x6f80 4
>>> dma outl: 0x518 0x806f000000000000
[ 0.508528 0:5 axvm::vm:258] >>>>> exit_reason IoWrite {
port: 0x518,
width: Dword,
data: 0x206f0000,
}

[ 0.509263 0:5 axvm::vm:289] IoWrite: 0x518 Dword 0x206f0000
[ 0.509671 0:5 axdevice::device:96] emu: GPA:0x510..GPA:0x522 handler write port:GPA:0x518 width:4 val:0x206f0000
[ 0.510356 0:5 axdevice::fwcfg:238] >>> do_write GPA:0x518 4 0x206f0000

[ 0.510837 0:5 axdevice::fwcfg:226] dma_write: GPA:0x518 0x206f0000

>>> dma outl over: 0x518 0x806f000000000000
QEMU: Terminated

参考文档:

SeaBIOS实现简单分析

浅度剖析 SeaBIOS 之 QEMU 初始化

<<Qemu/kvm源码解析与应用>> - 李强

三、四阶段学习总结

ArceOS及Starry项目

ArceOS

ArceOS是一个组件化内核,我的理解就是把传统内核中的那些模块都变成一个一个的组件,如果你需要fs相关的功能,你就把这个包弄进来,编译进来,然后就可以在里面直接用他的功能。这样给使用这个内核的人一种灵活性,同时每个模块相对独立,也让不同的模块之间的耦合较松,维护进来也很方便。

另一个好处就是我可以在这个项目的基础上做出我自己的扩展,比如说第四阶段的项目Starry。或者是如果我只需要用到其中的一些功能,例如我在一些嵌入式设备上使用,那额外的功能就可能完全没有用,还会有额外的开销,我就可以把我不需要的功能全都干掉。

具体来说主要看了的一些模块:

  • axhal管理了和平台相关的那些东西,这样可以把平台相关的代码(尽量)全都压缩到一个模块里,只暴露出一个接口就可以了,避免漏得到处都是
  • axmm实现的是虚拟内存管理相关的功能,提供了一些有关的抽象。里面用了MemorySet这个东西,把具体的实现给分开了(到Backend里),扩展性也比较好。
  • axtask实现了任务管理的功能,提供了很好用的接口,每一个任务可以被很容易地创建。这个项目里用来实现用户进程也很方便。
  • axfs实现的是文件系统相关的功能。
  • axns实现的是类似于Linuxnamespace的功能,可以让一些不同的应用有自己独占的资源。

等等

Starry

Starry是在ArceOS基础上做的一个宏内核的项目。我的理解大概就是,使用ArceOS提供的这些模块的基本功能,在这个基础之上做出我们常见的宏内核的样子,并且可以和我们现在常用的Linux等内核实现兼容,这样有很多的好处。

比如其中之一就是,用传统的方式来实现一个宏内核,很有可能会得到一个很乱很庞大的代码树。但是ArceOS他每个模块都分开来,本身各个模块的分工都是很清晰的,我们只需要用这些已有的功能,把我们需要的东西给组装起来,然后再加上额外的一些东西就可以了。这样我们写起来也很快乐,得到的代码也比较好理解。

我做的事

一句话说一下的话,就是在这个项目框架的基础上实现了一些系统调用。

任务管理

ArceOS的任务管理基本上是保持了简单的风格,有了大多数我们所必需的任务管理的内容,同时也把类似于Linux中的进程组啊,会话啊这些内容给拿掉,保持了简洁。他有一个TaskExt的设计,就是如果我们需要什么额外的功能,我们可以在这个位置加上我们所需要的field,这样就实现了扩展性。

就比如在这个项目里,我们对其扩展,加上了pid的字段。ArceOS中的任务已经有一个编号了,但是我们希望将对用户空间可见的部分和ArceOS中这些实现相关的部分给分开来。所以我们再添加了一个额外的pid,专门用于用户空间进程的管理,以后如果我们实现了进程组,会话等,可以再在这些加上pgidsid等,或者可以加上侵入式链表来把这些Task给串起来等。

然后还加了用于实现brk的变量,记录当前进程的break的位置。另外,用户空间程序的地址空间和Context也放在这里。因为每一个用户态程序都需要自己的(也有可能和其他人共用)虚拟地址空间,所以把这些放在这个位置,而不需要去修改原本ArceOS的任务部分。

AxNamespace

其中还用到的一个部分就是AxNamespace,但是这个用的觉得有一些问题。这个东西本来的作用大概就是我可以虚拟出几个空间,在这些空间里的进程都有自己的一些资源。在AxNamespace的描述里说,可以实现每进程专属的资源。他的实现是类似于 Thread Local,我有一个全局的数据,然后我可以选择,对于每一个任务,我在访问的时候要不要全都用这个全局的数据,还是每个任务都有自己的数据。

实际使用中发现,他在初始化每个任务自己的数据的时候,是直接把全局的这个数据给Copy过来。也就是说,如果我想把东西放到这个里面,那我需要这个东西都是Copy的。但是CURRENT_DIR这样的东西,他用的是AxResource,或者也叫Arc<Mutex<T>>,那把这个东西直接复制其实应该是会造成一些问题。其一就是他根本没有实现希望有的复制的语义。第二个就是这样实际会造成内存泄漏,因为这样相当于是造出了一个没有引用计数的Arc。如果我要是用一个Copy的东西呢?我觉得这样也比较麻烦。。。或者说这个实现在某种意义上有点像TLS。所以说这个AxNamespace的使用是我感觉有些疑惑的一个点。

实现的系统调用

实现了clone,但是具体的flags没有处理,只按照fork的语义+返回并且用给定的栈。这个位置应该加上Copy on Write的支持,在MemorySetBackend里加上这个,给每个页一个标记,然后在处理Page Fault的时候如果这个标记有了,就把这个页给复制一份,然后取消共享(虽然因为时间的关系没有实现)。我觉得Backend这个设计很好,在一些库里可以看到。这个设计让我们可以很方便地扩展三方库给的一些功能,同时不破坏这个库本身的代码和结构。

内存管理相关

现在的内存管理部分还是非常的简单,没有CoW,也没有懒分配,这个是我希望可以进一步完善的部分。axmm还有其他的一些库提供了很好的抽象,我觉得在这些基础上完成这个部分应该会相对比较轻松。

文件系统相关

实现了opencloseread还有dup等。这些都比较简单,因为ArceOS本身已经大致提供了这些功能的函数了,我们只需要把这些功能给封装一下。

在做这个部分的时候,想到了一个玩法。因为Starry本身就是一个ArceOS的应用,那我们可不可以在一个ArceOS的基座上同时跑两个Starry呢?现在这样实现,他们的进程之间就是分开的(都在TaskExt中有各自管理的pid),并且我们可以将他们在文件系统中的根目录给分开,比如说ArceOS这边的/mounta是其中一个的根,/mountb是另外一个的根。这样有些类似于是容器了,但是可能能提供更好的隔离性?

碎碎念

期末周杀我,这次项目中没做很多的事,还是感觉很遗憾吧。但是看了ArceOS的架构以后,感觉有很多的收获,了解到了一些新的想法。希望有时间的时候,可以进一步完善这个项目。参加这次训练营非常开心。

第四阶段参加了项目四基于协程异步机制的 OS,主要学习了协程异步的基本原理,阅读了 Tokio 源码,还了解了 io_uring。最终实现了简单的异步任务调度的操作系统,实现了异步任务调度功能,已经用于模拟异步延迟的 delay 函数,下一阶段还要继续实现异步的 I/O。

第四阶段还旁听了项目一题目一 Unikernel 支持 LinuxApp 第一周的学习,并参与项目实验,切身体验到了单内核应用开发所遇到的问题和难点。第二周是关于实现 Linux 应用支持的,因为本身对 Linux 应用了解就较少,也没精力和时间投入学习,只能战略性先放弃了。

我觉得经过两个项目的学习,我已经有了一个构建单内核异步操作系统的想法:内核像库一样提供,开发者只需选择需要使用的 future,然后像构建普通应用一样构建单内核操作系统,可以主要应用于嵌入式系统。接下来就是尝试实现这个框架,OS 库实现内存分配,异步任务调度等功能,使开发者调用库即可构建支持 Http Server 的单内核 OS,并运行到真机上。

同时,我也输出了两篇笔记,内容如下:

【Async OS】协程基本概念

协程的目的

协程的目的在于解决并发问题。常规并发是借助操作系统提供的线程来实现,其过程包含生成线程,通过系统调用执行 I/O 操作,并且在 I/O 执行期间会阻塞相应线程直至操作完成。在此过程中,存在两大显著问题:

  1. 用户态和内核态切换成本颇高。每次切换都涉及到系统资源的开销以及一定的时间消耗,这在频繁进行切换的场景下,会对整体性能产生较大影响。
  2. 操作系统线程需要预分配堆栈。每个线程都要提前分配好相应的堆栈空间来存储运行时的数据,当要实现大规模并发时,大量的线程就意味着需要大量内存来维持这些堆栈,内存资源占用较大。

协程解决并发问题的方式

协程主要通过以下两种方式来解决上述并发问题:

  1. 实现用户态的线程,也就是协程本身。协程运行在用户态,避免了频繁进出内核态带来的高昂切换成本,使得执行流程相对更为高效、轻便。
  2. 采用无栈协程的方式,实现不保存堆栈。这就避免了像操作系统线程那样,为每个任务都预留大量堆栈空间,从而节省内存开销。

线程、绿色线程与协程

协程的概念相对模糊,它本质上是一种可以暂停后再恢复执行的函数。不过其暂停机制存在歧义,可分为显式的通过语法函数实现(对应协作式调度)以及隐式地由运行时执行(对应抢占式调度)这两种情况。像知名的 Golang 使用的是堆栈式的抢占式调度方案,在 Rust 术语里,将这种类似操作系统线程的、堆栈式的抢占式调度方案定义为 “绿色线程” 或 “虚拟线程”。从本质上看,它们除了是在用户态实现之外,和操作系统线程并无根本差异。而严格意义上的协程,理应是无栈的协作式调度。

协作式调度与抢占式调度

协作式调度的特点是,任务若不主动让出执行权(yield),就会持续执行下去。与之相反,抢占式调度则是任务随时可能被切换出去。现代操作系统出于避免恶意程序长时间占用 CPU 的考量,大多采用抢占式调度方式。然而,抢占式调度存在明显缺点,由于任务随时可能被切换,所以必须保存任务的堆栈,如此一来,当任务再次被切回时,才能恢复到切换出去时的状态。这就导致在大规模并发场景下,需要耗费大量内存来保存众多任务的堆栈。

有栈协程与无栈协程

有栈协程就是上述提到的在抢占式调度场景下,需要保存任务堆栈的协程类型。那么无栈协程是如何实现的呢?在协作式调度中,因为任务不会被外部强制切出,所以可以在主动让出执行权(yield)时,仅保存必要的状态信息,无需像有栈协程那样完整保存计算过程中的数据。更进一步来说,甚至可以直接利用状态机来实现,从而彻底摆脱对堆栈保存的依赖。

Rust 的协程情况

早期 Rust 曾有过一个堆栈式协程的方案,但在 1.0 版本发布前被移除了。对于 Rust 而言,绿色线程需要解决的关键问题是怎样减小预分配堆栈的大小,进而降低内存开销,毕竟若不能比操作系统线程更节省内存,那使用操作系统线程就好了,没必要再另辟蹊径。
其中 Golang 采用的一种方法是堆栈复制,即先分配一个较小的堆栈,待其达到上限时,再将数据转移至更大的堆栈。但这种方式会引发两个问题:一方面,需要跟踪并更新原先指向堆栈的指针,这一过程本质上和垃圾回收器类似,只是将释放内存变成了移动内存;另一方面,内存复制操作会带来额外的性能开销。而 Golang 本身就有垃圾回收器且能接受额外的性能开销,所以在某些方面可以应对这种方式带来的问题。但对于注重性能和内存管理效率的 Rust 来说,这两点都是难以接受的,因此 Rust 最终选择使用无栈式的协程方案,其实现原理是将代码编译成状态机,虽然这种方式相对较难理解,但社区中已有不少优秀文章对此进行了清晰讲解,例如 blog-os 的 async-await 章节。以下是编译成状态机后的大致伪代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
enum ExampleStateMachine {
Start(StartState),
WaitingOnFooTxt(WaitingOnFooTxtState),
WaitingOnBarTxt(WaitingOnBarTxtState),
End(EndState),
}

impl Future for ExampleStateMachine {
type Output = String; // return type of `example`

fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
loop {
match self { // TODO: handle pinning
ExampleStateMachine::Start(state) => {…}
ExampleStateMachine::WaitingOnFooTxt(state) => {…}
ExampleStateMachine::WaitingOnBarTxt(state) => {…}
ExampleStateMachine::End(state) => {…}
}
}
}
}

总结

协程主要是用于解决并发问题,而非性能问题。

Golang 中的协程属于 “有栈协程”,与操作系统中基于堆栈式抢占式调度的线程本质相同,在 Rust 中被称作 “绿色线程”。而 Rust 实现的协程是 “无栈协程”,采用无堆栈的协作式调度方案,其核心原理是将代码编译成状态机。

网上常见对于 async Rust 的批判,认为其提升不了多少性能,却需要投入大量资源进行开发,还增加了开发复杂度,甚至会导致 “函数着色” 问题。实际上,这些批判有一定道理,但需要明确的是协程本身旨在解决并发问题,而非聚焦于性能提升。

【Async OS】最小化异步操作系统

前提

需要了解操作系统基础知识,实现简单的操作系统内核框架,并且要实现堆内存分配,因为必须要使用 BoxPin,还要实现打印功能用于测试,还需要实现时间获取,用于模拟异步延迟。

Async in Rust

Rust 提供了 Future trait 用于实现异步操作,其结构定义如下:

1
2
3
4
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}

poll 方法接受两个参数,Pin<&mut Self> 其实和 &mut Self 类似,只是需要 Pin 来固定内存地址,cx 参数是用于传入一个唤醒器,在异步任务完成时可以通过唤醒器发出信号。poll 方法返回一个 Poll 枚举:

1
2
3
4
pub enum Poll<T> {
Ready(T),
Pending,
}

大致工作原理其实很简单,调用异步函数的 poll 方法,如果返回的是 Pending 表示值还不可用,CPU 可以先去执行其他任务,稍候再试。返回 Ready 则表示任务已完成,可以接着执行往下的程序。

运行时

知道基本原理后,基于异步来构建操作系统的思路就很清晰了,即遇到 Pending 就切换到另一个任务,直到所有任务都完成。

先创建一个 Runtime 结构体,包含 tasks 队列,用于存储需要执行的任务。spawn 方法用于将任务添加到队列,run 方法用于执行异步任务,其逻辑是先取出队列中一个任务,通过 loop 不断尝试执行异步任务,如果任务 Pending,则先去执行另一个任务,以此实现非阻塞,直到队列任务全部为空。

poll 方法需要传入 Waker 参数,用于在异步任务完成后发出信号,因为目前是 loop 盲等的机制,并没有实现真正的唤醒,所以先采用一个虚假唤醒器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub struct Runtime {
tasks: VecDeque<Task>,
}

impl Runtime {
pub fn new() -> Self {
Runtime {
tasks: VecDeque::new(),
}
}

pub fn spawn(&mut self, future: impl Future<Output = ()> + Send + Sync + 'static) {
self.tasks.push_back(Task::new(future))
}

pub fn run(&mut self) {
while let Some(mut task) = self.tasks.pop_front() {
let waker = dummy_waker();
let mut context = Context::from_waker(&waker);
loop {
match task.poll(&mut context) {
Poll::Ready(val) => break val,
Poll::Pending => {
self.tasks.push_back(task);
break;
}
};
}
}
}
}

任务

任务的结构也很简单,含有一个内部 future,在 poll 时执行 future 的 poll。内部 future 定义很长但是不复杂,基于的 future 结构是 Future<Output = ()>,然后需要一个 'static 生命周期,同时需要 SendSync 实现跨线程共享,虽然现在没用到,但是 Rust 编译器可不同意你不写。dyn 声明动态类型也是必须要,同时还需要使用 Box 包裹来使编译器确定闭包大小,Pin 用于固定内存位置不可以动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Task {
future: Pin<Box<dyn Future<Output = ()> + Send + Sync + 'static>>,
}

impl Task {
pub fn new(future: impl Future<Output = ()> + Send + Sync + 'static) -> Task {
Task {
future: Box::pin(future),
}
}

fn poll(&mut self, cx: &mut Context) -> Poll<()> {
self.future.as_mut().poll(cx)
}
}

虚假唤醒器

无需了解过多,真正实现唤醒器的时候才需深入了解。

1
2
3
4
5
6
7
8
9
10
11
12
13
fn dummy_waker() -> Waker {
unsafe { Waker::from_raw(dummy_raw_waker()) }
}

fn dummy_raw_waker() -> RawWaker {
fn no_op(_: *const ()) {}
fn clone(_: *const ()) -> RawWaker {
dummy_raw_waker()
}

let vtable = &RawWakerVTable::new(clone, no_op, no_op, no_op);
RawWaker::new(0 as *const (), vtable)
}

Delay

基础框架实现完后,还需要实现一个延迟任务,用于模拟耗时操作。我打算实现一个 delay 方法模拟 sleep,以测试任务 sleep 的时候运行时会切换到下一个任务。代码很简单,DelayFuture 结构体包含一个 target_time 和一个 wakertarget_time 表示延迟到什么时候,waker 用于在延迟完成后发出信号。poll 方法中判断当前时间是否大于 target_time,如果大于则返回 Ready,否则返回 Pending

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pub async fn delay(ms: usize) {
DelayFuture::new(ms).await;
}

struct DelayFuture {
target_time: usize,
waker: Option<Waker>,
}

impl DelayFuture {
fn new(ms: usize) -> Self {
DelayFuture {
target_time: get_time_ms() + ms,
waker: None,
}
}
}

impl Future for DelayFuture {
type Output = ();

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if get_time_ms() >= self.target_time {
Poll::Ready(())
} else {
Poll::Pending
}
}
}

测试

rust_main 操作系统的 rust 入口,task1task2 是两个异步任务,task1 先打印 start task 1,然后延迟 200ms,再打印 end task 1task2 也是类似,只是延迟时间更长。运行时先执行 task1,然后切换到 task2,再切换到 task1,最后切换到 task2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#[no_mangle]
fn rust_main() {
let mut rt = Runtime::new();
rt.spawn(task1());
rt.spawn(task2());
rt.run();
}

async fn task1() {
println!("start task 1");
delay(200).await;
println!("end task 1");
}

async fn task2() {
println!("start task 2");
delay(500).await;
println!("end task 2");
}

打印结果:

1
2
3
4
start task 1
start task 2
end task 1
end task 2

总结

Rust 的异步编程还是很方便的,但是目前的实现还是很粗糙,比如没有实现真正的唤醒器,也没有实现真正的异步操作,只是模拟了异步延迟。下一步是实现真正的唤醒器,然后尝试实现异步的 I/O。

arceos宏内核扩展

我选择的方向是arceos宏内核扩展,主要工作是在新的宏内核扩展starry-next上,补全系统调用。我希望通过这个过程能更好的理解宏内核的内部的结构,和组件化操作系统的抽象解耦思想,也希望自己的代码能够更好的实现宏内核扩展和基座的解耦,虽然现在还是以先实现功能为前提,但是先完成后完美,之后逐步完善功能,并且抽离耦合的部分,让系统调用的实现更加优雅。

实现成果

目前已经完成绝大部分系统调用的实现,最花费时间的是任务clone的相关系统调用,将arceos的多任务切换(时钟中断抢占机制),地址空间(与rcore的地址空间排布不同,复制内核的地址空间)等相关的知识进行研究之后,才将clone的简单功能fork实现,之后关于任务相关的系统调用就很顺利了。

接下来,对arceos的文件系统进行研究,研究了线程间资源独立的新机制namespace,使用了一个很好的方法,实现了unikernel资源全局共享,使用宏内核扩展后,资源线程间独立或共享的机制。实现文件系统相关的api主要也就是调用axfs提供的api进行实现,需要解决的是axfs不支持open目录,但是openat这个系统调用需要打开目录,所以需要解决这个问题。

感悟

通过四阶段的学习,学习了组件化操作系统实现宏内核的思想和方法,经历过这三个月的学习,从不会rust和操作系统的小白,慢慢一步一步做,从学习rust,到完成rcore,再到学习arceos,逐步学习操作系统多任务、地址空间、文件系统等等,更加理解了riscv特权级结构,sv39页表结构等risv体系结构知识,总结这三个月,收获非常大,在这里也希望开源操作系统训练营可以越办越好,更多的人可以在这里学到知识,收获成长。

2024秋冬开源操作系统训练营第四阶段总结-张宇驰

学习内容

这个阶段,我选择了unikernel方向一的任务,为arceos实现linux的app移植,其中我进行了如下方向的探索:

  1. 跟安同学讨论了基于对Ecall进行截断的思路,并自己尝试截断Ecall并导向自己提供的函数,在最小幅度的情况下实现。最后也是其他东西都有了,但是没有做明白怎么截断。
  2. 同样是在群里,和安同学讨论在SBI中进行转发的设想,不过后来没琢磨明白怎么处理这些新的调用号而困惑,最后也仅仅停留在理论层面上。
  3. 最终的选择:自己实现Mocklibc,验证了用自己的函数替换musl中的系统调用的思路,也就是获得abitable,然后在对应的地方直接进行跳转。交流会的时候看到了另一个同学针对动态链接的实现思路,尝试修改代码后发现支持动态链接还是好做的,但是由于时间原因在交这份报告之前还没有跑起来。

学习收获

这个任务对我来说属实难度比较大,总结一下有两点没有做好:

  1. 时间管理。在学校安排中,我们进行了为期两周的实习,其实空余时间挺多的,但是自己没有利用起来(也跟中间还有个考试有关),然后往后做的时间少而且累。
  2. 探索能力。在面对二、三周的复杂项目时,我没有很好的确定到自己的方向,在一定程度上延误了时间。(不过就是探索了很多有意思的东西了)

下一步计划

往后顺着PPT上的东西继续探索吧,我不是特别在意一定要在训练营之中去做什么事儿。出于对这个项目的兴趣,我打算保持跟这里的交流,往后继续探索,验证一下自己的想法。然后看看寒假有没有机会去实习,吧这个项目完善完善,而且家里也没人,假期回家的意义也不大。