谁在运行你的 Rust Future?动手实践入门异步 Rust
摘要
这是一套动手实践教程系列,旨在弥合理解异步 Rust 内部机制(Future、poll、Pin、执行器)与使用 Tokio 部署实际异步代码之间的差距,面向熟悉 JavaScript 异步和基础 Rust 的开发者。
暂无内容
查看缓存全文
缓存时间: 2026/06/10 14:45
# 谁来驱动你的 Rust 未来?异步 Rust 实战入门
来源:https://aibodh.com/posts/async-rust-chapter-1-hands-on-intro-to-async-rust/
关于 AI 辅助
“AIBodh” 中的 “Bodh” 意为 “理解”。自对话式 AI 首次出现以来,我反复使用它的主要目的是学习、提问、犯错、再提问,直到某个概念终于讲通。这让我形成了一个至今仍坚信的观点:用得恰当,AI 能真正帮助人们学习。所以我用它来教学,但不是作为捷径。每篇文章仍然需要我花 20 到 25 个小时。AI 帮我度过写作中真正困难的部分:找到一个例子,让概念成为显而易见的答案;找到一种类比,让人豁然开朗;找到一种视觉化方式,让抽象变得具体;以及删减术语和冗余。我会验证代码能否运行,保持自己的 voice,删掉任何无助于你理解的内容。判断和修正都由我负责。如果任何部分感觉不对,请通过 Discord(https://discord.com/invite/cD9qEsSjUH)告诉我,我会改进。
异步 Rust 通常从两个方向之一开始教学。异步书籍和运行时文档展示内部机制:`Future`、`poll` 和 `Pin` 如何工作,你甚至可以手动构建一个微型执行器。Tokio 的指南则走另一条路:你搭建一个真实服务,它就能跑起来。两者都非常有用。但很难找到两者之间的桥梁——连接 “理解异步如何工作” 与 “真正用它交付代码” 的那部分。这个系列就是那座桥梁。
**你亲手构建引擎**——future、waker、executor——直到你理解每一块,然后**驾驶真正的引擎**——Tokio——并认出那些相同的部件,因为你亲手构建过它们。
---
**情侣疗法(此处为节标题,保持原文)**
本系列假设两件事:第一,你之前用 JavaScript 写过 `async` 和 `await` 代码。第二,你熟悉 Rust 的基础知识:struct、enum、关联函数和闭包(这些在《Bevy 和 Rust 急性子程序员指南》的免费章节中都有涵盖,链接:https://aibodh.com/books/the-impatient-programmers-guide-to-bevy-and-rust/#chapters)。
*关于本系列的快速说明:我计划在此处保留至少 5 到 8 个免费章节供阅读,其余章节收入付费电子书。免费章节可能不按编号顺序排列,因此有些章节会跳序出现。*
如果你写过任何现代 JavaScript,你已经做过无数次了:
伪代码,请勿直接使用。
```javascript
async function getUser() { /* ... */ }
const user = await getUser(); // 然后它……就运行了
```
你写 `async function`,加上 `await`,它就跑起来了。你从未想过**是什么**在驱动它,因为总有东西在驱动。`node` 进程自带了一个内置的**事件循环**:一个隐藏的引擎,它拾起你的 Promise,推动每一个直到完成。
Node 的事件循环是一个永不停止的周期。每转一次,它抓取一个可以前进的 Promise,运行到需要等待,然后把它放到一边,拿起下一个。Node 为你保持这个循环运转。
在 JavaScript 中,你从未问过是谁保持这个循环运行。但 Rust 呢?
**到底是谁在运行你的异步代码?**
用 Rust 自己的术语来说:**是谁在运行你的 future?**
Rust 有意识地反其道而行之。它**根本不提供任何事件循环**。语言内部没有任何东西等着运行你的异步代码。如果你需要一个,自己引入。或者,按照我们即将学习的方式——**自己构建一个**。
---
**第二份工作(节标题)**
这听起来像缺少特性。但到本系列结束时,你会觉得恰恰相反。但在问“谁运行它”之前,我们先精确地定义一下 `async` 到底是什么。
普通函数是你每天写的那种。你调用它,它立即执行,下一行代码时返回值已经躺在变量里:
```rust
fn add(a: i32, b: i32) -> i32 { a + b }
let sum = add(2, 3);
```
**异步函数**则不同。你调用它,它返回一个占位符而不是结果。不是值,而是一个**代表**尚未准备好的值的东西,上面贴着“稍后完成”的标签。每个异步语言都有这个占位符,只是名字不同:
- JavaScript 称之为 **`Promise`**。
- Rust 称之为 **`Future`**。
不同的词,完全相同的概念:**一个代表尚未完成工作的值**。
**等等,JavaScript 不也有 `Future` 吗?**
不是作为类型。你仍然会听到这两个词,但它们不能互换:存在一个“写”侧——工作完成后把值写入,和一个“读”侧——你持有的句柄并用 await 等待。在 JavaScript 中,你 await 的 Promise 就是读侧,而 Rust 给它自己的名字:`Future`。resolve 是写侧;你 await 的 Promise 是读侧。
```javascript
const promise = new Promise((resolve) => {
setTimeout(() => resolve("the data"), 1000);
});
const data = await promise;
```
Rust 把你持有的句柄命名为 `Future`,因为在 Rust 中,由你来读取它——通过轮询(poll)。所以每当你在本系列读到 `Future`,把它想象成一个需要你自己去读取的 `Promise`。
调用异步函数在 Node 中返回 Promise,在 Rust 中返回 Future。在 Node 中,一个隐藏的内置事件循环自动驱动 Promise 到完成。在 Rust 中,Future 是惰性的,不会自己执行任何操作:在你轮询它之前,没有代码运行,因为语言本身不提供事件循环。
在 Node 中,你调用异步函数的那一刻,占位符就是**活的**。隐藏的事件循环会抓住它并驱动到完成,无论你是否在观察。`await` 只是你等待值准备好的那一刻。工作总会发生。
在 Rust 中,调用 `async fn` **什么都不做**:
```rust
async fn get_user() -> User { /* ... */ }
let f = get_user();
```
`get_user()` 给了你一个 `Future`,然后停止。函数体尚未执行。它是**惰性的**:只是待在那里,沉睡,停在一个变量里。它将永远什么都不做,直到有东西**轮询**它——这个技术术语是指轻拍它的肩膀并问“你能前进一点吗?”
由于 Rust 不提供事件循环,那个负责拍肩膀的东西必须是一个运行时(如你引入的 Tokio),或者——按照我们将要学习的方式——你自己编写的代码。
---
**我的 Future(节标题)**
你手里拿着这个 future。你能**真正用它做什么**来让工作完成并取出值?
## 轮询
一个 `Future` 只给你一个方法。你可以 `poll` 它。轮询就是你向 future 问一个唯一的问题:“你现在能前进一点吗?”
future 会尽可能运行,然后给出两种回答之一:如果完成了,返回 `Ready(value)`;如果不得不停下等待某件事,返回 `Pending`。
“尽可能运行”这个短语是整个概念的核心,让我们用一个有实际任务的 future 来具体化它:结账购物车。`async fn` 先从数据库加载购物车,然后调用支付提供商的 API 来扣款,最后返回一个确认订单。数据库和支付 API 都不会立即响应,所以 future 不得不在两次调用处停下等待。
现在让我们轮询它,观察每次轮询如何推动 future 前进:
- **轮询 #1**: future 启动,发出数据库查询以加载购物车。数据库尚未回复,所以无法继续。它就在那里休眠,回答 `Pending`。
- **轮询 #2**: 一旦数据库回复,它从暂停处精确恢复,拿到购物车,继续运行直到调用支付 API。扣款仍在进行中,所以再次休眠,回答 `Pending`。
- **轮询 #3**: 一旦支付完成,它最后一次恢复,一直运行到函数末尾,构建确认订单,回答 `Ready(order)`。
所以“尽可能运行”的意思是:从你上次暂停的地方继续,向前运行直到要么完成,要么遇到下一个需要等待的东西。早期的轮询都在等待处结束,因此返回 `Pending`。最后一次轮询没有东西可等,所以它运行到函数末尾,返回 `Ready`。
大多数轮询会撞墙并说“还没好”。最终,有一次轮询会跑到尽头并说“完成了”。异步就是这样一个循环:撞墙、等待、再试一次,直到最后一次轮询终于成功。
这里是从 Node 过来的人容易困惑的部分:`poll` 不仅仅是**检查** future,它就是**运行** future 的东西。调用 `async fn` 并不会运行函数体中的任何代码。**第一次** `poll` 才启动它,之后的每次 poll 把它往前推一段。
所以规则很直白:没有 poll,就没有进展。一个从未被轮询过的 future 永远不会运行一行代码。没有任何东西在后台运行它。唯一推动它前进的就是你再次调用 `poll`。
“酷,那我只需要调用 `poll` 就行了,听起来很简单。”
好吧,让我们看看函数签名。
完整的 poll 签名:
```rust
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>
```
一行简短代码,包含三个陌生部分。我们逐一拆解。
### Future 本身
`self: Pin<&mut Self>`
一开始我让你把 future 想象成一个 `Promise`——尚未完成工作的表示。那么,这个表示具体是什么?它是一块**数据**。当你写一个 `async fn` 时,编译器把你的代码转换成一个值,这个值持有它需要记住的一切,以便后续继续执行。future **就是**那个值:以数据形式存在的异步代码。
随着 future 一轮一轮地运行,这块数据不断前进。所以一个 future 只是一个小的 `struct`,它持有的每个字段都是它的状态——需要在两次轮询之间记住的东西。
下面的图是一种理解 future 的方式。把它当作一个心理模型来建立直觉,而不是真实 future 在内存中的精确字节级布局:
一个结账 future 的心理模型,作为 struct 持有其状态:一个步骤(在“扣款”处暂停)、一个已加载的购物车,以及一个指向该购物车(同一个 struct 中的兄弟字段)的 item。这是一个用来建立直觉的图,而非精确的内存布局。
我们的结账 future,在任务中途暂停,持有诸如它在哪停下的(位于“扣款”处)、它已经从数据库加载的 `cart`,以及仍然指向 `cart` 内部的 `item`。每次 `poll` 都会捡起这个状态并向前运行,因为我们需要更新这些字段,所以通过第一个参数请求可写访问(`&mut`)。
但有一个问题:`item` 指向 future 自身内部——指向它旁边的 `cart` 字段。一个值指向自己的内部。你大概从未写过这样的 struct,这并非偶然:普通 Rust 会安静地引导你远离自引用。
因此你从未关心过值在内存中的位置。把它移入函数、推入 `Vec`、交给调用者——一切都能工作。但如果这个 future 被移动到内存中的新位置,`cart` 跟着去了新地方,而 `item` 仍然指向旧位置。它现在指向空的空间——一个悬垂指针。
**为什么 `future` 会自己移动到新内存位置?**
它不会自己移动。是你的代码移动它,就像 Rust 中任何值被移动一样:
1. **调用 `async fn` 本身**: `checkout()` 把 future 返回给你。那个返回本身就是一次移动。
2. **传递它**: future 是一个值,因此把它交给任何函数都是按值传递——这是一次移动。
3. **存储它**: 把它推入 `Vec`,或者放在 struct 字段中。也都是移动。
我知道这变得棘手了。这些概念确实难以掌握,它们值得比我现在给出的更详细的解释。我将在后面的章节中再回来详细说明:为什么 future 最终会指向自身,编译器如何布局它,以及值为什么会在内存中移动。现在,只需要记住这一点:`self: Pin<&mut Self>` 中的 `Pin` 保证 future 在被轮询期间不会在内存中移动。目前你只需知道这些。
---
**无返回值(节标题)**
### Waker
`cx: &mut Context`
想象我们的结账 future,正在等待支付提供商。你最初发起的轮询启动了工作,你得到了 `Pending`。那么,你什么时候再次轮询?立即再试没有意义:提供商尚未回复,future 只会再给你一个 `Pending`。
你实际上有两个选择:
- 第一,在一个紧密循环中不断轮询,直到它终于说 `Ready`。它能工作,但在提供商仍在处理时,每秒数千次地追问“好了吗?好了吗?”会烧掉整个 CPU 核心。
- 第二,去睡觉,让某件事在答案到达的那一刻唤醒你。这才是真实运行时的做法,这引出了一个问题:谁告诉你时机到了?
这就是 `Waker` 的全部工作。future 知道它在等待什么:它发起了数据库调用,它持有了 socket——意味着它持有回复会到达的实际网络连接。你的轮询器不知道;对它来说,future 是一个黑盒子,只有一个 `poll` 按钮,因此它无法知道什么时候值得再次轮询。
所以 `poll` 把 `Waker` 交给 future(藏在 `Context` 内)。`Waker` 是一个回调,意思是“当我能继续前进时唤醒我”。future 把它注册到它所阻塞的东西上——计时器或回复会到达的网络连接——然后返回 `Pending`。一旦那个东西准备好,它就会触发 waker。waker 唤醒轮询器,轮询器再次轮询 future。
在 cx 内部有一个 Waker,画成一个铃铛。轮询器把铃铛交给 future,future 把它留在它等待的东西(支付提供商)那里,然后返回 Pending,轮询器进入睡眠。当提供商准备好时,它摇响铃铛,唤醒轮询器再次轮询 future。
### 输出
每次 `poll` 的结局恰好是两种答案之一:`Ready(value)`,其中 value 是 future 的 `Output`——它最终产生的任何东西(一个 `Order`、一个 `u32`、一个 `String`);或者 `Pending`,表示还没完成。`Ready` 结束循环;`Pending` 让你回到睡眠,直到下一次唤醒。
poll 返回 future 的 Output 的 Poll 枚举:要么是 Ready 携带 Output 值,要么是 Pending 表示尚未完成。Ready 结束循环;Pending 回到睡眠直到下一次唤醒。
---
## 构建一个单向通道
我们已经认识了 async 的三个运动部件:`Future`、驱动它的 `poll`,以及指示何时再次轮询的 `Waker`。现在让我们把它们应用到一个实际问题中。想象一个 web 服务器后端……
相似文章
从头实现异步任务局部变量
本文从头解释如何在 Rust 中实现异步任务局部变量,避免依赖 tokio 生态。它讨论了线程本地存储和执行模型,以实现无需通过函数参数传递变量即可将数据与异步任务关联。
为3DS构建AsyncIO执行器
本文介绍了在Nintendo 3DS上进行异步编程的必要性,因为其采用协作式多任务处理,并开始解释如何为其构建一个asyncio执行器,重点讨论了Rust中的任务、未来、唤醒器和执行器这些概念。
Rust异步与ARM通用定时器
一篇技术博客文章,探讨了在ARM架构上使用ARM通用定时器进行Rust异步编程,比较了定时器外设,并讨论了Embassy和RTIC等框架。
使用AI编写10万行Rust代码的心得(2025)
一位开发者分享了使用AI编程助手构建一个基于Rust的10万行多Paxos共识引擎的心得,实现了显著的生产力提升和性能改进。
模拟 Rust 代码的所有方法
一篇教程,涵盖了在 Rust 中模拟网络调用的多种策略,以发出事件的 Kubernetes 控制器为例,重点强调不降低生产代码的可测试性。