Tokio 自定义 Future:手写一个异步状态机,理解 poll 的调用时机

Tokio 自定义 Future:手写一个异步状态机,理解 poll 的调用时机

所以今天这篇文章,我会从零开始,手写一个自定义 Future,然后带你一步步画出它的状态转移图。相信我,理解了 poll 的调用时机,你对异步 Rust 的理解会上一个台阶。

一、Future 的本质是一个状态机

先看一个最简单的异步任务:向一个 HTTP 接口发请求,拿到响应后解析 JSON。用 async/await 写:

/// 一个简单的异步任务:
/// 发HTTP请求 → 读body → 解析JSON
async fn fetch_and_parse(url: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    // 第一步:发起请求(这里会 yield,让出CPU)
    let resp = reqwest::get(url).await?;      // ← 状态切换点1
    
    // 第二步:读取完整响应体
    let body = resp.text().await?;             // ← 状态切换点2
    
    // 第三步:解析 JSON
    let value: serde_json::Value = serde_json::from_str(&body)?;
    
    Ok(value)
}

看着很简单对吧?但编译器(或者说 async 关键字)实际生成的代码大概长这样:

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

/// 编译器为 fetch_and_parse 自动生成的状态机
/// 每一个 .await 点,就是一个状态
enum FetchAndParseFuture {
    /// 状态0:初始状态,尚未开始
    Start { url: String },
    
    /// 状态1:正在等 HTTP 响应回来
    /// 内部保存着 reqwest::get 返回的 Future
    WaitingResponse {
        // 注意:这里保存的是 Future,不是结果
        // Box::pin 是因为 Future 的大小在编译期不确定
        future: Pin<Box<dyn Future<Output = Result<reqwest::Response, reqwest::Error>>>>,
    },
    
    /// 状态2:HTTP 响应已拿到,正在读 body
    WaitingBody {
        resp: reqwest::Response,
    },
    
    /// 状态3:body 读完了,等待 JSON 解析
    /// 其实这个状态可以省略(解析是同步的),
    /// 编译器为了通用性还是加了
    Parsing { body: String },
    
    /// 终态:任务完成
    Done,
}

impl Future for FetchAndParseFuture {
    type Output = Result<serde_json::Value, Box<dyn std::error::Error>>;
    
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // poll 是状态机的"推进器"
        // 每次被调用,就根据当前状态决定下一步做什么
        loop {
            match &mut *self {
                FetchAndParseFuture::Start { url } => {
                    // 当前是初始状态 → 发起 HTTP 请求
                    let client = reqwest::Client::new();
                    let req_future = Box::pin(client.get(url.as_str()).send());
                    // 切换到"等待响应"状态
                    *self = FetchAndParseFuture::WaitingResponse {
                        future: req_future,
                    };
                    // 继续循环,进入下一个状态
                    continue;
                }
                
                FetchAndParseFuture::WaitingResponse { future } => {
                    // 当前在等 HTTP 响应 → poll 内部的 Future
                    match future.as_mut().poll(cx) {
                        Poll::Ready(Ok(resp)) => {
                            // 响应回来了!切换到"等body"状态
                            *self = FetchAndParseFuture::WaitingBody { resp };
                            continue;
                        }
                        Poll::Ready(Err(e)) => {
                            // 出错了,直接返回错误
                            *self = FetchAndParseFuture::Done;
                            return Poll::Ready(Err(Box::new(e)));
                        }
                        Poll::Pending => {
                            // 还没好,告诉调度器"我还在等"
                            // 调度器会记下,等 waker 被触发再调 poll
                            return Poll::Pending;
                        }
                    }
                }
                
                FetchAndParseFuture::WaitingBody { resp } => {
                    // 注意:这里本来应该有一个读 body 的 Future
                    // 简化起见,假设 body 可以直接读
                    // 实际编译器会生成类似的 WaitingBodyFuture 状态
                    *self = FetchAndParseFuture::Done;
                    return Poll::Ready(Ok(serde_json::Value::Null));
                }
                
                FetchAndParseFuture::Parsing { .. } => {
                    // 解析状态 → 同步操作,直接完成
                    *self = FetchAndParseFuture::Done;
                    return Poll::Ready(Ok(serde_json::Value::Null));
                }
                
                FetchAndParseFuture::Done => {
                    // 已经到了终态,理论上不应该再被 poll
                    panic!("Future 已经完成了,不应该再次 poll!");
                }
            }
        }
    }
}

看完这个代码,我想你对"async 函数是状态机"这句话应该有感觉了。编译器的做法就是把 async 函数体切分成多个状态,.await 点就是状态转换点。

二、poll 的调用时机:手画状态转移图

我们来画一张图,看看 poll 到底在什么时候、由谁调用:

stateDiagram-v2
    [*] --> 初始化: Task::spawn(async_fn)
    
    state "Tokio 运行时" as Runtime {
        初始化 --> 首次poll: schedule(task)
        
        state "第一次 poll" as Poll1 {
            首次poll --> 执行到await: Future::poll()
            执行到await --> 注册Waker: Poll::Pending
            注册Waker --> 挂起任务: 返回Pending
        }
        
        挂起任务 --> 等待IO事件: task被加入等待队列
        
        state "IO就绪回调" as Wake {
            等待IO事件 --> Waker被调用: epoll通知IO就绪
            Waker被调用 --> 任务重新入队: wake_by_ref()
        }
        
        任务重新入队 --> 再次poll: schedule(task)
        
        state "第N次 poll" as PollN {
            再次poll --> 从断点继续: Future::poll()
            从断点继续 --> 最终完成: Poll::Ready(value)
        }
    }
    
    最终完成 --> [*]: 返回结果

这张图告诉你几个关键的时间节点:

  1. 第一次 poll:任务被 spawn 后,运行时会在一个合适的时机(通常是当前线程空闲时)调用 Future::poll()。这时你的 async 函数开始执行,直到遇到第一个还没就绪的 .await

  2. Waker 注册:当 poll 返回 Poll::Pending 时,Future 内部会通过 cx.waker() 获取一个 Waker 并保存起来。这个 Waker 是一个"回调通知",告诉运行时:"等 IO 就绪了,记得来叫我"。

  3. 再次 poll:当 IO 就绪(比如网卡收到了数据),epoll/kqueue 通知 Tokio 运行时,运行时通过 Waker 把任务重新放回调度队列,然后再次调用 poll。这一次 poll 会从上次中断的地方继续执行。

  4. 终态:当 poll 返回 Poll::Ready(value),任务完成,不会再被调度。

三、手写一个自定义 Future:超时重试器

光说不练假把式。我们来手写一个自定义 Future:带超时重试的 HTTP 请求。如果第一次请求在 3 秒内没完成,就自动重试一次。

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::time::{sleep, timeout, Instant};

/// 带超时重试的 HTTP 请求 Future
/// - 第一次请求:3秒超时
/// - 超时后自动重试一次:5秒超时
/// - 两次都超时就返回错误
enum RetryRequestFuture {
    /// 状态1:正在做第一次请求(3秒超时)
    FirstAttempt {
        // tokio::time::Timeout 也是一个 Future!
        // 它内部包装了另一个 Future + 一个计时器
        inner: Pin<Box<dyn Future<Output = Result<String, tokio::time::error::Elapsed>>>>,
    },
    
    /// 状态2:第一次超时了,正在做重试(5秒超时)
    Retrying {
        inner: Pin<Box<dyn Future<Output = Result<String, tokio::time::error::Elapsed>>>>,
    },
    
    /// 终态:已完成(不管成功还是失败)
    Done,
}

/// 模拟一个可能很慢的 HTTP 请求
/// 实际项目中会替换成 reqwest 的调用
async fn simulate_http_request(delay_ms: u64) -> String {
    // 模拟网络延迟
    sleep(Duration::from_millis(delay_ms)).await;
    format!("响应内容(延迟{}ms)", delay_ms)
}

impl Future for RetryRequestFuture {
    /// 输出类型:Ok(成功内容) 或 Err(错误信息)
    type Output = Result<String, String>;
    
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        loop {
            match &mut *self {
                RetryRequestFuture::FirstAttempt { inner } => {
                    // poll 内部的超时 Future
                    match inner.as_mut().poll(cx) {
                        Poll::Ready(Ok(result)) => {
                            // 第一次就成功了!直接返回
                            println!("[重试器] 第一次请求成功!");
                            *self = RetryRequestFuture::Done;
                            return Poll::Ready(Ok(result));
                        }
                        Poll::Ready(Err(_elapsed)) => {
                            // 第一次超时了 → 切换到重试状态
                            println!("[重试器] 第一次超时,准备重试...");
                            let retry_future = Box::pin(timeout(
                                Duration::from_secs(5),           // 重试时给更长的超时
                                simulate_http_request(2000),       // 假设重试时延迟2秒
                            ));
                            *self = RetryRequestFuture::Retrying {
                                inner: retry_future,
                            };
                            continue;  // 立即进入重试状态
                        }
                        Poll::Pending => {
                            // 还没超时,也没完成 → 继续等
                            return Poll::Pending;
                        }
                    }
                }
                
                RetryRequestFuture::Retrying { inner } => {
                    // poll 重试的超时 Future
                    match inner.as_mut().poll(cx) {
                        Poll::Ready(Ok(result)) => {
                            println!("[重试器] 重试成功!");
                            *self = RetryRequestFuture::Done;
                            return Poll::Ready(Ok(result));
                        }
                        Poll::Ready(Err(_)) => {
                            // 重试也超时了 → 彻底失败
                            *self = RetryRequestFuture::Done;
                            return Poll::Ready(
                                Err("两次请求均超时,本次放弃".to_string())
                            );
                        }
                        Poll::Pending => {
                            return Poll::Pending;
                        }
                    }
                }
                
                RetryRequestFuture::Done => {
                    panic!("已完成的任务不应该被 poll!");
                }
            }
        }
    }
}

/// 创建重试请求的构造函数
fn retry_request(delay_ms: u64) -> RetryRequestFuture {
    let first_attempt = Box::pin(timeout(
        Duration::from_secs(3),              // 第一次给3秒
        simulate_http_request(delay_ms),
    ));
    RetryRequestFuture::FirstAttempt {
        inner: first_attempt,
    }
}

#[tokio::main]
async fn main() {
    // 测试:模拟一个4秒的请求(超过3秒超时,触发重试)
    println!("=== 测试1:请求耗时4秒(会触发重试) ===");
    match retry_request(4000).await {
        Ok(result) => println!("成功: {}", result),
        Err(e) => println!("失败: {}", e),
    }
    
    println!("\n=== 测试2:请求耗时1秒(不会触发重试) ===");
    match retry_request(1000).await {
        Ok(result) => println!("成功: {}", result),
        Err(e) => println!("失败: {}", e),
    }
}

跑这个代码,你会看到:

  • 测试1的 4 秒请求超过 3 秒超时,自动触发重试(重试时模拟 2 秒延迟,在 5 秒内完成),重试成功
  • 测试2的 1 秒请求在 3 秒内完成,直接返回

四、poll 调用时机的几个反直觉细节

初学者(包括几个月前的我)最容易产出的几个误解,我这里统一澄清:

  1. "poll 被频繁调用会浪费 CPU":不会。poll 返回 Pending 后,如果不通过 Waker 通知,调度器不会再次 poll。不存在"空转轮询"。

  2. "Waker 只能唤醒一次":不。每次 poll 被调用时,都必须重新获取 Waker(或者重用之前同一个)。因为同一个 Future 可能被多次 poll。

  3. "async 函数中的 for 循环也是状态":是。编译器会把循环展开成状态机的一部分。如果你写 for i in 0..10 { something.await; },编译后会变成 10 个状态(或者用 loop 结构优化)。

  4. "自定义 Future 需要 unsafe":不一定。如果你只是组合已有的 Future(就像我上面的重试器),不需要 unsafe。只有当你需要直接和操作系统 IO 交互、手动管理内存时,才需要 unsafe。

五、总结

这篇文章我从编译器的视角,把 async/await 生成的代码展开成状态机,手画了 poll 调用时机的状态转移图,最后手写了一个"超时重试器"来加深理解。

理解 poll 的调用时机,最核心的就是记住三点:

  1. poll 不是循环调用的,而是通过 Waker 事件驱动
  2. 每次 poll 都从上次中断的地方继续,不会重新执行
  3. Pending 后必须注册 Waker,否则任务永远不会被唤醒

作为自学者,以前觉得这些东西"编译器帮我做了不就行了吗",现在越来越觉得:不理解的抽象层一定会在某个深夜变成你的 bug。与其到时候被卡住,不如现在花功夫搞明白。

如果这篇文章帮你理清了 poll 的调用逻辑,欢迎点赞收藏,我们下篇见!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值