. Rust 异步机制:从 Future 到执行器

从阻塞与非阻塞 I/O 出发,剖析 Rust 中 Future 与 Waker 的轮询协议、async/await 的状态机编译、Pin 与自引用结构,以及执行器与反应器的协作.

在系统编程中,异步编程让单个操作系统线程能够交替推进多个处于等待状态的计算任务. Rust 采用的 async async / await await 机制不依赖语言内置的运行时或垃圾回收器, 而是将异步函数在编译期变换为实现了 Future Future 特型的状态机, 并通过 Pin Pin 在类型系统层面约束跨挂起点借用带来的自引用结构.

许多介绍异步的文章要么停留在 tokio::spawn tokio::spawn 的接口用法, 要么直接贴出标准库 Pin Pin 与 RawWakerVTable RawWakerVTable 的源码. 前者难以解释为什么没有执行器时 Future Future 一步也不会走、为什么跨 .await .await 持有互斥锁会出问题; 后者则容易让人迷失在指针包装与虚表细节里,看不见每个构造要解决的具体矛盾.

这组文章按照“问题—约束—方案—实现—局限”的线索,把 Rust 的异步机制拆解为五个递进的切面: 为什么需要无栈协程与拉取模型, Future Future 与 Waker Waker 如何约定轮询与唤醒, 编译器如何把顺序代码改写为枚举状态机,跨挂起点的借用为什么迫使类型系统引入 Pin Pin , 最后执行器与 I/O 反应器怎样把这些部件串成完整的事件循环.

1 为什么需要异步:阻塞、回调与无栈协程 [model]

比较阻塞线程、回调状态机、有栈协程与无栈协程的资源模型,解释 Rust 选择基于轮询的无栈协程的原因.

处理高并发 I/O 时,程序的大部分时间都在等待外部事件: 等待网卡收到数据包,等待磁盘完成读取,或者等待定时器到期. 异步机制要解决的核心问题是:当一个任务因为等待外部事件而无法继续推进时, 怎样让出当前线程的计算资源,并在事件就绪后准确恢复到挂起前的位置.

1.1 阻塞线程与协作式切换

最直观的并发模型是“一个连接一个操作系统线程”. 当线程调用阻塞式的 read read 系统调用而数据尚未到达时, 操作系统内核会将该线程移出运行队列,保存其寄存器与栈指针,再切换到其他就绪线程.

这种抢占式调度的优点是代码保持顺序结构:局部变量留在调用栈上,无需程序员手动管理. 但每个操作系统线程都需要独立的内核对象与固定或按页分配的调用栈(在 Linux 上默认通常为数兆字节,即便只统计实际触碰的物理页,每个活跃栈仍至少占用数千字节), 而且每次上下文切换都要跨越用户态与内核态边界. 当同时维持数万个大多处于空闲等待状态的连接时,内存与调度开销会成为瓶颈.

如果不在内核里挂起线程,而是让同一个线程在用户态交替服务多个任务, 时间轴上的行为就会从“等待时阻塞整个线程”变成“发起非阻塞操作后立即切换到其他就绪任务”:

上图中,单线程异步模型不会缩短单次 I/O 等待的物理时长,也不会让 𝐵 2 与 𝐴 2 在同一时刻并行执行; 它省下的是为每个等待中的任务维持独立内核线程与独立调用栈的成本. 要实现这种用户态切换,关键在于:任务挂起时,原本保存在线程调用栈上的局部变量和执行位置放去哪里?

1.2 有栈协程与无栈协程

围绕“执行状态保存在哪里”,程序语言演化出了两条路线:有栈协程与无栈协程.

有栈协程(如 Go 的 goroutine、Erlang 的进程或 C++ 的 fiber)为每个任务分配一块独立的用户态栈. 切换任务时,运行时只需切换栈指针与少量寄存器; 任务可以在任意深度的普通函数调用中直接挂起,因为整条调用链的栈帧都完好地保存在该任务私有的堆内存栈上.

然而,独立栈与系统级语言的约束存在两处冲突:

  1. 栈大小的预测难题:编译器在编译一个模块时,往往无法静态算出某个协程在运行期会调用多深的函数(尤其涉及动态分发或外部函数接口时). 如果预先分配固定大小的栈,设得太大浪费内存,设得太小则会栈溢出; 如果像 Go 那样支持运行时动态扩缩栈或分段栈,则需要运行时介入栈检查,并且在调用 C 语言函数时必须切换到系统栈,还会使指向栈上变量的原始指针在栈搬迁后失效.
  2. 零运行时开销的目标:Rust 需要运行在没有操作系统、甚至没有堆分配器的裸机( no_std no_std )环境中. 强制引入管理用户态栈与调度器的语言级运行时,会违背“不为未使用的抽象付费”的设计约束.

因此,Rust 选择了无栈协程:不为任务分配独立的调用栈, 而是由编译器分析函数中哪些局部变量需要跨越挂起点( .await .await )存活, 将这些变量连同当前执行位置一起打包成一个普通的 Rust 结构体(状态机).

在无栈协程中,当任务正在执行计算时,它借用当前工作线程的普通系统栈; 一旦遇到需要等待的挂起点,函数就会直接 return return 返回到调用者手中, 把线程栈清空让给下一个任务,只将跨挂起点存活的状态留在那个编译期大小确定的状态机对象里.

关于早期 Rust 为什么放弃了有栈绿色线程以及分段栈、栈拷贝各自的技术障碍,详见下面的附注:

Note 1.2.1 为什么 Rust 在 1.0 前移除了绿色线程 [green-threads-and-stack-management]

1.3 推送模型与拉取模型

即便同样采用无栈状态机,JavaScript、C# 等语言与 Rust 在驱动方式上也有区别: 前者采用推送模型,而 Rust 采用拉取模型.

在 JavaScript 中,调用一个 async async 函数会立即开始执行函数体,直到遇到第一个 await await ; 同时,底层的事件循环持有挂起时的回调闭包(续延),当 I/O 完成时,运行时主动调用该回调并把结果作为参数推进去. 这种模式要求每次挂起时,都能把“下一步要执行的闭包”注册到事件源上,通常伴随着闭包的堆分配与引用计数管理.

Rust 反过来采用了拉取模型: 调用 async fn async fn 不会执行函数体内的任何一行用户代码,它只是在内存中构造并返回一个处于初始状态的状态机(即实现了 Future Future 的值). 只有当外部驱动者(通常是执行器,或者外层状态机)主动对它调用 poll poll 方法拉取一次时,它才会往下走一步.

拉取模型给 Rust 带来了三项直接后果:

  1. 组合状态机的零分配内联:当异步函数 𝐴 调用并 .await .await 异步函数 𝐵 时,𝐵 的状态机可以直接作为字段内联在 𝐴 的状态机枚举中. 推进 𝐴 时只是普通的方法调用 b.poll(cx) b.poll(cx) ,不需要在每一层 .await .await 处动态分配回调闭包.
  2. 惰性求值:如果不把 Future Future 交给执行器或对其 .await .await ,它就只是一个静止的结构体,不会产生任何副作用.
  3. 同步取消(drop 即取消):在推送模型中,任务把回调交给底层 I/O 后,若想取消任务,必须显式传递取消令牌(如 CancellationToken CancellationToken 或 AbortSignal AbortSignal )去阻止回调生效; 而在拉取模型中, Future Future 是被执行器持有的被动状态机——只要执行器不再 poll poll 它并直接将其 drop drop ,整个状态机树连同其持有的资源就会立即按 Rust 的析构规则释放.

这种设计把“推进状态计算”( poll poll )和“通知状态可推进”( wake wake )拆成了两条正交的通道. 从更宏观的语言设计视角来看,这种将控制流具体化为状态机特型并提供多层级操作方式的手法,贯穿了 Rust 的迭代、错误处理与异步三大机制:

Remark 1.3.1 控制流作用的四个语域与迭代–异步对偶 [registers-of-rust-and-control-flow-effects]

下一篇将直接打开标准库中仅有的十余行核心定义,看 Future Future 与 Waker Waker 怎样用最少的类型接口定下这条契约.

2 Future 与 Waker:基于轮询的异步契约 [future-and-waker]

剖析标准库中 Future、Poll、Context 与 Waker 的类型定义、轮询协议不变量,以及 RawWaker 手工虚表的零成本擦除机制.

3 async/await 脱糖:状态机编译 [state-machine]

追踪 Rust 编译器怎样按挂起点切分控制流、分析跨挂起点存活变量,并将 async 函数变换为实现了 Future 的枚举状态机.

4 Pin 与自引用结构:为什么状态机不能随便移动 [pin-and-self-reference]

从跨挂起点的局部引用出发,图解自引用状态机在按位移动后的悬垂指针问题,并阐明 Pin、Unpin 与结构化投影的安全边界.

5 执行器与反应器:从零拼出异步运行时 [executor-and-reactor]

实现最小可运行的 block_on 与多任务队列执行器,剖析 epoll/kqueue/IOCP 反应器怎样通过 Waker 把操作系统事件接入事件循环.

6 总结与延伸阅读 [summary]

串联 Rust 异步机制从编译期变换到运行期调度的全景图,汇总核心术语与延伸资料.