本文是对 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 的结构化日志语法值得一提:%value 用 Display 格式化,?value 用 Debug 格式化:
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 完成了,值是valuePoll::Pending:Future 还没完成,稍后再 poll
不调用 .await,DumbFuture 什么都不做:
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 的普通函数"。因为借用了 client 和 url,生成的 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 方法根据内部状态推进执行,返回 Ready 或 Pending。
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 就是一个函数调用,什么都是确定性的、可预测的。
参考链接
- 原文:fasterthanli.me/articles/un...
- 续集(更多 async 细节):fasterthanli.me/articles/ge...
- tokio async 深入文档:tokio.rs/tokio/tutor...
- Rust async book:rust-lang.github.io/async-book/
- reqwest:lib.rs/crates/reqw...
- color-eyre:lib.rs/crates/colo...
- tracing:lib.rs/crates/trac...
- futures(含 FuturesUnordered):lib.rs/crates/futu...