Rust Future 运行原理:从 IO Model 到 epoll 的完整链路
本文梳理 Rust 异步系统的核心设计与运行机制:从操作系统 I/O 模型演进推导拉式状态机的必然性,解析
Pin与Waker的底层约束,剖析最小运行时实现与 Tokio 的epoll驱动机制,并分析取消安全与生命周期边界。
一句话总纲
Future 是一个可以被“非阻塞拉取”的状态机;Executor 负责按需拉取;Waker 是底层内核或硬件通知 Executor“可以再来拉我了”的反向挂号凭证;Pin 保证这个状态机在内存里不被挪动。
理解这四点,需要先回到操作系统的 I/O 与调度机制。
第零层:第一性原理——为什么必须是“拉式状态机”?
1. 物理现实:巨大的时钟周期差距
计算机系统中的计算与网络 I/O 存在巨大的速度差异:
- CPU 执行一条指令:约 0.3 纳秒;
- 发起一次跨国网络请求(RTT):约 150 毫秒 = 150,000,000 纳秒。
等待一个网络数据包的时间,CPU 可以执行数亿次时钟周期。如果让操作系统线程同步阻塞等待 I/O,会造成显著的 CPU 算力与线程资源浪费。
2. 操作系统 I/O 模型的演进
操作系统的 I/O 设计经历了四种典型演进:
① 阻塞 I/O (BIO)
线程调用 read() ──> 内核挂起线程 ──> 硬件数据到达 ──> 唤醒线程
代价:1 个连接 = 1 个 OS 线程(几 MB 栈内存 + 上下文切换开销),无法支撑百万并发。
② 非阻塞轮询 (NIO)
线程循环 read() ──> 没数据立刻返回 EWOULDBLOCK ──> 疯狂占用 100% CPU 空转。
③ 多路复用 (I/O Multiplexing: epoll/kqueue) 【就绪通知模型(Readiness)】
线程向内核注册 10 万个 fd ──> 线程调用 epoll_wait 睡眠
──> 哪个 fd 就绪内核就唤醒线程 ──> 线程主动调用 read(fd) 提取数据。
★ 特点:由应用程序自己主动“拉取(Pull)”数据。
④ 异步完成 I/O (AIO: io_uring / Windows IOCP) 【完成通知模型(Completion)】
应用程序提前准备一块内存 buffer 借给内核 ──> 内核在后台把数据填满
──> 完成后通知应用程序“已经读好了”。
★ 特点:内核完成后主动“推送(Push)”数据。
核心认知:以 Linux
epoll为基石的主流操作系统网络栈,本质是 “就绪通知(Readiness)”——内核只告诉你“socket 可读了,你快拿缓冲区来读”,而不是“内核帮你读好送到手里”。
3. 语言层面的十字路口:为什么 Rust 不能学 Go 或 JS?
面对 epoll 的多路复用,各大语言走向了完全不同的分叉路:
分叉 A:Go 语言路线(有栈协程 / Goroutine)
- 做法:Go 运行时接管底层一切。底层用
epoll,向上给程序员暴露纯阻塞的同步语法conn.Read()。底层通过动态连续栈(2KB~8KB 起步)、M:N 抢占式调度器和垃圾回收器(GC)运转。 - Rust 为什么不采用?
- 违背“零成本抽象”:每个协程哪怕处于空闲状态,也需要占用独立的协程栈内存。
- 违背“无运行时(No Runtime)”约束:Rust 的核心设计目标之一是支持操作系统内核、嵌入式裸机固件以及导出 C-ABI 库,不能强绑定包含 GC 与复杂抢占调度器的重型运行时。
分叉 B:JavaScript / C# 路线(推式 Push / Promise / 回调链)
- 做法:基于发布-订阅机制。每个异步调用传入回调函数或返回一个 Promise,状态变化时把结果推(Push)给下游执行。
- Rust 为什么不采用?
- 堆内存分配开销:链式组合子与闭包通常需要在堆上分配空间以保存捕获的上下文。
- 缺乏天然背压(Backpressure):上游持续推送数据,如果下游消费过慢,中间队列容易积压导致内存激增。
- 取消机制(Cancellation)复杂:通常需要沿途传递取消令牌(如 AbortController / CancellationToken),并在执行链路中显式轮询检查。
推导:Rust 的选择——拉式(Pull-based)无栈状态机
Rust 需要一种并发抽象,同时满足:
- 单线程支撑高并发(基于
epoll/kqueue等多路复用机制); - 零独立栈开销(无栈协程,编译期确定结构体大小);
- 按需分配(任务状态机可内嵌在调用栈或单次分配在堆上,无需细碎闭包堆分配);
- 天然支持背压与低成本取消。
推导出的设计即拉式(Pull-based / 需求驱动)状态机:如果不主动调用 poll,任务不会推进;一旦不再调用 poll 并执行 drop,任务即被取消。
第一层:Future 是什么
在标准库中,Future 只是一个极度精简的 trait:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
关键语义:
poll是非阻塞的。没准备好就立刻返回Poll::Pending,绝不 sleep、绝不阻塞 OS 线程。poll由上层驱动反复拉取。每次被拉取时,状态机从上次停下的地方继续推进。- 返回
Pending之前,必须确保存储了cx.waker()。否则后续没有谁来重新调度,任务将永久挂起。 - 返回
Ready之后不应再被poll。这是调用契约,违规调用会 panic 或引发逻辑错误(状态机进入 Fuse 保护)。
两种模型的本质对比
传统阻塞 I/O: 线程调用 read() ──> 内核挂起线程 ──> 一个线程同时只能死等一个 I/O
Future 拉式模型: 任务调用 poll() ──> 没数据返 Pending ──> 线程立刻去跑别的任务
└── 数据到了通过 Waker 把任务重新放回就绪队列
异步的核心机制:将等待 I/O 就绪的阻塞操作,从操作系统内核线程下沉到了用户态的任务状态机。
第二层:async/await 被编译成了什么
你手写的代码:
async fn foo() -> i32 {
let a = bar().await; // 断点 1
let b = baz(a).await; // 断点 2
a + b
}
编译器不会为它分配任何多余的堆内存,而是将它编译成一个自引用的 enum 状态机:
enum FooFuture {
Start, // 初始状态
State1 { fut: BarFuture }, // 停在 await bar()
State2 { a: i32, fut: BazFuture }, // 停在 await baz(a),注意 a 被内嵌了
Done,
}
impl Future for FooFuture {
type Output = i32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<i32> {
let this = unsafe { self.get_unchecked_mut() };
loop {
match this {
FooFuture::Start => {
*this = FooFuture::State1 { fut: bar() };
// 不 return,继续 loop,立即推进下一阶段
}
FooFuture::State1 { fut } => {
match Pin::new_unchecked(fut).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(a) => {
*this = FooFuture::State2 { a, fut: baz(a) };
// 不 return,继续 loop,直接进入 State2
}
}
}
FooFuture::State2 { a, fut } => {
match Pin::new_unchecked(fut).poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(b) => {
let res = *a + b;
*this = FooFuture::Done;
return Poll::Ready(res);
}
}
}
FooFuture::Done => panic!("poll after completion"),
}
}
}
}
三个核心特征
async fn被调用时什么都没发生。foo()只是构建了上面这个enum的初始值(处于Start)。函数体内部的代码必须等第一次poll被调用时才会真正开始执行。- 每个
.await就是状态机的一个切分点。 返回Pending时,所有跨断点存活的变量(如上述代码里的a)和子 Future 都会被打包并内嵌存入enum变体字段。 poll里的loop避免不必要的调度开销。 如果子 Future(如内存缓存命中或非阻塞数据已在缓冲区)立刻返回Ready,loop会直接进入下一个分支继续执行,无需退回调度器重新排队;一旦真正遇到Pending,立即return让出线程。
第三层:Pin——保证自引用结构体的内存位置不变
1. 为什么拉式状态机必然出现自引用?
在同步函数中,局部引用非常自然:
let mut x = 1;
let r = &x;
println!("{r}");
但在 async 状态机中,如果一个借用跨越了 .await:
async fn f() {
let mut x = 1;
let r = &x; // r 引用了局部变量 x
something().await; // 挂起点
println!("{r}"); // 恢复后继续使用 r
}
因为要跨断点保存,编译器必须把 x 和 r 同时装进同一个状态机结构体:
struct FFuture {
x: i32,
r: *const i32, // 物理上指向本结构体内部的 x 字段!
}
2. 内存移动带来的未定义行为(UB)
如果这个结构体在内存中被移动了(例如在栈帧间复制、或者作为返回值返回、或者在 Vec 扩容时被 memcpy 到新地址):
移动前:
[ 地址 0x1000: x = 1 ] <─── 指向 0x1000 ─── [ 地址 0x1008: r ]
移动后(搬到了新地址 0x2000):
[ 地址 0x2000: x = 1 ] [ 地址 0x2008: r = 0x1000 (悬挂野指针!) ]
x 移动到了 0x2000,但 r 保存的地址依然是 0x1000(悬挂指针)。下次调用 poll 解引用 r 时,就会访问非法内存导致未定义行为(UB)。
在没有运行时垃圾回收来辅助重定位指针的前提下,Rust 的解法是:通过类型系统限制结构体的移动(Move)操作。
3. Pin 是怎么做到的
Pin 是一个零开销的包装结构体:
pub struct Pin<P> {
pointer: P, // 内部就包着一个指针(如 &mut T 或 Box<T>)
}
Pin 的本质约束:
- 对于安全的无自引用类型(实现了
Unpin,如i32、String),Pin允许通过DerefMut拿到&mut T:impl<P: DerefMut<Target: Unpin>> DerefMut for Pin<P> { ... } - 对于可能包含自引用的
!Unpin类型(如async编译生成的状态机),Pin不实现DerefMut。 无法直接获取&mut T,就无法使用std::mem::swap、std::mem::replace等方式将值移走,从而保证其内存地址在生命周期内固定不变。
第四层:Executor、Task、Waker 三者关系
| 角色 | 本质 | 职责 |
|---|---|---|
| Task | Future + 调度上下文(通常以 Arc<Task> 存在) | 调度系统分配给 Worker 线程的原子执行单元 |
| Waker | 一个可克隆的“反向唤醒凭证”,内部存有 Task 的回拨指针 | Future 在数据就绪时通知 Executor 重新排队的信号线 |
| Executor | 工作线程池 + 就绪任务队列 | 负责轮流取出 Task 并调用其顶层的 poll() |
核心架构图
┌─────────────────────────────────────────────────────────┐
│ Executor (调度环) │
│ │
│ loop { │
│ task = ready_queue.pop_front() ◄─────────────┐ │
│ waker = Waker::from(task) │ │
│ match task.future.poll(waker) { │ │
│ Ready => 任务结束,释放资源 │ │
│ Pending => 丢弃(绝不自动重新入队,等 wake 通知)│ │
│ } │ │
│ } │ │
└───────────────────────────────────────────────────┼─────┘
│
wake() 时将 Task push_back
│
┌───────────────────────────────────────────────────┴─────┐
│ Waker 内部指针 (Arc<Task>) │
│ │
│ impl Wake for Task { │
│ fn wake(self: Arc<Self>) { │
│ self.queue.push_back(self.clone()); │
│ } │
│ } │
└─────────────────────────────────────────────────────────┘
核心调度契约
- Future 不直接感知 Executor 的具体实现,仅通过
Waker.wake()发送就绪通知。 Poll::Pending不能自动重新入队。如果返回Pending仍持续入队轮询,执行器就会退化为高 CPU 占用的忙等待。- Waker 需在每次返回 Pending 时刷新(避免漏唤醒)。
Task 在两次
poll之间可能会被工作窃取(Work-stealing)调度迁移到其他工作线程。每次poll返回Pending时,需保证存储的是当前最新的cx.waker()。在多线程底层实现中,通常使用AtomicWaker进行并发安全的更新。
第五层:手写一个能跑的最小异步运行时
下面是一个支持防御虚假唤醒、可独立运行的极简执行器与 Future 实现:
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
use std::thread;
use std::time::{Duration, Instant};
// ========== 1. 防御虚假唤醒的安全 Delay Future ==========
struct SafeDelay {
deadline: Instant,
started: bool,
}
impl SafeDelay {
fn new(dur: Duration) -> Self {
Self {
deadline: Instant::now() + dur,
started: false,
}
}
}
impl Future for SafeDelay {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// ★ 核心检查:时间真的到了吗?严格防范协作任务触发的虚假唤醒
if Instant::now() >= self.deadline {
return Poll::Ready("pong");
}
// 仅在首次 poll 时起后台线程模拟硬件中断
if !self.started {
self.started = true;
let waker = cx.waker().clone();
let sleep_time = self.deadline.saturating_duration_since(Instant::now());
thread::spawn(move || {
thread::sleep(sleep_time);
println!(" [硬件中断/内核通知] 定时器时间到,调用 waker.wake()");
waker.wake();
});
}
println!(" [poll] 硬件未就绪,返回 Pending");
Poll::Pending
}
}
// ========== 2. Task:将 Future 绑定调度与唤醒能力 ==========
struct Task {
future: Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>,
queue: Arc<Mutex<VecDeque<Arc<Task>>>>,
}
impl Wake for Task {
fn wake(self: Arc<Self>) {
println!(" [wake] Task 被唤醒,放回 Executor 就绪队列");
self.queue.lock().unwrap().push_back(self.clone());
}
}
// ========== 3. Executor:纯粹的就绪队列拉取环 ==========
fn run_executor(queue: Arc<Mutex<VecDeque<Arc<Task>>>>) {
loop {
let task = queue.lock().unwrap().pop_front();
let Some(task) = task else { break };
let waker = Waker::from(task.clone());
let mut cx = Context::from_waker(&waker);
let mut fut = task.future.lock().unwrap();
match fut.as_mut().poll(&mut cx) {
Poll::Ready(()) => println!(" [executor] Task 彻底完成"),
Poll::Pending => println!(" [executor] Task 挂起,出让线程跑其他任务"),
}
}
}
// ========== 4. 驱动主流程 ==========
fn main() {
let queue = Arc::new(Mutex::new(VecDeque::new()));
let future = Box::pin(async {
println!(" [future] 开始执行");
let res = SafeDelay::new(Duration::from_millis(100)).await;
println!(" [future] 收到结果: {res}");
});
let task = Arc::new(Task {
future: Mutex::new(future),
queue: queue.clone(),
});
queue.lock().unwrap().push_back(task);
println!("执行器启动");
run_executor(queue);
println!("执行器退出");
}
运行输出与时序
执行器启动
[future] 开始执行
[poll] 硬件未就绪,返回 Pending
[executor] Task 挂起,出让线程跑其他任务
[硬件中断/内核通知] 定时器时间到,调用 waker.wake()
[wake] Task 被唤醒,放回 Executor 就绪队列
[future] 收到结果: pong
[executor] Task 彻底完成
执行器退出
工业级异步运行时(如 Tokio)的核心逻辑与上述模型一致,主要区别在于将后台线程替换为内核级 I/O 多路复用(如 epoll_wait),并将互斥锁队列替换为支持工作窃取(Work-stealing)的高性能调度队列。
第六层:真实世界——Tokio 如何把 epoll 接入 poll
在实际网络通信中,Tokio 采用 Executor 与 Reactor 分工协作的双环架构:
【环 1:Executor 调度环(Worker 线程池)】 【环 2:Reactor 驱动环(Driver 线程)】
从就绪队列取 Task ──> 调用 poll() 循环调用 epoll_wait(timeout)
▲ │
│ ▼
└──────── Waker::wake() ◄── epoll 就绪唤醒触发对应的 ScheduledIo 槽位
1. Optimistic I/O(乐观读写 / Fast Path)
Tokio 的 I/O 读写优先尝试无锁非阻塞调用,而非每次都注册 epoll:
- Fast Path(快路径):
调用
stream.read(&mut buf)时,首先直接执行非阻塞系统调用recv(fd)。 若内核接收缓冲区已有数据,系统调用直接返回读取字节数并返回Poll::Ready(Ok(n)),无需经过 epoll 注册与任务调度上下文切换。 - Slow Path(慢路径):
仅当
recv返回EWOULDBLOCK(接收缓冲区为空)时,才进入慢路径:将当前 Waker 注册到对应的ScheduledIo槽位,并借助epoll_ctl订阅可读事件。
2. 边缘触发(Edge Triggered)下的驱动循环
Tokio 的 mio 默认使用 epoll 的边缘触发(EPOLLET)。
边缘触发的特性是:只有当 fd 从“不可读”变为“可读”的瞬间,内核才会通知一次。
// Tokio 内部的读轮询骨架
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
loop {
// 1. 检查就绪标记(如果未就绪,存入当前 cx.waker() 并返回 Pending)
ready!(self.io.poll_read_ready(cx))?;
// 2. 尝试读取
match self.io.get_ref().try_read(buf) {
Ok(n) => return Poll::Ready(Ok(())),
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
// 3. 抹除旧就绪标记!重新回循环头,下一次迭代将安全挂起
self.io.clear_readiness(Ready::READABLE);
}
Err(e) => return Poll::Ready(Err(e)),
}
}
}
为什么必须有
clear_readiness? 在并发场景下,内核通知了就绪,但在你真正调用recv之前,缓冲区中的几个字节可能已经被另一个操作读走;如果直接返回Pending而不清空缓存的就绪标记,下次进入poll依然会误判为“可读”,陷入瞬间死循环。清空就绪标记后再次尝试,才能让任务安全地重新向内核挂号。
第七层:取消机制与取消安全(Cancellation Safety)
1. 取消机制:取消即 Drop(Cancellation is Drop)
在许多语言中,取消异步任务通常依赖显式传递的上下文(如 Go 的 context.WithCancel),需要任务内部主动监听 <-ctx.Done(),否则协程可能泄漏。
在 Rust 的拉式模型中,取消直接由生命周期与所有权系统驱动:
- 执行器不再调用
poll,Future 就不会继续推进; - 当 Future 被
drop时,其内部保存的局部变量与子 Future 会递归析构; - 底层持有的资源(如 socket fd)随之释放关闭,从
epoll中自然注销。
2. Future 被 Drop 时 Reactor 的监听处理
如果 Future 已经 drop,但 Reactor 仍在 epoll 中监听该 fd,后续事件到达时的处理取决于 drop 的粒度:
情况 A:单次操作 Future 被 Drop(连接保持存活)
例如在 tokio::select! 中读取超时,stream.read() Future 被丢弃,但底层 TcpStream 仍在后续逻辑中使用:
- Reactor 不注销 epoll:调用
epoll_ctl(EPOLL_CTL_DEL)需要陷入内核,开销较高,因此 Tokio 会保留监听。 - 迟到事件的处理:当内核数据到达、
epoll_wait触发时,Reactor 将对应槽位标记为就绪,并调用先前保存的Waker。如果关联 Task 正在执行其他分支,这次唤醒只是一次无害的虚假唤醒(Spurious Wakeup);若 Task 已结束,wake()直接返回。 - 就绪标记留存(Fast Path):槽位中记录的就绪状态不会丢失。后续代码再次对该连接调用
read()时,可直接检测到就绪标记并发起非阻塞读取,无需重新挂号排队。
情况 B:I/O 资源本身被 Drop(连接关闭)
例如请求处理完毕或发生错误,TcpStream 离开作用域被析构:
- 内核自动解绑:
TcpStream析构时触发close(fd)。在 Linux 中,当文件的最后一个引用计数关闭时,内核会自动将其从所有epoll实例中移除。 - Reactor 槽位清理:Tokio 会显式解绑并清理槽位中保存的 Waker,防止内存泄漏。
- 代际版本号(Generational Token)防并发竞争:
如果 Reactor 捕获到就绪事件的同时工作线程恰好 drop 了
TcpStream,为避免误唤醒被回收或已复用给新连接的槽位,Tokio 在注册给 epoll 的 Token 中附带了代际编号(Generation)。槽位回收时代际号递增;Reactor 处理事件时若发现 Token 的代际号与槽位当前代际号不一致,直接丢弃该过期事件。
3. 取消安全(Cancellation Safety)
由于取消在 Rust 中表现为直接析构(Drop),在组合并发操作时需要特别注意取消安全性。
当使用 tokio::select! 或 tokio::time::timeout 并联多个分支时,只要其中一个分支就绪,其余未完成的分支 Future 就会被直接 drop:
tokio::select! {
res = socket.read_exact(&mut buf) => { ... } // ❌ 非取消安全!
_ = timeout => { ... }
}
- 安全场景(如
AsyncReadExt::read): 若数据尚未到达即被 drop,socket 连接未受损,未破坏未完成的数据流状态。 - 不安全场景(如
AsyncReadExt::read_exact): 若要求读取 10 个字节,当前已读入 4 个字节时被取消(drop),已读出的 4 个字节会随着中间状态机的销毁而丢失。后续即使基于同一连接重试读取,数据流也已经断裂损坏。
注意:在
tokio::select!的分支中使用 Future 时,需查阅文档确认方法是否为 Cancellation Safe。若非取消安全,应在select!外部持有中间缓冲与状态,或将该 I/O 放入独立的 Task 处理。
4. 跨 .await 的借用与 Send 边界
// ❌ 编译报错:Future 不能跨线程发送
tokio::spawn(async {
let rc = Rc::new(100);
sleep(Duration::from_millis(10)).await;
println!("{rc}");
});
- 本质机理:
tokio::spawn依赖多线程 Worker 窃取。Task 被挂起后,下次可能在另一个不同的 Worker 线程中恢复执行。 - Rc 的跨断点判定:
编译器是按字段细粒度检测的。只要
Rc没有跨越.await断点,它就不会被收录进跨断点的状态机字段:// ✅ 编译完全通过 tokio::spawn(async { { let rc = Rc::new(100); println!("{rc}"); } // rc 在这里 drop,状态机字段里不含 Rc sleep(Duration::from_millis(10)).await; }); - 同理,
std::sync::MutexGuard跨.await会让 Future 失去Send且引发死锁,必须使用作用域隔离或者换用tokio::sync::Mutex。
结语:全链路端到端全景时序
把底层系统调用、内核驱动与异步抽象串成完整时序:
① 用户代码 stream.read(&mut buf).await
│
② 乐观尝试 直接非阻塞 recv(fd) ──> 若命中缓冲区,直接返回 Ready(n)(零开销退出)
│ (WouldBlock)
③ 挂号登记 将当前 Task 的 Waker 存入 ScheduledIo,通过 epoll_ctl 注册监听
│
④ 出让线程 返回 Poll::Pending,Worker 线程去执行队列中的其他就绪任务
│
⑤ 硬件就绪 网卡接收数据,DMA 写入内存并触发硬件中断
│
⑥ 内核置位 Linux TCP 协议栈将数据放入 socket 接收缓冲区,标记 fd 可读
│
⑦ Reactor 唤醒 Driver 线程从 epoll_wait 醒来,解析 Token 找到对应 ScheduledIo
│
⑧ 唤醒通知 Reactor 执行 waker.wake(),将挂起的 Task 重新推入 Executor 就绪队列
│
⑨ 调度重入 空闲 Worker 线程取出 Task,再次调用 poll(Pin<&mut State>, cx)
│
⑩ 真正读取 恢复到断点,再次调用 recv(fd) 成功获取数据,返回 Poll::Ready(n)
Rust 的异步设计通过拉式状态机与编译期零成本抽象,将操作系统多路复用能力与无 GC 内存安全保证结合在一起,在实现低运行时开销的同时保证了系统调度的灵活性。