谁在运行你的 Rust Future?动手实践入门异步 Rust

Hacker News Top 新闻

摘要

这是一套动手实践教程系列,旨在弥合理解异步 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 服务器后端……

相似文章

从头实现异步任务局部变量

Lobsters Hottest

本文从头解释如何在 Rust 中实现异步任务局部变量,避免依赖 tokio 生态。它讨论了线程本地存储和执行模型,以实现无需通过函数参数传递变量即可将数据与异步任务关联。

为3DS构建AsyncIO执行器

Lobsters Hottest

本文介绍了在Nintendo 3DS上进行异步编程的必要性,因为其采用协作式多任务处理,并开始解释如何为其构建一个asyncio执行器,重点讨论了Rust中的任务、未来、唤醒器和执行器这些概念。

Rust异步与ARM通用定时器

Lobsters Hottest

一篇技术博客文章,探讨了在ARM架构上使用ARM通用定时器进行Rust异步编程,比较了定时器外设,并讨论了Embassy和RTIC等框架。

模拟 Rust 代码的所有方法

Lobsters Hottest

一篇教程,涵盖了在 Rust 中模拟网络调用的多种策略,以发出事件的 Kubernetes 控制器为例,重点强调不降低生产代码的可测试性。