Future 的 poll 模型?
解读
在国内 Rust 后端与云原生岗位的面试里,“Future 的 poll 模型” 几乎是必考高频题。面试官想确认三件事:
- 你是否真正写过
async/.await,还是只会抄模板; - 能否把零成本抽象的
Future翻译成“状态机 + 轮询”的底层思维; - 是否理解
Waker带来的线程安全唤醒链,这直接关系到高并发服务能否做到无锁、无惊群、低延迟。
回答时切忌背定义,而要围绕 “状态机转移 → 轮询 → 挂起 → 唤醒 → 再轮询” 这一闭环展开,并给出手写 Future 的实战细节,才能体现“编译通过即正确”的 Rust 工程素养。
知识点
-
Future trait 的签名
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;只有 1 个方法,却完成了“协程调度原语”的全部职责。
-
Poll 枚举
Poll::Ready(T):计算完成,返回值。Poll::Pending:尚未完成,当前线程绝不允许空转,必须注册Waker后立刻挂起。
-
Pin<Ptr<T>>
杜绝mem::swap导致的状态机指针失效,是自引用结构体安全落地的基石;面试时要能解释Unpin与!Unpin的区别,并给出Box::pin/pin_mut!的选型理由。 -
Context 与 Waker
cx.waker().wake_by_ref()一次调用即可把任务放回全局/本地运行队列;正确实现必须保证wake() 幂等、线程安全、无锁,否则极易出现** Lost Wake-Up** 或惊群。 -
状态机编译变换
async fn会被rustc前端翻译成匿名enum状态机,每个.await点对应一个Suspend状态;生成代码不会引入任何系统调用或额外堆分配,真正做到零成本抽象。 -
零成本 vs 有成本误区
零成本指不用的功能不付费,而非“所有场景都比 C 快”;面试中可主动提及极端小包场景下,Waker链表的缓存未命中可能带来 3~5% 额外开销,体现性能敏感度。
答案
Future 的 poll 模型是 Rust 异步运行时最核心的“协作式调度原语”,它把“异步计算”抽象成一个自驱动的状态机,通过反复轮询(poll)推进执行,而非依赖操作系统线程切换。核心流程如下:
-
状态机生成
编译器将async fn或async {}块转换成匿名结构体,实现Futuretrait;每个.await点被拆成暂停与恢复两个状态。 -
轮询入口
运行时(Tokio / async-std / smol)在本地任务队列中取出任务,调用Future::poll(self: Pin<&mut Self>, cx: &mut Context<'_>)。 -
执行与挂起
- 若内部资源已就绪,状态机一路推进到
Ready(T),运行时把结果返回给上层。 - 若遇到未就绪的 IO(如 socket 缓冲区空),当前 Future 在返回
Poll::Pending之前,必须把cx.waker()克隆并注册到Reactor(epoll/uring),确保事件到达时可被唤醒;这一步是杜绝 Lost Wake-Up 的关键。
- 若内部资源已就绪,状态机一路推进到
-
唤醒与再调度
当内核事件到达,Reactor 调用Waker::wake(),把任务再次压入运行队列;线程池下一次轮询继续调用poll,状态机从上次暂停点恢复,直到最终Ready。 -
零成本体现
整个流程无系统线程切换、无堆分配、无虚拟表派发;Waker仅含两个指针(data/vtable),唤醒路径一次原子写即可入队,cache miss 极低,可在 100 ns 级别完成,支撑百万级 QPS 网关。
手写代码示例(简化 TimerFuture)
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex};
struct Delay {
until: Instant,
waker: Option<Waker>,
}
impl Future for Delay {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if Instant::now() >= self.until {
return Poll::Ready(());
}
// 关键:注册 waker,防止 Lost Wake-Up
self.waker = Some(cx.waker().clone());
Poll::Pending
}
}
// 在另一个线程定时唤醒
impl Delay {
fn spawn_wakeup(self: Arc<Mutex<Self>>) {
let until = self.lock().unwrap().until;
std::thread::spawn(move || {
let now = Instant::now();
if until > now {
std::thread::sleep(until - now);
}
if let Some(w) = self.lock().unwrap().waker.take() {
w.wake(); // 一次原子写,任务重新入队
}
});
}
}
该例子展示了自注册 Waker、Pin 无需额外堆分配、以及唤醒路径零锁竞争三大要点,面试现场手写可立即拉开差距。
拓展思考
-
为什么 Rust 不采用 JavaScript 的 Promise.then 回调模型?
回调模型在高并发、高尾延迟场景下会造成栈撕裂与分配风暴;poll 模型把控制权交还给运行时,一次分配 + 状态机复用,更适合百万级协程的网关、代理、区块链 P2P 节点。 -
Waker 的线程安全如何保证?
标准库只要求wake()满足Send + Sync + 'static;Tokio 内部使用无锁 MPSC 链表,把唤醒操作变成一次原子 XCHG,避免 cacheline 弹跳,NUMA 友好。 -
与 Go GMP 模型的对比
Go 的 goroutine 由运行时抢占式调度,GC 带来 1~2 ms 暂停;Rust poll 模型完全协作式,无抢占、无 GC,尾延迟可压到 10 µs 级,金融量化撮合、游戏帧同步等延迟敏感系统更倾向 Rust。 -
!Unpin 的实战陷阱
若 Future 内部使用自引用结构(如async move { let a = 1; let b = &a; ... }),必须Box::pin;误用pin_mut!会导致栈上移动,触发 UB。面试时可反问“如果必须零堆分配,又想自引用,该如何设计?”——答案是用 generator + pin-project 宏 在栈上生成固定布局,展示深度优化能力。 -
io_uring 与 poll 模型的融合
在 Linux 5.10+ 场景,Tokio-uring 把Waker直接塞进cqe.user_data,一次系统调用批量收割 1024 事件,将上下文切换降到 0,单核 10 Gbps 转发已成国内 CDN 边缘节点的新标杆;理解 poll 模型是阅读其源码的前提。