0%

基于Iouring的异步运行时

我首先选择完成的任务是基于iouring的用户态异步运行时,支持常见的文件和网络(Tcp)io。在我的实践经历来看,构建一个高性能的用户态异步运行时,就像是在应用程序内部再造了一个微型的操作系统,它接管了传统上由内核负责的部分调度和I/O管理职责,目的是为了消除内核/用户态切换的开销,并最大化I/O吞吐量。

为了实现的简单,我选择了thread-per-core 的任务调度模型。简单来说,thread-per-core可以理解为每个CPU核心分配一个独立的执行线程,每个线程(每个核心)都拥有自己的任务队列。当一个异步任务被提交时,它会被放入相应核心的任务队列中。这种设计有几个优势,一是减少竞争,由于每个线程操作自己的队列,线程间避免了共享锁的开销;二是缓存友好:任务和数据在特定核心上处理,能更好地利用CPU缓存,减少缓存失效,提高数据访问速度;三是不需要对每个任务有Send的限制。

IO接口的异步封装,负责与将os暴露的io接口改造成rust async/await异步语法的形式。传统的I/O模型(如 select, poll, epoll)虽然是非阻塞的,但它们本质上是“事件通知”模型——通知你有事件发生了,你再去读取数据。这依然涉及用户态和内核态之间的多次上下文切换。io_uring 则是提供了更为本质的异步io接口,一种全新的提交-完成模型————队列 (SQ):用户态应用程序将各种I/O操作(如文件读写、网络套接字的发送接收等)封装成请求,批量地放入一个共享的内核提交队列中;完成队列 (CQ):内核处理完这些I/O请求后,会将结果(成功与否、处理了多少字节等)批量地放入另一个共享的内核完成队列中。应用程序只需定期检查这个完成队列,就能得知哪些I/O操作已经完成,以及它们的结果。

无锁ringbuffer BBQ

为了进一步优化我们比赛的OS内核的任务队列,我选择参考BBQ paper实现的无锁ringbuffer,虽然最终性能并不理想,但实现该结构是一个很有趣的历程。大多数lock-free ringbuffer基于version+idx组成的 Atmoicusize 作为头尾指针,并通过loop + CAS方式更新头尾指针,version主要用于解决ABA问题;而BBQ通过将数组分块,头尾指针变为头尾块指针,并且在每个块的内部额外维护2个指针(allocated/reserved)以及2个计数(committed/consumed),一个显然的好处是头节点可以直接通过FAA指令获取分配位置。我对我实现的bbq进行了性能测试,目前实现的BBQ的性能表现非常糟糕,对比crossbeam-arrayqueue,尤其在SPMC、MPMC场景下吞吐差距在10倍以上甚至更多。并且我在实践中认为算法本身还有些边缘情形处理的问题,感兴趣的同学可移步讨论区。无锁的设计总是“危险”而精妙的,哪怕论文给出算法伪代码,实现的过程依然是相当曲折的,内存序的问题,aba问题,以及如何调整测试复现特定的bug,这个过程只有踩过坑才能知道痛。

os内核赛中组件化和异步化尝试

关于我们的比赛内核,我和我的队友在原先宏内核的基础上做了大量的改动,内容聚焦在组件化拆分以及异步化改造,前者主要集中在工作量上的庞大,如果确定好组件的依赖,如何设计出合适接口,这都需要仔细考量;异步化的改造客观来说工作量也很大,这是async传染性带来的必然,(如果重头构建一个异步内核可能相对好点),所以说目前我们为了必然大范围的传染性,会使用block_on语义的函数做一个暂时解决方案。异步os一个大的优势是不需要对每个task分配内核栈,这确实会节约相当大的内存开销,但任务异步化引入的问题之一就是内核抢占,2024届内核赛获奖内核Phoenix给出的解决方案是通过设置抢占标志允许至多一次的内核抢占,这是一个不错的方案,但通用性能否做的更好一点呢?或许早先组会上听到有无栈的结合是最佳的解决方案,但由于内核比赛测试临近,最近的工作在不停的修syscall,暂时没时间研究,希望能在决赛时拿出我们认为优秀的解决方案。

相关参考资料
monoio设计介绍[https://rustmagazine.github.io/rust_magazine_2021/chapter_12/monoio.html]
iouring介绍[https://arthurchiao.art/blog/intro-to-io-uring-zh/]
BBQ论文[https://www.usenix.org/conference/atc22/presentation/wang-jiawei]
AsyncOs[https://asyncos.github.io]

序言

非常高兴能参加开源操作系统训练营第四阶段的学习,并与大家共同进步。

在此,我首先要感谢陈渝老师,您在每周学习中给予的指导和鼓励,成为了我前进的坚实支柱。同时,我也非常感谢其他同学,在第四阶段的学习中,我从大家那里学到了很多,无论是便捷地获取学习资料和代码,还是在遇到疑惑时能找到理解并深入探讨技术的伙伴,都让我受益匪浅。

经历完这四个阶段,我取得了显著的收获:不仅深刻理解了操作系统内部的运行机制,更掌握了通过组件化管理实现现代化操作系统的方法。具体而言,我成功完成了arceos-org/oscampaarch64架构支持,并为starry-next适配了iperfTCP部分。

任务1 完成arceos-org/oscampaarch64架构的支持

  1. 使用QEMUmonitor info mtreemonitor info block 找出pflash区域,并在aarch64-qemu-virt.toml中增加正确的映射区域,在tour代码中写入正确的PFLASH_START

  2. 增加了aarch64部分的uspace代码,进行适配

  3. makefile内的规则进行修改,修改payloadmk_pflash对其他架构进行适配

  4. 增加payload(https://github.com/879650736/oscamp/blob/main/arceos/payload/hello_c/Makefile )(https://github.com/879650736/oscamp/blob/main/arceos/payload/origin/Makefile )内其他架构的编译规则,使payload对其他架构也能适配

  5. 增加aarch64的CI测试,并测试通过

  6. pr:https://github.com/arceos-org/oscamp/pull/9

  7. fork仓库:https://github.com/879650736/oscamp

任务2 为starry-next适配iperf

  1. 提交https://github.com/oscomp/starry-next/pull/56/files 。修复了oscomp/starry-next中的c中的sockaddrarceos/axnet中的SocketAddr的类型转换问题,改为使用trait直接将sockaddr 转换为SocketAddr,而不需要加一个中间层SockAddr,并测试通过。已合并。

  2. 提交https://github.com/oscomp/testsuits-for-oskernel/pull/52 。在为 starry-next 兼容 iperf的过程中,我发现一个段错误问题。具体来说,如果在cJSON_New_Item 函数中未对全局变量 global_hooks进行初始化,会导致空指针访问。然而,当我单独编译cJSON的相关代码时,并未复现此异常。我推测这可能是由于编译为 ELF 文件时,编译器进行了某种优化所致。将 global_hooks的初始化操作增加到cJSON_New_Item函数的起始位置后,该段错误便得以消除。

  3. muslopenssl库,使用build_riscv.sh, 进行openssl库的交叉编译

  4. 创建iperf_wrap, 进行本地编译载入测试

  5. arceos/modules/axfs/src/mount.rs中增加/dev/urandom的挂载,并增加了一个简单的urandom的实现

  6. 修改iperf中 autoreconfconfigure.ac,增加--disable-xxxx选项的支持

  7. 实现可增加--disable参数去除部分 Linux 特有的选项如SO_MAX_PACING_RATESO_BINDTODEVICEIP_MTU_DISCOVER等,为交叉编译提供支持,参考 build.sh,宏定义生成结果可通过src/iperf_config.h查看,也为调试提供方便。

  8. 允许用户在配置 iperf3 时,通过命令行参数禁用特定的功能或特性,特别是那些可能与特定操作系统(如 Linux)紧密相关的特性,以便于在其他平台或进行交叉编译时避免兼容性问题。

    • configure.ac 文件中使用 AC_ARG_ENABLE 宏来定义新的配置选项。

    • --disable-have-dont-fragment 为例

      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
      # Check if Don't Fragment support should be disabled
      AC_ARG_ENABLE([have-dont-fragment],
      [AS_HELP_STRING([--disable-have-dont-fragment], [Disable Don't Fragment (DF) packet support])],
      [
      case "$enableval" in
      yes|"")
      disable_have_dont_fragment=false
      ;;
      no)
      disable_have_dont_fragment=true
      ;;
      *)
      AC_MSG_ERROR([Invalid --enable-have-dont-fragment value])
      ;;
      esac
      ],
      [disable_have_dont_fragment=false]
      )

      if test "x$disable_have_dont_fragment" = "xtrue"; then
      AC_MSG_WARN([Don't Fragment (DF) packet support disabled by user])
      else
      if test "x$iperf3_cv_header_dontfragment" = "xyes"; then
      AC_DEFINE([HAVE_DONT_FRAGMENT], [1], [Have IP_MTU_DISCOVER/IP_DONTFRAG/IP_DONTFRAGMENT sockopt.])
      fi
      fi

      AC_ARG_ENABLE([have-dont-fragment], ...) 定义了 --disable-have-dont-fragment 选项。
      如果用户指定了 --disable-have-dont-fragment,则 disable_have_dont_fragment 变量被设置为 true
      如果 disable_have_dont_fragmenttrue,则会发出警告,并且不会定义 HAVE_DONT_FRAGMENT 宏。
      否则(用户未禁用),并且如果 Autoconf 之前的检查 (iperf3_cv_header_dontfragment) 确认系统支持 IP_MTU_DISCOVER 等选项,则会定义 HAVE_DONT_FRAGMENT 宏。

    • 针对 Linux 特有的套接字选项(如 SO_MAX_PACING_RATESO_BINDTODEVICE、IP_MTU_DISCOVER),提供 --disable 选项,以便在非 Linux 环境下(如交叉编译到嵌入式系统或其他操作系统)能够顺利编译,避免因缺少这些特性而导致的在其他环境下的运行错误。

    • 其通用模式

      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
      # 定义一个名为 'have-feature-name' 的选项
      AC_ARG_ENABLE([have-feature-name],
      [AS_HELP_STRING([--disable-have-feature-name], [Disable support for Feature Name])],
      [
      case "$enableval" in
      yes|"")
      disable_feature_name=false
      ;;
      no)
      disable_feature_name=true
      ;;
      *)
      AC_MSG_ERROR([Invalid --enable-have-feature-name value])
      ;;
      esac
      ],
      [disable_feature_name=false] # 默认启用
      )

      # 根据用户选择和系统检测结果,决定是否定义宏
      if test "x$disable_feature_name" = "xtrue"; then
      AC_MSG_WARN([Feature Name support disabled by user])
      else
      # 这里可以添加额外的系统特性检测,例如检查头文件、函数或套接字选项
      # if test "x$ac_cv_header_some_header" = "xyes"; then
      AC_DEFINE([HAVE_FEATURE_NAME], [1], [Description of the feature macro.])
      # fi
      fi
    • 当修改了 configure.ac 文件后,仅仅保存文件是不够的。configure.acAutoconf 的输入文件,它需要被处理才能生成实际的 configure 脚本。这个处理过程就是通过运行 autoreconf 命令来完成的。

    • autoreconf 命令会执行一系列工具(如 aclocal, autoconf, autoheader, automake 等),它们会:

      1. 处理 configure.ac: 将 configure.ac 中的 Autoconf 宏转换为可执行的 shell 脚本代码,生成 configure 脚本。
      2. 生成 config.h.in: 如果你的 configure.ac 中使用了 AC_CONFIG_HEADERSautoheader 会根据 AC_DEFINE 等宏生成 config.h.in 文件,这是一个模板文件,最终会被 configure 脚本处理成 config.h
      3. 处理 Makefile.am: 如果项目使用了 Automakeautomake 会处理 Makefile.am 文件,生成 Makefile.in
        因此,每次修改 configure.ac 后,你都必须在项目根目录运行 autoreconf -fi 命令,以确保这些修改能够体现在新生成的 configure 脚本中。 否则,你新添加的 --disable-xxxx 选项将不会被识别。
    • build.sh 脚本中,可以根据编译目标或环境变量来决定是否添加这些 --disable 参数。

      1
      2
      ./configure --disable-have-dont-fragment --disable-openssl --disable-cpu-affinity  
      ........
  9. api/src/imp中进行syscall的适配

  10. 对于跨平台elf调试,使用

    1
    2
    int i = 1;
    assert(i == 0);

    进行手动打断点结合printf一步步调试,最终找到https://github.com/oscomp/testsuits-for-oskernel/pull/52 的段错误的具体问题。

  11. iperf3测量原理

    • 基本工作流程:
    1. 服务器端启动: 一台机器作为服务器端,启动 iperf3 并监听特定端口,等待客户端连接。
    2. 客户端启动: 另一台机器作为客户端,启动 iperf3 并指定服务器的IP地址和端口,发起连接请求。
    3. 数据传输: 连接建立后,客户端或服务器(取决于测试模式)开始发送数据包。
    4. 性能测量: 双方在数据传输过程中记录时间、传输数据量、丢包等信息。
    5. 结果报告: 传输结束后,客户端和/或服务器会计算并报告测量的网络性能指标。
    • 在本机apt install iperf3后,自动安装并自启动了/usr/lib/systemd/system/iperf3.service
    text
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    iperf3.service - iperf3 server
    Loaded: loaded (/usr/lib/systemd/system/iperf3.service; enabled; preset: enabled)
    Active: active (running) since Fri 2025-06-20 05:23:30 UTC; 7h ago
    Docs: man:iperf3(1)
    Main PID: 1326 (iperf3)
    Tasks: 1 (limit: 9434)
    Memory: 472.0K (peak: 5.4M swap: 440.0K swap peak: 440.0K)
    CPU: 30.580s
    CGroup: /system.slice/iperf3.service
    └─1326 /usr/bin/iperf3 --server --interval 0

    每次开机后,systemd 会根据 iperf3.service 的定义,自动启动 /usr/bin/iperf3 --server --interval 0 命令,使其作为后台服务持续运行,等待客户端连接。

    • 当你在本机运行 iperf3 -c 127.0.0.1 时,这个命令会启动一个 iperf3 客户端进程。这个客户端进程会尝试连接到 127.0.0.1(即本机)上正在监听的 iperf3 服务器。iperf3 -c 127.0.0.1 会向服务器发送数据包,服务器接收这些包并进行统计。客户端也会统计发送的数据量和时间,最终报告发送端的吞吐量。

    • 客户端和服务器之间建立 TCP 连接(默认)。客户端以尽可能快的速度向服务器发送数据,服务器接收并记录数据量。双方都记录开始和结束时间。通过传输的数据量除以传输时间,即可计算出吞吐量。

    • qemu内运行的starry-next同理,因为qemu与主机是通过NAT。在 qemu 虚拟机内部运行的 starry-next(假设它也包含 iperf3 客户端或服务器)与主机之间的网络通信,会经过 qemu 的网络虚拟化层。

    • qemu 使用 NAT(网络地址转换)模式时,虚拟机拥有一个私有 IP 地址,它通过主机的 IP 地址访问外部网络。对于虚拟机来说,主机看起来像一个路由器。

    • 场景 : qemu 内的 iperf3 客户端连接到主机上的 iperf3 服务器。

    • qemu 虚拟机内的 iperf3 -c <主机IP地址>

    • 数据流:qemu 客户端 -> qemu 虚拟网卡 -> qemu NAT 转换 -> 主机物理网卡 -> 主机 iperf3 服务器。

    • 这种测试测量的是虚拟机到主机之间的网络性能,包括 qemu NAT 层的开销。

    • 无论哪种场景,iperf3 的基本客户端-服务器通信原理不变。qemu 的 NAT 模式只是在网络路径中增加了一个虚拟化的层,iperf3 测量的是经过这个虚拟化层后的实际吞吐量。

    • 关键设计点:

    • 处理程序中断信号(如 Ctrl+C)的机制。它使用了 signalsetjmp/longjmp 组合来实现非局部跳转,以便在接收到中断信号时能够优雅地退出并报告结果。

    • iperf_catch_sigend 函数

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      void
      iperf_catch_sigend(void (*handler)(int))
      {
      #ifdef SIGINT
      signal(SIGINT, handler);
      #endif
      #ifdef SIGTERM
      signal(SIGTERM, handler);
      #endif
      #ifdef SIGHUP
      signal(SIGHUP, handler);
      #endif
      }

      这段代码将 sigend_handler 函数注册为 SIGINT, SIGTERM, SIGHUP 这三个信号的处理函数。这意味着当程序接收到这些信号中的任何一个时,sigend_handler 函数就会被调用。

    • 信号处理的设置和跳转点

      1
      2
      3
      4
      5
      iperf_catch_sigend(sigend_handler); // 注册信号处理函数
      if (setjmp(sigend_jmp_buf)){ // 设置跳转点
      printf("caught SIGEND\n");
      iperf_got_sigend(test);
      }
      • if (setjmp(sigend_jmp_buf)): 这是 setjmp/longjmp 机制的关键。
      • setjmp(sigend_jmp_buf)
        第一次调用时(正常执行流程): 它会保存当前程序的执行上下文到sigend_jmp_buf 中,并返回 0。因此,if (setjmp(...))条件为假,程序会继续执行 if 语句块后面的代码。
      • longjmp被调用时(从信号处理函数中):longjmp 会使用 sigend_jmp_buf中保存的上下文,使程序“跳回”到 setjmp 被调用的位置。此时,setjmp 会返回longjmp 传递的非零值(这里是 1)。因此,if (setjmp(...))条件为真,if 语句块内的代码会被执行。
    • sigend_handler 函数

      1
      2
      3
      4
      5
      6
      static jmp_buf sigend_jmp_buf; // 用于存储跳转上下文的缓冲区
      static void __attribute__ ((noreturn))
      sigend_handler(int sig)
      {
      longjmp(sigend_jmp_buf, 1);
      }
      • 这是实际的信号处理函数。
        __attribute__ ((noreturn)): 这是一个 GCC 扩展属性,告诉编译器这个函数不会返回(即它会通过 longjmp 跳转出去,而不是正常返回)。这有助于编译器进行优化,并避免一些警告。
        longjmp(sigend_jmp_buf, 1);: 这是核心操作。当SIGINTSIGTERMSIGHUP 信号被捕获时,这个函数会被调用,然后它会执行longjmp
      • longjmp 会将程序的执行流从当前位置(信号处理函数内部)直接跳转到 setjmp(sigend_jmp_buf)所在的位置。
    • iperf_got_sigend 函数

      • 捕获到中断信号后,实际执行清理、报告和退出的函数
    • 这段代码实现了一个健壮的信号处理机制,确保 iperf3 在接收到中断信号(如 Ctrl+C)时,能够:

      1. 立即停止当前的数据传输。
      2. 收集并报告截至中断时的所有统计数据。
      3. 通过控制连接通知另一端的 iperf3 进程,以便对方也能感知到测试的结束并进行相应的处理。
      4. 最终优雅地退出程序。

适配成功:

iperf-V
iperf-c

序言

非常高兴能参加今年的操作系统训练营,经过这四个阶段的学习,对操作系统的理解逐渐加深。在此过程中,对陈渝老师和郑友捷老师的耐心指导表示衷心感谢。

在第四阶段的学习中,我主要做了以下工作:

  1. starry-next支持新特性:大页分配

    1. 后端映射实现

      1. 修改后端数据结构,为LinearAlloc这两种映射方式增加对齐参数align,该参数的类型为PageSize。根据对齐方式,分配相应的内存块。
      2. 修改物理页帧的分配方法alloc_frame,以4KB为单位,根据对齐参数大小,计算页面数量num_pages,调用全局分配器,分配num_pages个连续的物理页面,将这段连续内存的地址返回并映射到页表中,作为一整个页面。
      3. 修改释放方法dealloc_frame,基于以上alloc_frame,物理页帧的释放同理,根据对齐参数,以4KB为单位,计算需要释放的物理页帧数量,并调用全局分配器,释放这一段连续的物理内存。
      4. 修改空闲内存查找方法find_free_area,为适配不同的对齐要求,该函数首先对建议的起始地址hint进行对齐,然后执行两轮扫描,第一轮扫描处理hint之前的区域,以确定起始搜索位置,第二轮扫描检查各内存区域之间的空隙,跳过重叠和已占用的区域,检查满足对齐要求和大小的区域,最后检查末尾区域,验证并返回找到的地址。
      5. 修改unmap方法,考虑取消映射的内存区域可能存在不同对齐的情况,对unmap方法进行改进。首先验证起始地址和大小是否满足4K对齐,然后查找定位取消映射的内存块,根据每个内存块的对齐要求,验证对齐,最后执行取消映射。
      6. 增加一个page_iter_wraper.rs文件,包装PageIter4KPageIter2MPageIter1G为一个PageIterWrapper,方便遍历。
      7. 对线性映射,文件读写,DMA和懒分配的支持,在相关函数如new_linearprocess_area_datahandle_page_fault中增加对齐参数支持不同页面的对齐要求。
    2. 内存扩展

      1. 为测试大页尤其是1G大页的分配,读写和回收等情况,需要扩展平台内存以运行测例。
        1. 修改aarch64架构配置文件,修改其物理内存大小为4G
        2. 修改riscv64架构配置文件,修改其物理内存大小为4G
        3. 修改loongarch64架构的配置文件,扩展其物理内存,并修改其物理基址和内核基址以避免内存重叠
        4. 修改x86_64架构的配置文件,扩展其物理内存,在mmio-regions新增高地址支持,修改modules/axhal/src/mem.rs文件,新增free_memory_region,扩展x86_pc中的multiboot.S支持高位地址空间
      2. 扩展内存之后在所有架构下测试
      3. 将以上所做的修改工作落实成文档提交
  2. 分析和改进axalloc

    1. 分析axalloc代码逻辑,撰写文档
    2. 改进axalloc
      1. 新增buddy_page.rs,基于buddy算法,实现了一个页分配器,并添加相关的测试模块。实现思路如下:
        1. 初始化过程:将起始地址和结束地址对齐到页边界,内存基址对齐到1G边界(与arceos的BitmapPageAllocator保持一致),将整个内存区域分解为最大可能的2的幂次个块。
        2. 分配算法:检查需要分配的页面数量和页面大小,验证其是否与PAGE_SIZE对齐,根据所需页面数量和对齐,计算所需阶数,查找可用块,将大块分割到所需大小,将多余部分加入对应的空闲链表,标记页面为已分配,更新使用统计。
        3. 释放与合并算法:首先标记当前块为空闲,然后递归检查伙伴块是否空闲,如果是则合并,将最终合并的块加入到对应阶数的空闲链表。
      2. 新增tests.rs,提供一个基准测试模块,用于比较BitmapPageAllocatorBuddyPageAllocator两种页分配器的性能表现。该测试模块包含两个主要测试函数,用于评估不同分配器在碎片化和内存合并方面的表现 ,测试结果如下:image-20250621162956014
        1. 合并效率测试结果:两个分配器都显示了 100% 的合并效率,这意味着它们都能完美地将释放的单页重新合并成大的连续内存块
        2. 碎片化测试结果分析:
          • Random Small Pattern(随机小块分配):
            • Bitmap: 50.0% 碎片化率
            • Buddy: 83.3% 碎片化率
            • BitmapPageAllocator 表现更好
          • Mixed Size Pattern(混合大小分配):
            • Bitmap: 41.7% 碎片化率
            • Buddy: 33.3% 碎片化率
            • BuddyPageAllocator 表现更好
          • Power-of-2 Pattern(2的幂次分配):
            • Bitmap: 16.7% 碎片化率
            • Buddy: 33.3% 碎片化率
            • BitmapPageAllocator 表现更好

开源操作系统训练营第四阶段总结-改进Starry文档

学习心得

赶着入职前进入到了第四阶段,留给我的时间不多,都不到一周,但是陈老师没有放弃我,仍然耐心的给与了我如何开始学习Starry的指导,我非常感动。四阶段项目一群中的群友也给与了我很大的帮助,让我感受到了开源社区的魅力。

在四阶段学习中,我收获良多,由于不是计算机专业的学生,有许许多多的第一次:

  • 第一次学习操作系统:还是 RISCV 指令集的,在MacOS上跑代码没少折腾。很多内容让我回想起了本科学习计算机组成原理的时光(还是很不相同)。
  • 第一次使用 docker:本来倔强的我想在 MacOS 上坚持到最后,没想到还是在第三阶段涉及到交叉编译的部分败下阵来,配置 docker 环境的时候可能由于网络问题也没少折腾。
  • 第一次提交 PR:之前使用GitHub也只是上传论文的代码,单打独斗。在刚进入第四阶段的时候,陈老师很耐心的用一个修改 README.md 的机会指导我怎么提交 PR。第一次了解了团队合作乐趣。
  • 第一次了解 GitHub 工作流:前三阶段大概知道有这么一个东西在给我们提交的文档打分,在第三阶段中 musl 被官方屏蔽了微软的访问之后一直访问超时,我才开始去理解其中的含义。

闲聊了这么多,还是因为我入门尚浅,翻来覆去看代码也只知道还要学习的有很多,难以下手,留给我的时间又只有一周。还好陈老师给我指了一条明路——完善文档,接下来我就介绍一下这一周我的工作以及将来的计划。

本周工作

我的工作主要是分析starry-next,并阅读 starry-next tutorial book,从各个层面改进starry-next tutorial book,帮助自己和其他初学者更好地学习操作系统开发。

在开始完善文档之前,我阅读了郑友捷同学的综合论文训练——《组件化操作系统 ArceOS 的异构实践》,宏观的了解了 StarryOS 的设计理念和目标。在完善文档的过程中,郑友捷同学也给予了我很多建议和指导,在此表示感谢。

我的工作具体如下:

  1. PR#23 修复了指导书不能切换主题的bug, 可以切换到深色模式,便于完善文档时的调试(一边黑一边亮容易被吸引注意力)。

  2. PR#24 在阅读完郑同学的论文之后,修改了文档的欢迎页,说明了 StarryOS 和 ArceOS 之间的关系和差别。因为从第三阶段到第四阶段,作为初学者的我一开始是一头雾水,不知道 Starry 要实现一个什么样的目标,所以我也在欢迎页添加了设计目标的说明(仍需完善)。在我与郑同学的交流中感觉到 Starry 的目标可能是:

    • 在 crate 层中希望能够开发一些独立的组件,一部分就像 ArceOS 一样,能够被任意类型内核复用, 一部分则是能够被其他的宏内核使用。

    • 使 Starry 能够兼容 Linux 程序,即提供 POSIX API 接口。

    • 完成作为一个宏内核该有的功能,包括进程管理、信号处理等。

      作为一个第一次接触 Starry 的开发者,我觉得可能还需要一个更宏大的目标,或者更明确的商用的可能性来吸引更多的人加入我们的开源社区,并做出贡献。

  3. pr#25 在进入到第四阶段的时候,配置完环境后,只是按照 README.md 中给的指令运行了一遍,但是依然没有理解具体 Starry 究竟做了些什么,他的目标是什么。而且我在创建镜像的过程中遇到了一些问题——loop设备满了导致无法加载,我也是通过读了一遍Makefile的流程才定位到这个错误。因而我写了一个一个案例快速上手 Starry,介绍了这些指令内部的一些细节,帮助初学者快速理解。PS: 今天我再读的时候发现了其中的一些错误(镜像文件应该是给QEMU加载的,不是ArceOS的文件系统),而且没有介绍案例 nimbos 的主要作用,可能对于学习过操作系统的同学来说,不用说也知道是一个测试操作系统功能的测试集,从一个门外汉的角度来看,他就是测试了几个testcase而已。我会在后续的工作中对文档进行修正。

  4. pr#26 可能对于大佬来说,各种 git 指令都已经理解透彻,烂熟于心,但对于初学者来说,只会用一个

    1
    2
    3
    git add .
    git commit "update XXX"
    git push

    在这之前我都没有创建过分支来提交PR,导致了一些混乱,因此在附录中添加了一个创建分支提交PR的标准流程。此外对于理解StarryOS究竟在做什么,理解他的工作流很有必要,因此我也在附录中添加了对于工作流相关的说明。

总结和将来的工作

由于时间不多,我的水平有限,相比其他同学对社区的贡献,我的工作可能微不足道。如果社区的大佬们不嫌弃,入职工作之后我也愿意继续帮助完成文档(主要因为这个项目里面用到的Rust语法很全面,我想要学习rust, 还包含了很多汇编和c,将来也能对用rust驱动硬件做为一个参考)。

可能有一部分同学和我一样,在阶段一到阶段三主要注重于做题(阅读测例->知道预期的结果是什么->查看相关的接口和需要用到的函数->实现功能),完成任务即可。没有特别理解整体的设计和原理,因而到了阶段四之后没有了题目之后感到迷茫。

因而为了帮助第三阶段的同学能够丝滑的进入到第四阶段,我觉得当务之急是需要完善ArceOS的文档(我第三阶段完全就是参考PPT完成的,对于很多细节没有掌握)我接下来的工作就是理解ArceOS的细节,说明Starry如何使用了ArceOS的接口和模块。然后首先实现文档整体框架的从无到有,最后在掌握了细节和整体设计思路之后再完善指导书并修正其中的错误。也希望有大佬能够一起加入到完善文档队伍中,一起交流。

序言

过去一个月,由于我对 ArceOS 的架构理解较少,为了快速掌握 ArceOS 的架构,Unikernel宏内核,我的主要目标改进 oscamp,为其完善对 x86_64 的支持。同时还做了对内核组件 x86_rtc 说明文档和测试的完善。
最直接的收获有两点:

  • Unikernel 思想 —— “用户态就是内核的一部分” 的最小可信边界,让我重新审视传统多进程操作系统里“内核/用户”硬隔离的成本。
  • 宏内核工程学 —— 模块划分、内核线程、系统调用网关、设备驱动归一化,这些都在 Starry/ArceOS 的设计里有了“先行者版本”。

工作记录

第一周

为了快速掌握 ArceOS 的架构,Unikernel宏内核,我选择了改进 oscamp,为其完善对 x86_64 的支持这一项工作。并开始了对 x86_64 的学习。

第二、三周

主要是做代码工作,以下是一些总结
RISC‑V 是通过 scause + stval;x86‑64 要区分 Exception Class(#PF/#UD)与 IRQ Vector,且栈布局不一样。
x86_64 的支持这一项工作需要完成以下的功能实现:

  1. 改进 context.rs,保存相关的寄存器,并完善 context_switchenter_uspace
    • 保存/恢复的寄存器集对齐 SysV x86‑64 调用约定:RBX RBP R12‑R15 + CR3 + RFLAGSFPU/AVX 延后到 lazy fp 任务。
    • context_switch(old, new) = 保存旧任务栈顶 → 恢复新任务栈顶 → iretq;为支持 SMP,加了 core::arch::asm!("swapgs") 保证每 CPU 的 GS 基址切换。
    • 进入用户态 (enter_uspace):手动构造 iretq 帧:SS|RSP|RFLAGS|CS|RIP,再写 CR3 = user_pml4; 关中断→加载帧→开中断→ iretq
  2. 改进 trap.S
    • IDT 256 项0x20 时钟、0x80 软中断、0x0E #PF……全部指向统一的 trap_entry;硬中断通过 APIC 自动切到 IST[0] emergency stack 防止内核栈溢出。
    • trap.rs 根据向量号派发到 handle_page_fault / handle_irq / handle_syscall
  3. 改进 syscall.rssyscall.S
    • SYSCALL/SYSRET 而非 INT 0x80;入口先 swapgs 用 GS 保存/恢复用户栈。
    • 按 SysV ABI RAX=nr,RDI RSI RDX R10 R8 R9 传六参 —— 在汇编里把寄存器序列化到栈,统一传给 x86_syscall_handler()
    • 退出路径:恢复通用寄存器 → swapgssysretq

第四周

对内核组件 x86_rtc 说明文档和测试的完善。
repo

未来展望

  • Transparent HugePages:复用前期完成的巨页 API,引入 khugepaged 合并线程。
  • vDSO:把高频 clock_gettime 胶水放到用户态,加速 Sys‑API。

第一阶段 rust基础

rustlings比较顺利,已关注rust好几年了,也一直跟进rust的发展和变化,用了大概几天的时间突击完成

第二阶段 rcore

这是我第一次接触操作系统,印象还都停留在原来的书本上,概念上,都是理论,没有真正接触操作系统是什么样的,所以,这个机会能让我了解探究操作系统内部真正的原理和运行逻辑,解开我心中的多年的疑惑,很是开心到起飞。

总体印象最深的就是操作系统内核是以什么形态存在的,上下文如何切换,进程空间如何形成,页表是如何实现的,跳板页又是怎么回事

第三阶段 ArceOS

这个和我预期不一样,没有沿着rcore继续走,这是一个全新的设计,一时间,有点慌乱,不能和rcore相关的思路很自然的顺承下来,显然这个更具有前瞻性,为此我也花了好多时间来梳理这个逻辑和rcore都关联起来。这个模块化设计的理念很突出,相互之间的关联及细节,需要仔细的研读和体会。

第三阶段的具体case

color

对println!() 把颜色表示直接加入后,可以显示颜色,
如在当前文件写了一个宏print_with_color!,让后让println!去去调用此宏,就得不到正确结果,提示找不到这个宏,查了资料,和 $crate有关,宏的暴露方式有关,引用路径,由于时间关系,后面调研

HashMap

开始的时候,从rust官方移植,这个太痛苦了,依赖太多了,最后放弃。
自己写个简单的,只是利用了官方的
use core::hash::Hasher;
use core::hash::Hash;
这两个hash算法,主体采用了最简单的线性插入算法

bumpallocator

需要仔细理解题意和上下文,思路选用:
申请一直从可用空间起始向前申请,
释放如果全部的空间都释放了,就把下一个可用空间调整到开始

rename

采用递归的方式,一层一层的先找到所在当前目录
增加当前目录的,更改命名的方法,查找相应的文件,删除后,再插入一个,因为当前存储使用的是BtreeMap,不能更改index

hv

这个耗费了我好多时间,主要是因为运行例程会卡住,后来发现可能是qemu的版本问题,升级到9.2后,还是一样卡住,当时环境为windows11,wsl2,卡在这里,不同的情况卡的还不一样:

1
Write file 'payload/skernel2/skernel2' into disk.img

最后没有办法,找一台空闲机器,实在不行,我就安装裸机linux系统,所以先尝试装了另一种虚拟机,virtualbox7.18,也花了些时间,配置好环境后,默认的qemu为6.x时,还是会出错,不过不是卡主的问题,是会触发异常访问,升级到9.2.x后,按照预期执行了,难道说,wsl2在某些情况就是不行,我真是难过。

之后遇到提交github,出错,musl.cc被block,

下载不下来:
wget https://musl.cc/riscv64-linux-musl-cross.tgz

下载不下来,终于等来了替代方案

wget https://musl.cc.timfish.dev/riscv64-linux-musl-cross.tgz
最后,终于得以解决

sys_mmap

参数和返回值我理解出现偏差,

1
2
3
4
5
6
7
8
fn sys_mmap(
addr: *mut usize,
length: usize,
prot: i32,
flags: i32,
fd: i32,
_offset: isize,
) -> isize

参数addr为0时要特别注意,需要寻找一空间,还有地址和size的对齐。返回值,当开始时认为0是成功,为负时,返回失败原因,后来再三确认失败返回0,成功返回地址,isize作为地址返回,有点不符合直觉?

Next

期待第四阶段

前三阶段总结

主要收获

在第四阶段中,更多的时间留给了自由探索.虽然起初缺少具体的目标有些令人摸不着头脑,不过跟随老师的引导,也一步步确立了整个阶段的目标:基于 uring^1 机制实现异步系统调用.尽管最后只实现了基于 uring 的异步 IPC 机制,一路上走来也有许多收获.

Rust 的异步机制

虽然有一些 Rust 异步编程经验,但尚未从更底层的角度了解过 Rust 的异步模型.在第一周中,通过动手实现一个简易的异步运行时 local-executor,认识到 Rust 的异步原语 Future 是如何与运行时交互的.以及深入到内存安全层面上,了解了 Rust 中如何通过 pin 语义来巧妙的保证自引用结构体的安全性.尽管这并不是一个完美的模型——几乎所有涉及到 pin 语义的数据结构和函数都需要 unsafe 代码,而这些 unsafe 代码所需的诸多安全性保证又着实有些令人头大.因为,Rust 提供了静态安全性,而编译器会基于这些安全保证进行比较“激进”的优化.所以,Rust 中的 unsafe 要比其他生来便“不安全”的语言更加不安全,对开发者的要求也更高.

在第三周的探索中,又了解到一个之前从未考虑过的问题——Future 的终止安全性^2.而这对于实现基于共享内存的异步通信机制来说尤其关键,稍有不慎就会引发难以察觉的漏洞.在后来着手实现异步通信机制的时候,又对这个问题进行了更深入的思考,并在现有方案的基础上提出了另外几个可行的思路

原子类型和内存排序

尽管曾了解过原子类型和内存排序相关的知识,但从未真正彻底搞懂过,直到在第二周的探索中发现了一本优秀的电子书 Rust Atomics and Locks^3.这本书从抽象的并发模型深入到具体的硬件细节,比较全面的介绍了几种原子操作和内存排序的设计初衷以及对应的汇编层面实现.结合这本书和自己的思考,又经过悉心整理最终形成了一篇比较详实的学习笔记.尽管在实践时还不能完全掌握各种内存排序的选择,通过翻看笔记以及参考相似场景下现有项目的做法,也都能找到一个安全正确的选项.

基于 uring 的异步通信

经过两周的调查和学习,最终在第三周完成了基于 uring 的异步通信框架 evering,同时利用 GitHub Pages 部署了它详细的设计文档

evering 最重要的两个数据结构是用来管理消息队列的 Uring 和用来管理操作生命周期的 DriverUring 的实现借鉴了 io_uring 的做法^4,但结合 Rust 的特性做了一些简化.比如,io_uring 支持 IOSQE_IO_LINK 来要求响应侧顺序处理请求.而在 Rust 中,每个异步请求都被封装为 Future,故可以利用 .await 来在请求侧实现顺序请求.Driver 的实现则借鉴了 tokio-uringringbahn.但相比后两者,evering 提供了更灵活、通用的异步操作管理机制.

不过,目前 evering 相对简陋,仅支持 SPSC,因此请求侧或响应侧只能在单线程上使用.也许未来可以实现 MPSC 的队列,以便于更好的与现有的异步生态(比如 tokio)兼容.

基于 evering 的异步 IPC

经过三周的铺垫,第四周正式开始实践跨进程的异步通信.在第三周中,基于 evering 实现了简易的跨线程异步通信 evering-threaded,而对跨进程来说,主要的难点就是内存的共享.好在 Linux 提供了易于使用的共享内存接口,基于 shm_open(3)memfd_create(2)mmap(2) 可以轻松在不同进程之间建立共享内存区.而 ftruncate(3p) 配合缺页延迟加载机制,使得程序启动后仅需一次初始化就能配置好可用的共享内存区间.不过,目前 evering 只能做到基础的“对拍式”的通信方式.而近期字节跳动开源的 shmipc 则是一个相对成熟、全面的异步通信框架,这对未来 evering 的改进提供了方向.

基于 evering 的异步系统调用

由于时间相对仓促,加之备研要占用大量的时间,遗憾的是,在第四阶段并没有完成最初的目标——实现基于 uring 的异步系统调用.与 用户线程 <-> 用户线程 的通信相比,用户线程 <-> 内核线程 的通信要额外处理内核任务的调度和用户进程的生命周期管理.即如何处理多个不同用户进程的请求,以及用户进程意外退出后对应内核任务的清理.而就共享内存而言,由于用户对内核单向透明,这看起来似乎比 IPC 的共享内存更容易解决.

用户态线程与协程的调度

去年的夏令营中,embassy-preempt 实现了内核中线程和协程的混合调度.那么用户态的协程能否被内核混合调度呢?在实现异步系统调用的前提下,当用户态线程由于内核尚未完成调用处理而让权(通过 sched_yield(2) 等系统调用)时,实际上,内核可以获知该线程应何时被唤醒.这就与 Rust 协程中的 Waker 机制非常相似,而用户态的让权又与 .await 很类似.基于这些,那么可以将一个实现异步系统调用的用户线程转换为一个用户协程.此后,内核就充当了这个协程的运行时和调度器的角色.

而相比用户态的线程,使用协程的一个显著优点是,对用户任务的唤醒实际上相当于执行一次 Future::poll.这意味着,当用户主动让权时,它不需要保存任何上下文——用户任务的唤醒本质上变成了函数调用,而主动让权表示该函数的返回.如此便能够进一步减少用户和内核切换的开销,以及系统中所需执行栈的数量.当然,当用户协程被抢占时,它便回退成了类似线程的有独立执行栈和上下文的存在.

总结

经过近两个月的学习,对操作系统和异步编程的许多方面都有了一些相对清晰的认知.非常感谢夏令营中各位老师的付出和历届同学的努力,学习的过程中让我切身的感受到操作系统发展到现在那段波澜壮阔的历史,以及在不断推陈出新的技术潮流中一点微不足道的参与感.尽管最后没能完成目标有些遗憾,不过,这也为将来再次参加夏令营留下了充足的理由 :P

序言

非常高兴能参加到开源操作系统训练营第四阶段的学习,跟大家一起进步。经历完这四个阶段,自己有非常大的收获,让我理解了操作系统内部的运行机制,通过组件化的管理来实现更现代化的操作系统,最终在操作系统中支持异步机制提高操作系统的性能。

在每周的学习过程中,非常感谢向勇老师给与了很多的指导和鼓励,成为了我前进的支柱。同时也要感谢周积萍学长给与了很多帮助和支持以及建设性的意见,让我遇到困难时不迷茫。在第四阶段的过程中,也从其他同学那学到了很多,能便捷的获取到学习资料和代码,当有疑惑了也有人能够理解你,跟你一起深入到技术中进行讨论,那种感觉真是舒服。

每周工作

对于一个程序员来说,时间总是不够的,在四周时间里,我主要做了以下几个方面的工作:

第一周 - 回顾

当我第一周(2025-06-01)加入训练营的时候,发现去年的学长大佬们已经早早的开始了自己的课题。我就从基础慢慢开始,这周主要是把文档中的例会视频都看了一遍,基本了解了协程异步的作用以及目前是如何把异步应用到操作系统内核当中去的。

第二周 - 定目标

在看例会视频的时候,我发现有学长讨论 rust 异步的函数着色问题,碰巧我之前在写 rust 的时候也遇到了这个问题,很多时候一套逻辑代码,要分别实现一个同步版本一个异步版本,从一些学长的代码中看确实是分开写的,比如赵方亮学长的仓库,在 2025-06-07 例会讨论中,有同学在参加 “大学生操作系统大赛” 的时候也遇到了此问题,最近在学习 zig 这门语言,说是能解决这个问题,我便想借着这个机会深入研究下。也是对于函数着色问题的一些自己的尝试,于是便确立了如下目标:

  • 长期目标:实现低侵入式的异步协程框架,服务于操作系统内核
  • 本期目标:实现简单的异步协程运行时 (zig)

后续在调研的过程中,发现 rust 的异步机制,是基于 Future 来实现的,这是一种无栈协程,跟我之前理解的 Go 语言的那种有栈协程还不一样。

第三周 - 学习和实验

后续的两个周,主要是学习文档中的异步协程资料,编写实验代码,验证自己的想法。

第四周 - 代码结合

  • 异步协程运行时 xasync 框架代码编写
    • 基本完成跑通简单测例
    • 同时初步解决了函数着色问题

完成上述四周的工作后,基本实现我在开源操作系统训练营本阶段的目标,符合预期。

xasync 异步协程运行时

使用者角度

我在设计 xasync 异步协程的时候,借鉴了 zig 协程 的设计思路,感觉 zig 协程更容易让使用者理解和减轻负担,那么从使用者的角度出发,什么样的协程用起来才是最舒服的,我认为尽量保持一套代码,只通过一些简单的标记就可以实现同步和异步的切换,是更加友好的协程框架实现方式。下面是我理解的伪代码。

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
var is_async = true // 如果关闭后,底层会走阻塞逻辑

fn read(file) {
if (is_async) {
scheudle(future:run(sys_read(file))) // 生成 future,交给 executor 和 eventloop 调度处理
suspend()
} else {
sys_read(file)
}
}

fn long_time_action() {
read("large file")
sleep(100)
}

fn other_action() {

}

fn main() {
let frame = xasync(long_time_action) // 使用者也可以用 xasync 来标记上层代码是异步的
// xawait(frame) // 需要等待的时候才等待

other_action()
}

通过上面的注释,可以仔细看下调用流程,这是我个人期望的协程框架使用方式的理解。

架构设计

总体设计图

上面是架构设计图,分为前后两部分把 有栈协程无栈协程 结合起来,其中红线理解为前进 蓝线理解为返回,比方说协程切换的前进返回、Future poll 前进和状态返回、协程调度的前进和唤醒的返回等。

目前图中描述的是有三个协程(绿色线程)在需要的场景下不断让出执行权,在异步任务结束后能随时切换到具体的任务上继续执行。这种机制在需要等待返回结果的情况下尤为重要。后续会优化成协程池方便使用。

下面从测例的角度简单剖析下实现代码和原理。

Future

测例代码

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

const Counter = struct {
const Self = @This();
num: u32,
max: u32,

fn init(num: u32, max: u32) Self {
return .{
.num = num,
.max = max,
};
}

fn doCount(ctx: *Context) Result {
const counter = @as(*Counter, @ptrCast(@alignCast(ctx.payload)));
if (counter.num < counter.max) {
std.debug.print("counter num = {}\n", .{counter.num});
counter.num += 1;
return .wait;
} else {
return .{ .done = &counter.num };
}
}

fn doNextCount(result: ?*anyopaque, ctx: *Context) *Future {
var counter = @as(*Counter, @ptrCast(@alignCast(ctx.payload)));

const num = @as(*u32, @ptrCast(@alignCast(result)));
const value = num.*;

counter.num = 0;
counter.max = value + 5;

return run(Counter.doCount, counter);
}
};

test "counter-chain-done" {
const allocator = std.testing.allocator;

var executor = Executor.init(allocator);
defer executor.deinit();

var counter = Counter.init(0, 5);
const fut = runWithAllocator(allocator, Counter.doCount, &counter).chain(Counter.printNum); // 这里支持链式调用

executor.schedule(fut);

executor.run();
}

上面的代码是把一个 Counter 计数器,改成了异步机制,当 num > max 的时候才会终止运行。在实现的时候利用 zig uinon(enum) 的特性,尽量做到了零成本抽象。

支持组合

还支持了 Then 组合操作,因为在封装 Future 代码的时候可能要把原有的阻塞代码拆成多个 Future 逐步执行。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
test "counter-chain-counter" {
const allocator = std.testing.allocator;

var executor = Executor.init(allocator);
defer executor.deinit();

var counter = Counter.init(0, 5);
const fut = runWithAllocator(allocator, Counter.doCount, &counter).chain(Counter.doNextCount); // 这里支持链式调用

executor.schedule(fut);

executor.run();
}

后续还会在 Future 上进行扩展支持 Join 等更多组合操作。

Executor

Executor 中有两个队列:

  • ready_queue: std.ArrayList(*Future) - 调度队列,供调用者放入 Future 任务
  • futs: std.ArrayList(*Future) - 执行队列,实际调度器处理的 Future 任务

Future 先是进入到调度队列,如果调度开始执行后,会从调度队列取出任务放入执行队列,这时候执行队列中可能还有其他未完成的任务,当 Future 结束后会从执行队列中移除,如果执行队列中的所有任务都是等待状态,则 Executor 处于 idle 状态,等待 event_loop 唤醒,具体使用方式在上面的测例代码中已体现。

目前调度策略比较简单,而且没有经过任何优化,后续会不断完善。

Coroutine(绿色线程)

目前已经支持协程间的切换,下面的代码是非对称协程的实现方式:

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
var base_coro: Coroutine = undefined;
var count_coro: Coroutine = undefined;
var count: i32 = 1;

fn addCount() void {
count += 1;
base_coro.resumeFrom(&count_coro);
count += 1;
base_coro.resumeFrom(&count_coro);
count += 1;
base_coro.resumeFrom(&count_coro);
}

test "simple counter suspend and resume coroutine" {
const allocator = std.testing.allocator;

base_coro = try Coroutine.init(allocator, null);
defer base_coro.deinit();
count_coro = try Coroutine.init(allocator, addCount);
defer count_coro.deinit();

try std.testing.expect(1 == count);

count_coro.resumeFrom(&base_coro);
try std.testing.expect(2 == count);

count_coro.resumeFrom(&base_coro);
try std.testing.expect(3 == count);

count_coro.resumeFrom(&base_coro);
try std.testing.expect(4 == count);

std.debug.print("all finished\n", .{});
}

这个测试就是用协程的方式去执行 addCount 所在的 count_coro 协程,在 addCount 中也可以随时切换调用者协程 base_coro,执行原有逻辑。

还支持了函数参数的传递,在上下文切换的时候,不是两个函数的切换,是通过一个中间函数 call,它会根据汇编传过来的参数指针地址,转换成具体的 *Coroutine,再从其中拿出 func_ptr 和 args_ptr,就相当于中间层转发了一下。从下面的代码看目前参数类型都是定死的,有点牵强,目前够用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn call(coro_ptr_int: u64) void {
const coro: *Coroutine = @ptrFromInt(coro_ptr_int);

std.debug.print("current coro address: 0x{x}\n", .{@intFromPtr(coro)});

if (coro.frame.func_ptr != null) {
if (coro.frame.args_ptr != null) {
const func_ptr = @as(*const fn (*const anyopaque) void, @ptrCast(coro.frame.func_ptr.?));
const args_ptr = coro.frame.args_ptr.?;
func_ptr(args_ptr);
} else {
const func_ptr = @as(*const fn () void, @ptrCast(coro.frame.func_ptr.?));
func_ptr();
}
} else {
std.debug.print("the func pointer is null\n", .{});
}
}

Eventloop

事件响应机制也就是 eventloop (reactor 模型),其实是所有异步协程实现的底层支持,我甚至认为就算不用异步,只用事件机制和回调的方式也能做到高性能。这一部分在本期训练营并没有深入的去学习,目前只是实现了一个大概。如果这层封装好了,做成一层统一的抽象去处理 epoll、io_uring、iocp、kqueue 以及中断信号量等,也将会有很大的收获,给自己挖个坑,明年把这部分填上。

eventloop 的核心代码就是用一个循环,不停的调用系统需要等待的函数,等待系统给出响应,这里用的是 epoll_wait,这些系统提供的函数其实在操作系统里面都有自己的实现,一般性能都比较高,而且可以阻塞也可以非阻塞。当系统给出响应后,再触发回调去唤醒 Executor。

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 fn poll(self: *Self, timeout_ms: i32) !usize {
try self.events.resize(16); // 预分配事件数组,先这么写

const n = std.posix.epoll_wait(self.epfd, self.events.items, timeout_ms);

for (self.events.items[0..n]) |event| {
const fd = event.data.fd;

if (self.callbacks.get(fd)) |callback| {
if (event.events & std.posix.system.EPOLL.IN != 0) {
var buf: [8]u8 = undefined;
_ = std.posix.read(fd, &buf) catch {}; // 这里目前只处理了 timer 的情况

if (callback.callback_fn) |func| {
func(callback.user_data);
}
}
}
}

return n;
}

pub fn run(self: *Self) !void {
self.running = true;

while (self.running) {
_ = try self.poll(100); // 100ms 超时
}
}
}

整体组合 xasync

把上面各部分组合起来,看看能不能达到预期效果。

Timer

这部分注册一个 TimerHandle 到 event 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
32
33
34
35
36
37
38
39
40
41
const Timer = struct {
const Self = @This();

handle: TimerHandle,
completed: bool = false,
waker: ?*const Waker = null,

fn init(nanoseconds: u64) !Self {
const handle = try TimerHandle.init(&global_event_loop, nanoseconds); // 注册给 event_loop
return .{
.handle = handle,
};
}

fn deinit(self: *Self) void {
self.handle.deinit();
}

fn timerCompletedCallback(data: ?*anyopaque) void { // event_loop 回调
if (data) |ptr| {
const timer: *Timer = @ptrCast(@alignCast(ptr));
timer.completed = true;
std.debug.print("timer callback completed!\n", .{});
if (timer.waker) |waker| {
waker.wake(); // 唤醒
}
}
}

// future poll
fn poll(ctx: *Context) Result {
const timer: *Timer = @ptrCast(@alignCast(ctx.payload));
if (timer.completed) {
std.debug.print("poll timer is completed\n", .{});
return .{ .done = null };
} else {
timer.waker = ctx.waker;
return .wait;
}
}
};

Sleep

这部分把 Timer 包装成 Future

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
fn sleep(nanoseconds: u64) void {
std.debug.print("sleep comes in\n", .{});
if (!sys_is_block) {
const timer_ptr = global_runtime.allocator.create(Timer) catch unreachable;
timer_ptr.* = Timer.init(nanoseconds) catch unreachable;

const callback = EventCallback{
.callback_fn = Timer.timerCompletedCallback,
.user_data = timer_ptr,
};
timer_ptr.handle.setCallback(callback) catch unreachable;

const timer_fut = future.runWithAllocator(global_runtime.allocator, Timer.poll, timer_ptr).chain(struct {
fn thenFn(_: ?*anyopaque, ctx: *Context) *Future {
const timer = @as(*Timer, @ptrCast(@alignCast(ctx.payload)));
ctx.allocator.destroy(timer);
return future.done(null);
}
}.thenFn);

global_runtime.executor.schedule(timer_fut);

global_runtime.switchTaskToBase(); // 类似 suspend - 这个地方实现还有点歧义
// global_runtime.switchToExecutor(); // 如果需要等待返回结果则需要切换到 executor 等待其 resume 回来
} else {
std.Thread.sleep(nanoseconds);
}
}

fn delay() void {
std.debug.print("delay comes in\n", .{});
sleep(5 * std.time.ns_per_s);
}

main

整合完毕后对于使用者来说,代码如下:

1
2
3
xasync(delay);
// xawait(); // 需要等待的时候开启
std.debug.print("hello xasync\n", .{});

运行效果

不等待完成

1
2
3
4
5
6
7
delay comes in
sleep comes in
hello xasync - 注意这里,没有等待 timer 异步执行结束,而是直接返回
timer callback completed!
poll timer is completed - 注意这里,timer 结束了
main will quit
event loop quit

等待完成

1
2
3
4
5
6
7
delay comes in
sleep comes in
timer callback completed!
poll timer is completed
hello xasync - 注意这里,虽然底层是异步协程执行,但是这里等待 timer 执行完毕才打印
main will quit
event loop quit

总结

从目前执行效果和 API 的调用方式看符合预期,基本达成了本期的目标:实现简单的异步协程运行时 (zig),按照这种方式解决函数着色问题是有希望的。

虽然本期目标基本达成,但是中间学习的过程中还是有很多技术细节没有完全搞懂,有些学习资料没有完全看完,后续还要继续努力。

后续规划

  • 参数和返回值的类型支持且能自动推导
  • 支持线程池 thread pool
  • eventloop 完善
  • 是否后台调度支持用户配置 - 现在需要改代码来实现
  • 支持 rust 调用
  • 封装 asyncio
  • 集成到 arceos/rcore 中
  • 性能对比测试

答疑和思考

为什么要用 zig 写

  • 没有任何原因,个人偏好,peace & love.

实现代码在哪里