进入 Async (异步编程) 是你 Rust 之旅中至关重要的一步
Rust 的异步和 JavaScript 的异步在表面长的很像(都有 async/await),但在骨子里完全不同。
我们先来做个概念上的“破冰”。
1. 为什么要学异步?(不是已经有线程了吗?)
回顾一下我们刚学的:
- Rayon (并行): 适合CPU 密集型。算 5000 万个数字,把 CPU 跑满。
- Thread (线程): 适合少量并发。开 10 个线程没问题,但如果你要处理 10 万个 网络连接(C10k 问题),开 10 万个线程,光是内存占用就把电脑撑爆了,而且操作系统切换线程的开销极大。
Async 的核心目的: 用极少量的线程(通常等于 CPU 核数),去处理海量的 IO 等待任务(读文件、网络请求、数据库查询)。
通俗比喻:
-
多线程模型: 来了 100 个客人,雇 100 个服务员。每个服务员只盯着一个客人,客人喝咖啡时,服务员就在旁边傻站着等。
-
异步模型: 来了 100 个客人,只雇 1 个 金牌服务员。
- A 客人点单 -> 服务员记下 -> 交给厨房 -> 转头去服务 B。
- 厨房喊“A 的餐好了” -> 服务员收到通知 -> 转头把餐端给 A。
- 这个服务员永远在忙,从不傻等(Non-blocking)。
2. Rust 异步的“三大颠覆性认知”
这是你从 JS/Node.js 转过来最需要适应的地方:
认知一:它是“懒”的 (Lazy)
-
JavaScript: 你调用
async function foo(),它立刻就开始跑了,直到遇到 await。 -
Rust: 你调用
async fn foo(),它完全不会运行!- 它只是返回给你一个叫
Future的状态机(像是一个待办事项清单)。 - 除非你显式地对它
await,或者把它丢给执行器(Runtime),否则它永远只是一张纸,不会执行任何代码。 - 口诀:Rust 的 Future 是惰性的,你不推它,它不动。
- 它只是返回给你一个叫
认知二:它没有“内置引擎” (No Built-in Runtime)
-
JavaScript: 浏览器或 Node.js 自带了 Event Loop,你不用管。
-
Rust: 标准库里没有 Event Loop!Rust 标准库只定义了“什么是异步任务(Future)”,但不管“怎么跑这个任务”。
- 后果: 你必须引入第三方库来充当引擎。
- 王者:
Tokio。在 Rust 界,Tokio 几乎就是事实上的标准运行时。
认知三:绝对不能阻塞 (Don't Block)
- 在
async代码块里,严禁使用我们之前学的std::thread::sleep或耗时的Mutex锁。 - 因为你只有这一个“金牌服务员”,如果你让他睡了 5 秒,这 5 秒内后面 100 个客人都没人理了。
- 解决: 必须用
tokio::time::sleep(异步睡眠,服务员去干别的,时间到了再回来)。
(Future, Context, Pin)是 Rust 异步编程最底层、也是最难理解的“三巨头”。
别被这些大词吓到。如果把“异步任务”比作一次**“网购快递”**,这三个概念就非常好理解了。
第一部分:通俗拆解 Rust 异步三巨头
1. Future (未来/期指) —— 快递单
在 JavaScript 里,Promise 像是一个已经在跑的汽车。 在 Rust 里,Future 只是那张快递单。
-
定义: 它是一个状态机。它描述了一个任务:“我要去买个东西,目前还没买到,但将来会有结果”。
-
关键动作
poll()(轮询): 快递单自己不会动。必须有一个人(执行器/Runtime)拿着这张单子,时不时去问一下商家:“货好了吗?”- 如果好了,返回
Ready(数据)—— 快递到了。 - 如果没好,返回
Pending—— 还没发货,下次再来问。
- 如果好了,返回
2. Context (上下文/环境) —— 你的电话号码
如果执行器(Runtime)每隔 1 毫秒就拿着快递单去问商家“好了吗?”,商家会烦死,CPU 也会空转累死。这就需要 Context。
-
核心组件
Waker(唤醒器):Context里面装着一个叫Waker的东西。 当你去问商家(调用poll)但货没好时,你会把Context(电话号码)留给商家,说:“货好了打这个电话叫我,我就不一直在这傻等了。”
-
流程:
- 执行器去
poll。 - 返回
Pending(没好)。 - 执行器去睡觉(挂起)。
- ...过了很久,货好了...
- 商家(硬件中断/后台线程)拨打
Waker电话。 - 执行器醒来,再次去
poll,拿到数据。
- 执行器去
3. Pin (固定/定身术) —— 收货地址不能变
这是 Rust 独有的,也是最烧脑的。
为什么要 Pin? Rust 的异步函数(async fn)在编译后会变成一个结构体(状态机)。 为了节省内存,这个结构体里的变量可能会互相引用(指针指向自己内部的另一个变量,叫“自引用结构体”)。
比喻: 想象你在组装一个复杂的乐高城堡(Future)。
-
A 积木上系了一根绳子,另一头拴在 B 积木上(自引用)。
-
如果你把这个半成品的城堡从桌子 A 搬运到 桌子 B(在内存中移动位置):
- 普通的积木没事。
- 但是那根绳子(指针)还是指向桌子 A 的那个位置!
- 结果:绳子指到了空气或者别人的数据上。内存爆炸。
Pin 的作用: Pin 就是把这个乐高城堡钉死在桌子上。它向编译器保证:这个对象在内存里的地址永远不会变。只要它不动,内部的那些“绳子”(指针)就永远是安全的。
总结图解:三者的协作
-
Runtime (快递员) 拿着 Future (快递单) 。
-
Runtime 创建一个 Context (含电话号码) 。
-
Runtime 为了安全,先用 Pin (钉子) 把快递单钉在桌子上。
-
Runtime 调用
poll(Pin<&mut Future>, Context):- “货好了吗?”
- Future 答:“没好,但我记下你的 Context 电话了。”
-
Runtime 走开。
-
Event (货好了) -> 触发 Waker (打电话) -> Runtime 回来 -> 再次
poll->Ready!。
第二部分:异步 IO vs. 同步 IO
理解了底层机制,我们来看看这在宏观上(IO 操作)有什么区别。
1. 同步 IO (Blocking IO) —— 排队打饭
这是最传统的模式(如 std::fs::read)。
-
场景: 你去食堂窗口打饭。
-
行为:
- 你告诉阿姨:“我要一份红烧肉”。
- 阿姨去后厨盛菜的 30 秒里,你就站在窗口前干等。
- 你不能玩手机,不能去买饮料,不能离开。
- 阿姨回来了,把饭给你,你才能走。
-
计算机视角:
- 线程调用
read()。 - 操作系统把线程挂起(Sleep) 。
- CPU 切换去干别的。
- 硬盘转好了,操作系统把线程唤醒。
- 缺点: 如果有 1 万个人来打饭,你需要 1 万个窗口(线程)。线程很贵,这不可能实现。
- 线程调用
2. 异步 IO (Non-blocking / Async IO) —— 餐厅叫号
这是 Rust Tokio、Node.js、Nginx 的模式。
-
场景: 你去网红餐厅吃饭。
-
行为:
- 你告诉前台:“我要一桌饭”。
- 前台给你一个叫号器 (Future) ,说:“好了会震动”。
- 你立刻离开前台。你可以去逛街、玩手机、甚至去隔壁再点一杯奶茶(处理其他任务)。
- 后厨做好了,触发叫号器震动(Waker)。
- 你回到前台取餐。
-
计算机视角:
- 线程调用
tokio::fs::read。 - Tokio 仅仅是向操作系统(epoll/kqueue)注册一个关注点:“在这个文件可读时通知我”。
- 线程不睡觉,立刻转头去处理下一个请求。
- 优点: 只要 1 个前台(线程) ,就可以接待 1 万个拿着叫号器的顾客。这就是高并发的秘诀。
- 线程调用
总结与对比表
| 特性 | 同步 IO (Sync / Blocking) | 异步 IO (Async / Non-blocking) |
|---|---|---|
| 线程状态 | 阻塞 (Sleep),傻等数据 | 不阻塞 (Yield),去干别的 |
| CPU 利用率 | 低 (大量时间在切换线程) | 极高 (一直在干活) |
| 适合场景 | CPU 密集型,或简单的脚本工具 | IO 密集型 (高并发网络服务) |
| Rust 对应 | std::thread, std::fs | Tokio, async/await, Pin, Future |
| 主要代价 | 内存占用大 (每个线程都要栈空间) | 代码复杂 (状态机、生命周期、Pin) |