异步调试设计与实现#
背景介绍#
Rust 的 async 函数会被编译器转成状态机:函数调用时只创建异步实例,真正的执行发生在实例被 poll 时——执行器每 poll 一次,状态机前进一小段,遇到未就绪的 .await 就暂停并交出控制权。这使常规 GDB 给出的物理调用栈无法完整反映异步函数之间的调用关系:poll 返回后本次的物理栈帧即被销毁,实例之后可能在其他地方被继续 poll,此前的逻辑调用关系随之丢失。
具体来说,物理调用栈存在三方面的问题:
- 不完整:每次 poll 只推进状态机一小段,物理栈上只保留当前 poll 的片段。完整的逻辑调用链从未同时存在于物理栈上;
- 跨协程:同一个异步实例可以被先后多次 poll,同一逻辑调用链的不同片段出现在不同的物理栈上;
- 混杂:物理栈中混入执行器的调度帧,与业务逻辑的调用关系无关。
例如:nonleaf 协程 .await 了 async_fn_leaf,后者又 .await 了 another_branch。GDB 停住时,物理栈上只有「执行器 → 某个协程 poll 函数的当前片段」。至于 nonleaf 在等 async_fn_leaf、async_fn_leaf 又在等 another_branch 这层等待关系,在物理栈上完全看不到。
因此,调试异步程序需要回答两个问题:「这个协程正在等谁」,以及「控制流是怎样走到这里的」。前者称为等待边(await edge,协程间 .await 关系),后者称为调用边(call edge,协程内同步函数间的调用关系)。
相关工作#
常见的异步调试/观测方案大多依赖特定运行时或需要修改源代码:
| 方案 | 依赖特定运行时 | 修改源代码 | 输出形式 | 说明 |
|---|---|---|---|---|
| tokio-console | 是(仅支持 tokio) | 是(引入 console-subscriber、开启 tokio_unstable) | 交互式终端界面 | Tokio 官方任务观测工具 |
| async-backtrace | 否 | 是(逐函数添加 #[framed] 属性) | 文本逻辑栈 | 异步函数的逻辑栈跟踪库 |
而不改源码的动态跟踪普遍被认为很困难:编译器不会为 .await 生成足够完整的调试信息,poll 函数地址与状态机结构布局难以从调试信息中定位。
团队的前序工作(2024–2025,code-debug_Asynchronous-trace)已提出关键观察:要解释一次 Rust 异步执行,仅恢复等待边是不够的,还必须同时恢复调用边。但该实现采取「调试前全量插桩」策略——离线解析 DWARF、预计算依赖树、对所有相关函数提前设断点。这带来两个问题:
- 开销与程序规模成正比:哪怕只调试一条路径,也要为全部相关函数插桩;
- 基于类型偏移量的识别方式,无法区分同一类型的多个并发实例。
Async-Debuger 选择了动态路线:在调试器中直接从 GDB 获取协程信息,不依赖特定异步运行时,通常也无需修改源代码(被编译优化掉的关键函数可添加禁止编译优化的属性,以保证协程信息完整可见)。同时针对前序工作的两个问题,把「调试前全量插桩」改为「运行时按需发现」。
设计方案#
总体思路#
Async-Debuger 旨在重建异步调用逻辑:在物理调用栈的基础上,进一步给出由异步函数、同步函数组成的逻辑调用栈。方案在 GDB 内运行 Python 脚本,动态读取协程状态机的运行时信息,据此恢复等待边与调用边,组成逻辑调用树。
整个流程不依赖任何异步运行时的私有元数据,只依赖编译器为 async 状态机生成的标准结构(__awaitee 字段),因此对 tokio、embassy 等不同运行时同样适用。
白名单与运行时按需发现#
白名单是一份允许追踪的函数列表:先通过静态符号表分析,从全部符号中筛选出 async poll 函数与同步辅助函数,把追踪范围从「全量」收窄到目标 crate。用户只需勾选关心的 crate,不相关代码不产生任何开销。
追踪根是逻辑调用树的起点。用户通过 ardb-trace <符号> 指定(或由 ardb-infer-trace-root 从当前物理栈自动推断最外层 async 函数)。调试器从追踪根协程的状态机出发,直接读取其 __awaitee 字段——编译器为每个 async 状态机生成的、指向当前正在等待的子 Future 的指针——顺藤摸瓜发现被等待的子协程,再为它们安装追踪断点。
整个过程只在程序实际执行到对应路径时触发:未被触达的分支不安装断点、零开销。这正解决了前序工作「全量插桩开销与程序规模成正比」的问题。
协程实例识别#
同一函数可能被创建出多个并发实例(例如同时 spawn 两个相同的协程),仅靠函数名无法区分。Async-Debuger 使用 (poll_sym, env_ptr) 二元组作为实例唯一标识:poll_sym(poll 函数符号)区分 Future 类型,env_ptr(状态机环境指针)区分同一类型的不同实例。
每次 poll 事件发生时,以该二元组查询全局实例表:命中则复用编号并累加 poll 次数,未命中则分配新编号。这个编号即面板上的 CID(协程实例编号),保证同一实例在多次暂停、多次 poll 之间的数据连续累积。
等待边与调用边#
- 等待边:协程 A 的状态机里
__awaitee指向协程 B,即 A 正在等 B。读出这一指针,就恢复了「谁在等谁」; - 调用边:协程 poll 执行过程中调用同步函数的关系,由调试器在暂停时沿物理栈逐帧分类(async 帧 / sync 帧)得到。
两类边叠加,构成树形结构:async 协程节点通过等待边相连,sync 同步函数节点通过调用边挂在协程之下。
此外,程序可能停在协程尚未 poll 的位置(例如 Future 构造处),此时逻辑树为空在语义上是正确的。调试器会回退遍历物理调用栈,逐帧按 async/sync 分类构建一条瞬态链,保证任意停止位置都能看到当前执行脉络。
可视化:Async Inspector 面板#
前序工作的跟踪结果只能在终端打印文本,对于包含几十个协程的程序几乎无法阅读。Async-Debuger 实现为 VS Code 插件,采用三层架构:
- 数据层:GDB Python 脚本收集协程状态,输出 JSON 快照;
- 传输层:调试适配器(DAP 协议)在 VS Code 与 GDB 之间转发快照与命令;
- 展示层:Async Inspector 面板(Webview)以树形图渲染。
面板左侧为逻辑调用树:红色节点是 async 协程,蓝色节点是同步函数;实线是等待边,虚线是调用边;节点标注 CID、poll 次数与运行状态。面板右侧为白名单区,支持按 crate 分组勾选、生成与应用白名单。整个工作流(启动 → Gen Whitelist → 勾选 crate → Trace → 继续执行 → 快照刷新)都在 VS Code 界面内完成,与原生 Call Stack 形成双轨协同。
跨特权级异步跟踪#
调试 Rust 异步操作系统时,异步跟踪与跨特权级调试(内核态/用户态切换时的断点组与符号表管理)会发生冲突:特权级切换会清空异步跟踪积累的全部状态。
Async-Debuger 将两者融合:切换前序列化保存白名单、追踪记录、协程状态等四类数据,切换后按序恢复;白名单按断点组分组管理,切组时自动重建;以内核协程为锚点,其 poll 驱动用户任务直到完成,让一棵调用树贯穿内核态与用户态两个特权级。
开发历程#
- 2024–2025:完成 Rust 异步跟踪方案的设计与实现(code-debug_Asynchronous-trace),验证了等待边 + 调用边的思路,采用调试前全量插桩策略;
- 2025–2026:针对开销与实例识别问题,将全量插桩改为运行时按需发现;实现 VS Code 调试插件与 Async Inspector 面板(async-debug);
- 2025–2026:与跨特权级调试融合,实现跨特权级异步跟踪,可调试 Rust 异步操作系统(async-debug)。
验证#
| 验证目标 | 内容 | 结论 |
|---|---|---|
| 状态机逻辑 | 37 个单元测试 + 4 个集成测试场景 | 覆盖正常转换路径、方向属性校验与异常路径,全部通过 |
| embassy | 嵌入式异步运行时上的异步跟踪 | 三层嵌套等待链完整恢复,验证运行时无关性 |
| StarryOS | 组件化 OS 上的跨特权级调试 | 内核初始化到动态启动用户程序全程自动切换,无需手动干预 |
| Async-os | 异步微内核上的跨特权级异步跟踪 | 一棵调用树贯穿内核态与用户态 |
embassy。 在 embassy(面向嵌入式设备的 Rust 异步运行时)的 tick 示例上验证:成功恢复 main_task → run_task → timer::poll 的三层嵌套等待链,最外层 poll 1 次即完成、内层 poll 3 次后仍在等待,符合计时器驱动的异步执行模式。embassy 的自定义执行器与 tokio 内部结构截然不同,工具仍能正确追踪,证明追踪能力只依赖编译器生成的标准状态机结构,不依赖任何特定运行时。
StarryOS。 在组件化 Rust 操作系统 StarryOS 上完成跨特权级全流程调试验证:QEMU 启动 → 内核初始化 → 进入 shell → 动态启动用户程序 → 系统调用 → 断点组自动切换,全程无需手动干预,验证了调试器对组件化 OS 的通用性。
Async-os。 在 Rust 异步微内核 Async-os 上,以管道读写测试 pipetest 为目标完成跨特权级异步追踪:同一棵调用树包含内核协程与用户协程,树根始终是内核协程 user_task_top,其 poll 计数在多次内核↔用户切换间从 63 连续累积至 87;停在协程构造点时树由物理栈回退链构成、不为空;用户协程 reader 被 poll 后以独立 CID 出现在树上。
局限与后续工作#
- 编译优化会消除符号。 被编译优化掉、但又比较重要的函数(如 LTO 内联),需要为其添加禁止编译优化的属性(
#[inline(never)]),否则协程信息不完整。 - 跨特权级跟踪性能。 跨特权级异步跟踪的功能已基本实现,但特权级切换时需要保存/恢复跟踪状态并重建断点,且切换方向判断依赖逐指令单步,跟踪的时间消耗很大。后续计划用 GDB
finish命令替代逐指令单步,并引入切换冷却期机制。 - 多 CPU 支持。 追踪方法本身不依赖单 CPU 假设——影子栈与各 CPU 的物理栈逐一对应,方法上可直接推广到多核场景;多核配置下的实际验证留作后续工作。
- 调试配置自动推导。 目前调试配置(断点组、地址范围等)需手工编写,后续计划通过解析 ELF 符号表和链接脚本自动推导。