2026年春夏开源操作系统训练营总结-陈玺州

本文最后更新于 2026年6月21日 下午

一种基于 AI 辅助的 Tokio 异步状态机动态跟踪分析

这次训练营后半段,我把主要精力放在一个 Rust 异步爬虫项目上:不是单纯让它“能跑”,而是想看清楚 async/await 在运行时到底怎样被推进。Tokio、FuturesUnorderedreqwest、文件系统 I/O 这些组件平时看起来都是比较高层的抽象,但它们真正执行时仍然会落到状态机、寄存器、栈帧、堆上捕获变量和 Waker 调度上。

因此,我这次的总结不只记录“我实现了什么”,也记录“我是如何得到结论的”。整体技术路径可以概括为三步:

  1. 获得原始数据;
  2. 用 AI 辅助分析原始数据;
  3. 人工总结、校验并提炼 AI 的分析,生成最后报告。

这个流程最大的优点是:它暴露了 AI 得出结论的完整路径。最终报告不是一段凭空生成的自然语言,而是可以沿着命令、断点、日志、寄存器、内存地址、调用栈逐级追溯回去的分析结果。换句话说,AI 在这里不是“黑箱裁判”,而更像一个帮我整理证据、生成假设、发现结构的研究助手。


项目背景

项目仓库是一个用 Rust 编写的并发网页下载器。同步版本比较直观:遍历任务列表,依次请求网页并写入文件。而异步版本中,核心路径变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
src/bin/async.rs
-> download_async_with_tasks_limited(...)
-> rt.block_on(download_async_inner_with_tasks_limited(...))
-> tokio::fs::create_dir_all(&output_dir).await
-> stream::iter(tasks.map(|task| async move { ... }))
-> buffer_unordered(concurrency)
-> collect::<Vec<_>>().await
-> task.run_async_to(output).await
-> downloader_async(&self.url, output).await
-> client.get(url).send().await
-> response.bytes().await
-> tokio::fs::create_dir_all(parent).await
-> tokio::fs::write(output, bytes).await

表面上看,代码只是多了几个 .await。但真正的问题是:每一个 .await 暂停时,局部变量保存在哪里?Pending 后是谁把任务重新唤醒?多个并发任务同时存在时,它们的状态机是如何区分的?

这些问题很适合用训练营里学到的底层视角去拆。


第一步:获得原始数据

我先把程序编译为带调试信息的开发版本:

1
cargo build --bin async

然后用 rust-gdb 运行异步版本,并让 GDB 脚本自动设置断点、打印寄存器、局部变量和调用栈:

1
timeout 35s rust-gdb -q target/debug/async -x gdb_scripts/gdb_track.gdb

为了降低一开始的状态空间,我先把并发数限制为 1。这样一次只推进一个下载任务,更容易观察第一个 future 从创建、Pending、被唤醒,到继续执行的完整过程。随后我又追加了并发数为 4 的实验,用来观察多个同类型 async 状态机同时存在时的内存布局。

断点主要设置在几个关键 .await 位置:

  • download_async.rs:16:外层 create_dir_all(&output_dir).await
  • download_async.rs:25:每个任务内部的 task.run_async_to(output).await
  • downloader_async.rs:13-14reqwestsend().await
  • downloader_async.rs:16-17response.bytes().await
  • downloader_async.rs:20:内部 create_dir_all(parent).await
  • downloader_async.rs:23:最终的 write(output, bytes).await

这一步生成的材料包括两类:

  • GDB 原始日志:保留每次断点命中的寄存器、局部变量、backtrace;
  • 中文整理日志:把原始日志中反复出现的地址、变量、状态机层级和调度行为整理出来。

这一步很关键。没有原始日志,AI 很容易只复述 Rust async 的通用知识;有了原始数据,分析就必须回到具体证据上。


第二步:AI 辅助分析

原始 GDB 日志很长,直接人工读会非常慢。我的做法不是让 AI 一次性“总结全部”,而是分阶段给它任务。

第一类任务是结构化整理。比如让 AI 把每次断点命中按源码位置归类,区分这是外层调度 future、per-task async block,还是 downloader_async 内部 future。这样可以先建立一张“源码行 -> 状态机层级 -> 典型寄存器/变量”的表。

第二类任务是假设生成。比如在 x86_64 SysV ABI 下,函数前几个参数常通过 rdirsirdx 等寄存器传递;而 Future::poll 在理想情况下可以理解为接收 Pin<&mut Future>&mut Context。AI 可以据此提出某个地址可能对应 future frame、poll context 或捕获变量区。

第三类任务是证据对照。我不会直接接受“某个寄存器一定是什么”的结论,而是要求它把判断依据列出来。例如:

  • rdx = 0xc,恰好对应 "output/async" 的字节长度 12;
  • rcx = 0x636e7973612f7475 按小端序可读出 "ut/async" 片段;
  • rdi = 0x555556351ce0 附近内存能读到 "output/async"
  • _task_context = 0x7fffffffaff8 在 backtrace 中多次作为外层 cx 出现。

这样 AI 的作用就从“给结论”变成了“帮我建立可检查的推理链”。它可以快速发现日志中的重复模式,但每一个关键结论都要能回到 GDB 输出本身。


第三步:人工提炼与校验

AI 生成初步分析后,我再做人工筛选。这里最重要的是区分“能稳定对应的事实”和“只能作为临时现象观察的值”。

例如,下面这些对应关系比较可靠:

  • 0x7fffffffaff8:外层 poll Context,在 backtrace 中多次出现;
  • 0x555556351ce0:外层 output_dir/create_dir_all 相关状态区,内存里能看到 "output/async"
  • 0x555556355d10:per-task async block 的捕获变量区,包含 task/url/output
  • 0x55555642e850bytes.ptr,即响应体数据缓冲区;
  • 110683:本次抓到的响应体长度;
  • 0x555556303788bytes 的 vtable,GDB 显示为 bytes::bytes::PROMOTABLE_EVEN_VTABLE

但也有一些值不能强行解释。例如某些断点处的 rdi = 0x517,或者恢复点上的 rdi/rsi/rdx = 0x0,更多只是编译器生成代码中的临时值。源码行断点并不等价于精确停在 Future::poll 函数入口,所以寄存器的语义会随着 codegen 位置变化。

这一点也是 AI 辅助分析里最容易出错的地方:模型会倾向于把所有值都解释得很完整,但底层调试恰恰需要承认“不知道”。最终报告里必须保留这种边界感,明确哪些结论是稳定证据支持的,哪些只是不能过度解读的临时现象。


核心技术结论

通过单并发跟踪,可以看到 Rust async/await 在这个程序中生成的是一组嵌套状态机:

1
2
3
4
5
6
外层下载调度状态机
-> collect / buffer_unordered / FuturesUnordered 状态机
-> per-task async move 状态机
-> CrawlTask::run_async_to 状态机
-> downloader_async 状态机
-> reqwest send / bytes / tokio fs 子状态机

每个 .await 的本质过程可以概括为:

  1. 当前 future 把后续还要用的局部变量保存到自己的状态机 frame;
  2. poll 子 future;
  3. 子 future 如果返回 Poll::Pending,当前 future 也把控制权还给 runtime;
  4. I/O ready 后,之前注册的 Waker 被触发;
  5. runtime 再次 poll 外层 future;
  6. 状态机根据内部状态跳回 .await 后面的源码位置继续执行。

在日志中,这个过程有非常清楚的对应:

  • download_async.rs:16 -> 17:外层 create_dir_all.await 返回;
  • download_async.rs:25 反复命中:per-task future 在 reqwest send/bytes 等待期间被多次 poll;
  • downloader_async.rs:13-14 -> 15send().await 返回,拿到 Response
  • downloader_async.rs:16-17 -> 19bytes().await 返回,可以看到 Bytesptr/len/vtable
  • downloader_async.rs:23write(output, bytes).await 使用此前保存在状态机中的 outputbytes

追加的并发 4 实验进一步说明:同一种 async block 状态机可以同时存在多个实例。前四个任务分别对应不同的捕获变量区:

1
2
3
4
北京大学       -> 0x555556355d10
清华大学 -> 0x555556353f10
中国人民大学 -> 0x555556353b00
北京师范大学 -> 0x555556340590

后续调度时,日志中可以看到 rsi 在这四个地址之间轮换。这说明 FuturesUnordered 同时保存了多个子 future 实例;当某个任务因为网络或文件 I/O 返回 Pending,它自己的状态留在对应的捕获区中,等 Waker 重新唤醒后再被 poll。

这比单纯阅读文档更直观:异步并发不是“凭空并行”,而是一组状态机实例在 runtime 调度下不断保存、恢复和切换。


如何更好地使用 AI

这次实验给我最大的启发,不是“AI 可以替我写报告”,而是“AI 可以把复杂技术分析变成一条可追踪的流水线”。

我总结出几条更适合底层系统分析的 AI 使用方式:

  1. 先有原始数据,再让 AI 说话。 让 AI 直接解释 async/await,它会给出通用答案;把 GDB 日志、断点位置、寄存器和 backtrace 给它,它才会被迫面向事实分析。

  2. 把问题拆成可验证的小块。 不问“帮我分析 Rust 异步状态机”,而是问“这个断点处 rdi/rsi/rdx 分别可能对应什么,证据是什么,哪些不能确定”。

  3. 要求 AI 输出证据链,而不是只输出结论。 最有价值的回答不是“这是 future frame”,而是“这个地址反复出现在同一任务的 poll 中,并且附近内存能对应到 task.url/output,因此它很可能是捕获变量区”。

  4. 让 AI 主动标注不确定性。 底层调试中,错误的确定性很危险。对于源码行断点、编译器临时值、优化导致的寄存器复用,必须允许结论停在“不能稳定对应”。

  5. 最终报告由人来收束。 AI 擅长归纳模式和整理材料,但报告要讲清楚问题意识、实验设计、证据强度和结论边界,这些仍然需要人工判断。

这种方式的优点在于可解释性强:从最后一句结论往回追,可以追到 AI 的分析摘要,再追到某个断点命中,再追到原始 GDB 日志。AI 的价值不是替代推理,而是把推理过程显性化、结构化。


训练营后的反思

操作系统训练营让我越来越意识到,底层系统的学习不能只停留在“功能实现”。能写出代码是一层,能解释它为什么这样运行是另一层;能用工具观察它,则会再往前走一步。

这次 Tokio 异步状态机跟踪,本质上也是一次操作系统式的训练:从抽象接口一路向下追到 runtime、future frame、waker、寄存器和内存布局。它让我重新理解了 Rust 异步模型,也让我更清楚地看到 AI 在复杂工程学习中的位置。

我不希望 AI 只是生成一份看起来完整的文字,而是希望它参与到一个可复现、可校验的分析流程里。对于底层系统而言,这一点尤其重要。因为系统软件里真正有价值的结论,往往不是“听起来合理”,而是能够被日志、代码和机器状态共同支撑。


2026年春夏开源操作系统训练营总结-陈玺州
https://chenxizhou233.github.io/posts/1c1778da.html
作者
Xizhou Chen
发布于
2026年6月20日
许可协议