Future 的 poll 模型?

解读

在国内 Rust 后端与云原生岗位的面试里,“Future 的 poll 模型” 几乎是必考高频题。面试官想确认三件事:

  1. 你是否真正写过 async/.await,还是只会抄模板;
  2. 能否把零成本抽象的 Future 翻译成“状态机 + 轮询”的底层思维;
  3. 是否理解 Waker 带来的线程安全唤醒链,这直接关系到高并发服务能否做到无锁、无惊群、低延迟

回答时切忌背定义,而要围绕 “状态机转移 → 轮询 → 挂起 → 唤醒 → 再轮询” 这一闭环展开,并给出手写 Future 的实战细节,才能体现“编译通过即正确”的 Rust 工程素养。

知识点

  1. Future trait 的签名

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
    

    只有 1 个方法,却完成了“协程调度原语”的全部职责。

  2. Poll 枚举

    • Poll::Ready(T):计算完成,返回值。
    • Poll::Pending:尚未完成,当前线程绝不允许空转,必须注册 Waker 后立刻挂起。
  3. Pin<Ptr<T>>
    杜绝 mem::swap 导致的状态机指针失效,是自引用结构体安全落地的基石;面试时要能解释 Unpin!Unpin 的区别,并给出 Box::pin/pin_mut! 的选型理由。

  4. Context 与 Waker
    cx.waker().wake_by_ref() 一次调用即可把任务放回全局/本地运行队列;正确实现必须保证wake() 幂等、线程安全、无锁,否则极易出现** Lost Wake-Up** 或惊群

  5. 状态机编译变换
    async fn 会被 rustc 前端翻译成匿名 enum 状态机,每个 .await 点对应一个 Suspend 状态;生成代码不会引入任何系统调用或额外堆分配,真正做到零成本抽象。

  6. 零成本 vs 有成本误区
    零成本指不用的功能不付费,而非“所有场景都比 C 快”;面试中可主动提及极端小包场景下,Waker 链表的缓存未命中可能带来 3~5% 额外开销,体现性能敏感度。

答案

Future 的 poll 模型是 Rust 异步运行时最核心的“协作式调度原语”,它把“异步计算”抽象成一个自驱动的状态机,通过反复轮询(poll)推进执行,而非依赖操作系统线程切换。核心流程如下:

  1. 状态机生成
    编译器将 async fnasync {} 块转换成匿名结构体,实现 Future trait;每个 .await 点被拆成暂停与恢复两个状态。

  2. 轮询入口
    运行时(Tokio / async-std / smol)在本地任务队列中取出任务,调用 Future::poll(self: Pin<&mut Self>, cx: &mut Context<'_>)

  3. 执行与挂起

    • 若内部资源已就绪,状态机一路推进到 Ready(T),运行时把结果返回给上层。
    • 若遇到未就绪的 IO(如 socket 缓冲区空),当前 Future 在返回 Poll::Pending 之前,必须把 cx.waker() 克隆并注册到Reactor(epoll/uring),确保事件到达时可被唤醒;这一步是杜绝 Lost Wake-Up 的关键
  4. 唤醒与再调度
    当内核事件到达,Reactor 调用 Waker::wake(),把任务再次压入运行队列;线程池下一次轮询继续调用 poll,状态机从上次暂停点恢复,直到最终 Ready

  5. 零成本体现
    整个流程无系统线程切换、无堆分配、无虚拟表派发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();   // 一次原子写,任务重新入队
            }
        });
    }
}

该例子展示了自注册 WakerPin 无需额外堆分配、以及唤醒路径零锁竞争三大要点,面试现场手写可立即拉开差距。

拓展思考

  1. 为什么 Rust 不采用 JavaScript 的 Promise.then 回调模型?
    回调模型在高并发、高尾延迟场景下会造成栈撕裂分配风暴;poll 模型把控制权交还给运行时,一次分配 + 状态机复用,更适合百万级协程的网关、代理、区块链 P2P 节点。

  2. Waker 的线程安全如何保证?
    标准库只要求 wake() 满足 Send + Sync + 'static;Tokio 内部使用无锁 MPSC 链表,把唤醒操作变成一次原子 XCHG,避免 cacheline 弹跳,NUMA 友好

  3. 与 Go GMP 模型的对比
    Go 的 goroutine 由运行时抢占式调度,GC 带来 1~2 ms 暂停;Rust poll 模型完全协作式无抢占、无 GC,尾延迟可压到 10 µs 级,金融量化撮合、游戏帧同步等延迟敏感系统更倾向 Rust。

  4. !Unpin 的实战陷阱
    若 Future 内部使用自引用结构(如 async move { let a = 1; let b = &a; ... }),必须 Box::pin;误用 pin_mut! 会导致栈上移动,触发 UB。面试时可反问“如果必须零堆分配,又想自引用,该如何设计?”——答案是用 generator + pin-project 宏 在栈上生成固定布局,展示深度优化能力。

  5. io_uring 与 poll 模型的融合
    在 Linux 5.10+ 场景,Tokio-uringWaker 直接塞进 cqe.user_data一次系统调用批量收割 1024 事件,将上下文切换降到 0,单核 10 Gbps 转发已成国内 CDN 边缘节点的新标杆;理解 poll 模型是阅读其源码的前提。