深入理解 Rust Futures:从零开始,一头扎到底

本文是对 Understanding Rust futures by going way too deep的整理与翻译

内容结构概览

rust 复制代码
一、项目搭建:async 生态的工具链准备
    - cargo new、tokio、color-eyre、tracing 的配置
    - #[tokio::main] 宏的作用
    - 结构化日志:tracing 的 %value / ?value 语法

二、第一个教训:Future 不调用就什么都不做
    - 未 .await 的 Future 等于只创建了一个 struct
    - 编译器警告 "futures do nothing unless you .await or poll them"
    - 和 ECMAScript Promise 的根本区别

三、手写一个最简 Future
    - impl Future for DumbFuture:poll 方法是核心
    - Poll::Ready 和 Poll::Pending 两种返回值
    - panic 的 backtrace 揭示了完整的 tokio 调用栈
    - unsafe 写坏内存 → segfault → GDB 调试演示

四、async fn 的两种理解方式
    - 语法糖视角:async fn 就是普通函数
    - 本质视角:返回 impl Future<Output = T> + 'a 的函数
    - 用 std::any::type_name 查看 Future 的实际类型名
    - async block 的生命周期和借用规则

五、证明 Future 不被 poll 就不做任何事
    - reqwest debug 日志:只有 .await 之后才发起连接
    - strace 的 connect 系统调用:sleep 期间完全没有网络活动
    - 1 秒 sleep + strace 的完整验证实验

六、串行 vs 并发:两次 await 的问题
    - fut1.await?; fut2.await?; 是完全串行的
    - 两次请求之间有明显的时间间隔
    - 为什么这是错误的并发使用方式

七、tokio::join! 宏:单线程并发
    - join! 同时 poll 多个 future,都完成才返回
    - 和顺序 await 的时间对比:几乎同时完成
    - join! 不创建线程,在同一线程上并发

八、tokio::select! 宏:取第一个完成的
    - 谁先完成就用谁的结果,取消其他 future
    - 循环 select! 可以实现"哪个先来就处理哪个"的模式

九、tokio::spawn:真正的并行(多线程)
    - spawn 把 future 提交给 tokio 线程池,实际并行运行
    - JoinHandle 的 .await 等待 task 完成
    - spawn 要求 future 是 Send + 'static

十、FuturesUnordered:动态数量的并发
    - 比多次 tokio::spawn 更适合"一堆 future 同时跑"的场景
    - 实测 19ms 的时间差说明真正并发

十一、深入底层:Waker 机制
    - 问题:Poll::Pending 之后 runtime 怎么知道何时再次 poll?
    - Context 和 Waker:cx.waker().wake_by_ref()
    - 手写慢速 Future:先 Pending 再 Ready 的完整实现
    - Waker 不调用 → runtime 永久阻塞(演示死锁场景)
    - 正确实现:在独立线程里睡眠然后唤醒 Waker

十二、顺序 vs 并发 vs 并行:三个概念的精确定义
    - 顺序(Sequential):一次做一件事
    - 并发(Concurrent):多件事交替进行,但不必同时
    - 并行(Parallel):多件事真正同时进行(多核/多线程)

十三、小结:关键结论提炼

一、项目搭建:async 生态的工具链准备

作者从一个全新的项目开始,把所有准备工作都做一遍,这样后续任何一个例子都可以直接运行。

bash 复制代码
$ cargo new waytoodeep
$ cargo add tokio@1 --features full
$ cargo add color-eyre@0.5
$ cargo add tracing@0.1 tracing-subscriber@0.2
$ cargo add reqwest@0.11 --no-default-features --features rustls-tls

#[tokio::main] 宏把 async fn main 包装成一个同步函数,在里面启动 tokio 运行时,然后运行 main 的 Future 直到完成:

rust 复制代码
use color_eyre::Report;
use tracing::info;
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() -> Result<(), Report> {
    setup()?;
    info!("Hello from a comfy nest we've made for ourselves");
    Ok(())
}

fn setup() -> Result<(), Report> {
    if std::env::var("RUST_LIB_BACKTRACE").is_err() {
        std::env::set_var("RUST_LIB_BACKTRACE", "1")
    }
    color_eyre::install()?;
    if std::env::var("RUST_LOG").is_err() {
        std::env::set_var("RUST_LOG", "info")
    }
    tracing_subscriber::fmt::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();
    Ok(())
}

tracing 的结构化日志语法值得一提:%valueDisplay 格式化,?valueDebug 格式化:

rust 复制代码
info!(%url, content_type = ?res.headers().get("content-type"), "Got a response!");

这样的日志可以直接发送到 Datadog、Honeycomb 等 APM 平台,按字段检索,比纯文本日志强得多。


二、第一个教训:Future 不调用就什么都不做

作者先写了一个明显错误的例子,故意忽略了编译器的警告:

rust 复制代码
async fn fetch_thing(client: &Client, url: &str) -> Result<(), Report> {
    let res = client.get(url).send().await?.error_for_status()?;
    info!(%url, content_type = ?res.headers().get("content-type"), "Got a response!");
    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Report> {
    setup()?;
    let client = Client::new();
    fetch_thing(&client, URL_1);  // 没有 .await
    fetch_thing(&client, URL_2);  // 没有 .await
    Ok(())
}

运行结果:什么都没发生,两个请求都没有发出去。编译器其实已经给出了清楚的警告:

perl 复制代码
warning: unused implementer of `Future` that must be used
  --> src/main.rs:15:5
   |
15 |     fetch_thing(&client, URL_1);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: futures do nothing unless you `.await` or poll them

这和 JavaScript 的 Promise 有本质区别。在 JavaScript 里,fetch(url) 调用就已经发起了网络请求,不管你有没有 .then()await。Rust 的 Future 不一样:构建 Future 只是构建一个数据结构,任何实际工作都不会发生,直到有人来 poll 它

加上 .await 之后就正常了:

rust 复制代码
fetch_thing(&client, URL_1).await?;
fetch_thing(&client, URL_2).await?;

三、手写一个最简 Future

为了真正理解 Future 是什么,作者手写了一个:

rust 复制代码
// in src/dumb.rs
use std::{
    future::Future,
    pin::Pin,
    task::{Context, Poll},
};
use tracing::info;

pub struct DumbFuture {}

impl Future for DumbFuture {
    type Output = ();

    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
        info!("Hello from a dumb future!");
        Poll::Ready(())
    }
}

Future trait 只有一个方法:poll。它接受:

  • self: Pin<&mut Self>:对自身状态的可变引用(Pin 是为了安全地处理自引用结构)
  • cx: &mut Context<'_>:包含 Waker 的上下文,用于通知 runtime"我准备好了"

返回:

  • Poll::Ready(value):Future 完成了,值是 value
  • Poll::Pending:Future 还没完成,稍后再 poll

不调用 .awaitDumbFuture 什么都不做:

rust 复制代码
let fut = dumb::DumbFuture {};  // 只是构建了一个空 struct
// 不 .await,就什么都不会发生

调用 .await 后,tokio runtime 会 poll 它,然后它返回 Poll::Ready,流程结束:

arduino 复制代码
Jul 25 17:37:09.261 INFO waytoodeep: Building that dumb future...
Jul 25 17:37:09.261 INFO waytoodeep: Awaiting that dumb future...
Jul 25 17:37:09.261 INFO waytoodeep::dumb: Hello from a dumb future!
Jul 25 17:37:09.262 INFO waytoodeep: Done awaiting that dumb future

Panic 的 backtrace 揭示了 tokio 的调用栈

DumbFuture::poll 改成 panic,然后看完整调用栈:

arduino 复制代码
7: <waytoodeep::dumb::DumbFuture as core::future::future::Future>::poll
8: waytoodeep::main::{{closure}}     ← async fn main 生成的 closure
9: <core::future::from_generator::GenFuture<T> as ...>::poll
10: tokio::park::thread::CachedParkThread::block_on
...
18: tokio::runtime::thread_pool::ThreadPool::block_on
19: tokio::runtime::Runtime::block_on
20: waytoodeep::main                 ← 真正的 main 函数

从底往上读:tokio 的线程池启动,运行 Runtime,调用 block_on,poll GenFuture(这是 async fn main 被编译成的类型),然后 poll 我们的 DumbFuture。没有任何"魔法",就是普通的函数调用链。

unsafe 写坏内存,GDB 调试

把 poll 改成写一个无效地址,触发 segfault:

rust 复制代码
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
    unsafe {
        *(0xF00D as *mut u64) = 0x0;
    }
    unreachable!();
}

这时候 panic handler 救不了你,但 GDB 可以精确告诉你崩在哪一行:

less 复制代码
(gdb) bt
#0  <DumbFuture as Future>::poll  at src/dumb.rs:15
#1  waytoodeep::main::{{closure}}  at src/main.rs:17
...

这印证了一个关键事实:Rust 的 async/await 不是什么魔法特殊机制,它只是普通代码,遵循同样的调用约定,可以用同样的调试工具


四、async fn 的两种理解方式

同一个函数有两种等价的写法:

语法糖写法:

rust 复制代码
async fn fetch_thing(client: &Client, url: &str) -> Result<(), Report> {
    let res = client.get(url).send().await?.error_for_status()?;
    info!(%url, content_type = ?res.headers().get("content-type"), "Got a response!");
    Ok(())
}

去掉语法糖的本质写法:

rust 复制代码
use std::future::Future;

fn fetch_thing<'a>(
    client: &'a Client,
    url: &'a str,
) -> impl Future<Output = Result<(), Report>> + 'a {
    async move {
        let res = client.get(url).send().await?.error_for_status()?;
        info!(%url, content_type = ?res.headers().get("content-type"), "Got a response!");
        Ok(())
    }
}

async fn 本质上是"一个返回 Future 的普通函数"。因为借用了 clienturl,生成的 Future 的生命周期不能超过它们,所以返回类型需要标注 + 'a

std::any::type_name 可以看到这个 Future 的实际类型:

ini 复制代码
type_name="core::future::from_generator::GenFuture<waytoodeep::fetch_thing::{{closure}}>"

这个类型由编译器生成,我们无法在代码里直接写出来(不能给变量标注这个类型,不能写接受这个具体类型的函数)。这是 impl Trait 存在的原因之一。


五、证明 Future 不被 poll 就不做任何事

作者用三种方式证明这一点:

方法一:reqwest debug 日志

bash 复制代码
$ RUST_LOG=info,reqwest=debug cargo run
Jul 25 18:05:07.384 INFO waytoodeep: Building that fetch future...
Jul 25 18:05:07.385 INFO waytoodeep: Awaiting that fetch future...
Jul 25 18:05:07.385 DEBUG reqwest::connect: starting new connection  ← .await 之后才发起

方法二:sleep + strace

.await 之前加 1 秒 sleep:

rust 复制代码
let fut = fetch_thing(&client, URL_1);  // 构建 Future,不做任何事
sleep(Duration::from_secs(1)).await;    // 睡 1 秒
fut.await?;                             // 才真正发起请求
bash 复制代码
$ strace -e 'connect' ./target/debug/waytoodeep
Jul 25 18:09:36.595 INFO waytoodeep: Building that fetch future...
Jul 25 18:09:36.596 INFO waytoodeep: Sleeping for a bit...
# 整整 1 秒内,没有任何 connect 系统调用
Jul 25 18:09:37.599 INFO waytoodeep: Awaiting that fetch future...
connect(9, {sa_family=AF_INET, ...}, 16) = -1 EINPROGRESS  ← 这才建立连接

strace 是黄金级别的证明:操作系统层面没有发生任何网络活动,直到 .await 被调用。


六、串行 vs 并发:两次 await 的问题

把两个 Future 分别 await 是串行的:

rust 复制代码
fut1.await?;  // 等 URL_1 完全完成
fut2.await?;  // 再开始 URL_2

日志显示:

php 复制代码
DEBUG reqwest::connect: starting new connection: https://fasterthanli.me/
DEBUG reqwest::async_impl::client: response '200 OK' for ...whats-in-the-box
INFO waytoodeep: Got a response! url=.../whats-in-the-box
DEBUG reqwest::connect: starting new connection: https://fasterthanli.me/
DEBUG reqwest::async_impl::client: response '200 OK' for ...part-13
INFO waytoodeep: Got a response! url=.../part-13

两次请求完全串行,URL_1 完全完成后才发起 URL_2 的请求。这和同步代码没有任何区别。


七、tokio::join! 宏:同一线程上的并发

join! 让两个 Future 交替被 poll,在 URL_1 等待网络响应时,去 poll URL_2,反之亦然:

rust 复制代码
tokio::join!(
    fetch_thing(&client, URL_1),
    fetch_thing(&client, URL_2),
);

日志显示:

php 复制代码
DEBUG reqwest::connect: starting new connection: https://fasterthanli.me/
DEBUG reqwest::connect: starting new connection: https://fasterthanli.me/
DEBUG reqwest::async_impl::client: response '200 OK' for ...whats-in-the-box
DEBUG reqwest::async_impl::client: response '200 OK' for ...part-13

两个连接几乎同时发起,几乎同时完成。重要:这发生在同一个线程上,是并发(concurrent),不是并行(parallel)。线程数没有增加,只是 I/O 等待期间 CPU 被更充分利用了。

join! 宏的行为:

  • 同时 poll 所有传入的 Future
  • 哪个返回 Pending 就暂时跳过它
  • 哪个返回 Ready 就记下结果
  • 所有 Future 都返回 Ready 后,join! 返回包含所有结果的元组

八、tokio::select! 宏:取第一个完成的

如果你想要"谁先完成就用谁的结果,取消其他的",用 select!

rust 复制代码
tokio::select! {
    res = fetch_thing(&client, URL_1) => {
        info!("URL_1 won the race!");
        res?
    },
    res = fetch_thing(&client, URL_2) => {
        info!("URL_2 won the race!");
        res?
    },
}

select! 会 poll 所有分支,第一个返回 Ready 的分支"赢了",执行对应的处理代码,其他还在 Pending 的 Future 会被丢弃(drop)。

select! 在循环里非常有用,用于实现"持续等待,谁来了就处理谁"的模式:

rust 复制代码
loop {
    tokio::select! {
        msg = channel_a.recv() => { handle_a(msg); }
        msg = channel_b.recv() => { handle_b(msg); }
        _ = shutdown_signal() => { break; }
    }
}

九、tokio::spawn:真正的并行(多线程)

如果想让两个 Future 在不同线程上真正并行运行:

rust 复制代码
let handle1 = tokio::spawn(fetch_thing(client.clone(), URL_1));
let handle2 = tokio::spawn(fetch_thing(client.clone(), URL_2));

handle1.await??;  // 等待 task 1 完成(两层 ?:一个是 JoinError,一个是函数本身的 Result)
handle2.await??;

tokio::spawn 把 Future 提交给 tokio 的线程池,线程池里的不同线程可以同时运行这两个 Future。

spawn 的要求:Future 必须是 Send + 'static

  • Send:因为 Future 可能被发送到另一个线程,所以它包含的所有数据都必须是线程安全的
  • 'static:因为 spawned task 的生命周期可能比创建它的那行代码更长,所以不能借用局部变量

这就是为什么上面的例子用了 client.clone() 而不是 &client------引用不满足 'static


十、FuturesUnordered:动态数量的并发

当你有一组 Future 想并发运行时,FuturesUnordered 比多次 tokio::spawn 更简洁:

rust 复制代码
use futures::stream::{FuturesUnordered, StreamExt};

let mut futs = FuturesUnordered::new();
futs.push(fetch_thing(&client, URL_1));
futs.push(fetch_thing(&client, URL_2));

while let Some(result) = futs.next().await {
    result?;
}

实测结果:两个响应相差 19ms,说明确实是并发的:

ini 复制代码
Jul 25 20:12:37.208 INFO waytoodeep: Got a response! url=.../whats-in-the-box
Jul 25 20:12:37.227 INFO waytoodeep: Got a response! url=.../part-13

十一、深入底层:Waker 机制

到这里,有一个关键问题没有回答:当 Future 返回 Poll::Pending 之后,runtime 怎么知道什么时候该再次 poll 它?

答案在 poll 方法的第二个参数里:cx: &mut Context<'_>

Context 目前只做一件事:提供一个 Waker。Waker 是一个可以在任意时刻被调用的句柄------当 Future 调用 cx.waker().wake_by_ref()waker.wake(),runtime 就知道"这个 Future 可以再被 poll 了",并把它重新加入调度队列。

手写一个"慢速 Future"

rust 复制代码
use std::{
    future::Future,
    pin::Pin,
    task::{Context, Poll},
    thread,
    time::{Duration, Instant},
};

pub struct SlowFuture {
    wake_at: Instant,
}

impl SlowFuture {
    pub fn new(duration: Duration) -> Self {
        Self {
            wake_at: Instant::now() + duration,
        }
    }
}

impl Future for SlowFuture {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let now = Instant::now();
        if now >= self.wake_at {
            // 时间到了,返回 Ready
            Poll::Ready(())
        } else {
            // 时间还没到,需要等待
            // 克隆 Waker,在另一个线程里等待后唤醒
            let waker = cx.waker().clone();
            let wake_at = self.wake_at;
            thread::spawn(move || {
                let now = Instant::now();
                if now < wake_at {
                    thread::sleep(wake_at - now);
                }
                waker.wake();  // 告诉 runtime:现在可以再 poll 我了
            });
            Poll::Pending
        }
    }
}

如果不调用 Waker 会怎样

如果返回 Poll::Pending 却不调用 waker.wake()

rust 复制代码
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
    Poll::Pending  // 永远不调用 wake
}

这个 Future 会被 poll 一次,返回 Pending,然后再也不被唤醒------程序永远阻塞在这里。这是一个合约:Future 返回 Poll::Pending 时,必须保证在某个时刻调用 Waker,否则任何等待这个 Future 的东西都会永远等下去

为什么第一次 poll 就要设置 Waker

注意上面的实现:每次 poll 时都会重新生成一个线程来等待。这稍微浪费,但展示了一个重要规则:Waker 必须在每次 poll 的时候设置,不能只在第一次设置后就假设它一直有效。因为 Waker 可能在不同的 poll 调用之间发生变化(比如 Future 被从一个 task 移动到另一个)。

实际的 tokio 和 async-std 使用操作系统的 epoll/kqueue/io_uring 机制,当 socket 可读或可写时由操作系统回调 Waker,完全不需要额外线程。


十二、三个概念的精确定义

这里澄清三个经常被混用的概念:

顺序(Sequential):一次只做一件事,上一件做完才开始下一件。

css 复制代码
[任务 A -------] [任务 B -------]

并发(Concurrent):多件事交替进行,但在任何给定时刻可能只有一件在实际进行。关键是"进度是交错的",不是"同时发生的"。

ini 复制代码
线程 1: [A 一部分][B 一部分][A 剩余][B 剩余]

并行(Parallel):多件事在物理上同时发生,需要多个处理器或核心。

ini 复制代码
线程 1: [任务 A -------]
线程 2: [任务 B -------]

在 Rust async 生态里:

  • fut1.await?; fut2.await?; → 顺序
  • join! / select! / FuturesUnordered → 并发(单线程,交替 poll)
  • tokio::spawn + 多线程 runtime → 并行(多线程,真正同时)

对于 I/O 密集型任务(HTTP 请求、文件读写、数据库查询),并发通常已经足够------瓶颈在等待 I/O,CPU 并不是问题。


十三、小结

文章从最简单的 "Future 不 poll 就什么都不做" 开始,一层一层剥开,最终到达 Waker 机制的底层。几个核心结论:

Future 是惰性的 。调用 async fn 只是建立了一个状态机,任何实际工作都不会发生,直到被 poll。

Future 是状态机 。编译器把 async fn 转换成一个实现了 Future trait 的 struct,poll 方法根据内部状态推进执行,返回 ReadyPending

await 就是 poll.await 本质上是"poll 这个 Future,直到它返回 Ready,期间如果返回 Pending 就暂时去做别的事"。

并发的精确工具

  • join!(a, b) → 等所有,单线程并发
  • select!(a, b) → 等最快的那个
  • spawn(a) → 扔给线程池,可真正并行

Waker 是 runtime 和 Future 之间的协议 。Future 返回 Pending 时必须保证最终调用 Waker,runtime 才会再次 poll 它;否则永远等待。

这套机制零运行时开销------Rust 的 async 不像 Go 的 goroutine 那样每个任务都要运行时替你管理栈;Future 本身只是堆或栈上的一个 struct,poll 就是一个函数调用,什么都是确定性的、可预测的。


参考链接

相关推荐
前端的阶梯2 小时前
Cursor 开发 Python 项目完全指南
前端·人工智能·后端
前端的阶梯2 小时前
Conda 开发 Python 程序完全指南
前端·人工智能·后端
程序员cxuan2 小时前
AI 时代,如何超过大多数人
人工智能·后端·程序员
骄马之死2 小时前
Spring 核心知识点(IOC + AOP + 事务)
java·后端·spring
wei_shuo2 小时前
KES 高可用架构实战:主备复制、读写分离与容灾切换深度解析
后端
神奇小汤圆2 小时前
沉迷 Vibe coding 后我幡然醒悟:为什么可持续开发要回归半古法编程
后端
lichenyang4532 小时前
鸿蒙电商 Demo v2:真实商品接口 + 支付/订单闭环 + 收藏功能,外加一个 ArkUI V2 @Builder 响应式断链的硬核坑
前端·后端
前端的阶梯2 小时前
如何节省你的token,请看CodeGraph
前端·人工智能·后端
用户8356290780513 小时前
Python 在 PowerPoint 中创建箱形图
后端·python