Rust 进阶(四):async Rust 的真实性能模型

  • King
  • 发布于 3小时前
  • 阅读 22

很多人用asyncRust一段时间后,都会有一种矛盾感:表面上:async/await用起来很顺实际上:一到性能、内存、卡顿、奇怪的borrow报错,就开始失控于是常见的评价是:“asyncRust太复杂了”但真正的问题不是“复杂”,而是:asyncRust

很多人用 async Rust 一段时间后,都会有一种矛盾感:

  • 表面上:async/await 用起来很顺
  • 实际上:一到性能、内存、卡顿、奇怪的 borrow 报错,就开始失控

于是常见的评价是:

“async Rust 太复杂了”

但真正的问题不是“复杂”,而是:

async Rust 没帮你隐藏任何成本。


1️⃣ 一个必须先打破的幻想:async ≠ 并行

在不少语言/框架里,async 被潜移默化地理解成:

“写起来像同步,但跑起来很快”

Rust 不认这个说法。

在 Rust 的语义里:

  • async 不是并行
  • async 不是多线程
  • async 甚至不是调度

async 在 Rust 中只做一件事:

把函数编译成一个可被暂停和恢复的状态机。

仅此而已。


2️⃣ async fn 的真实形态:一个状态机 + 一堆字段

你写下:

async fn fetch() -> Data {
    let a = step1().await;
    let b = step2(a).await;
    step3(b)
}

编译器看到的不是“异步函数”,而是近似于:

enum FetchFuture {
    Start,
    WaitingStep1(Step1Future),
    WaitingStep2(Step2Future),
    Done,
}

并且:

  • 每个 await = 一个状态边界
  • await 之前活着的变量 = Future 的字段
  • Future 必须能被反复 poll

👉 这直接决定了 async Rust 的内存和性能特征。


3️⃣ 为什么 async Rust 对“借用”这么严格?

很多人第一次被 async Rust 折磨,通常是这类报错:

borrowed value does not live long enough cannot borrow across await

这不是编译器刁难你,而是状态机模型的必然结果。

关键事实

  • await 可能暂停当前任务
  • 暂停意味着:函数栈被“冻结”
  • 冻结的状态会被 移动调度存放

如果你在 await 前借了一个引用:

let x = &self.field;
foo().await;
use(x);

那就意味着:

  • Future 里保存了一个指向 self 内部的引用
  • Future 还可能被 move
  • 引用地址一旦变化,就会悬垂

Rust 选择了最保守、也最安全的策略:

除非你能证明地址稳定,否则禁止。


4️⃣ Pin 出现的真正原因:不是为难你,而是救你

Pin 是 async Rust 最容易被误解的概念之一。

你需要记住一句话:

Pin 不是“不能动”,而是“不能在你不知道的情况下被动”。

为什么 Future 需要 Pin?

因为:

  • async Future 可能是自引用结构
  • 一旦开始 poll,它的内存地址就必须稳定
  • 否则内部保存的引用就会失效

于是 Rust 设计了一个协议:

  • 你可以拿到 Pin<&mut T>
  • 但你不能再 move 这个 T
  • 除非 T 明确声明:Unpin

这是一种显式的安全契约


5️⃣ async Rust 的性能核心:不是 await,而是“唤醒”

async 的性能瓶颈,几乎从来不在 await 本身

真正关键的是:

  • poll 频率
  • wake 次数
  • 任务调度开销
  • 状态切换成本

一个重要认知转变

async 任务 ≈ 一个被频繁 poll 的小状态机

  • poll = “你现在能不能继续?”
  • 返回 Pending = “不行,等我被唤醒”
  • wake = “我现在可能行了”

大量细碎 async 任务 = 大量调度和唤醒。


6️⃣ 为什么“到处 spawn”往往是性能问题的根源?

很多 async 新手喜欢这样写:

tokio::spawn(async move {
    do_something().await;
});

感觉很“并发”,很“异步”。

但在 Rust 里,这意味着:

  • 分配一个新的 task
  • 注册到调度器
  • 维护 waker
  • 可能跨线程迁移

spawn 不是免费午餐。

经验法则:

  • 逻辑上的 async ≠ 物理上的 task
  • 能在一个 task 里 await 的,就别拆
  • task 是调度单元,不是代码组织工具

7️⃣ async Rust 的内存模型:你写的不是栈,而是“堆状态”

一个非常反直觉但极其重要的事实:

async 函数里的局部变量,几乎都活在堆上。

因为:

  • Future 需要跨 await 存活
  • 栈不能被暂停
  • 所以状态被打包进一个 struct

这意味着:

  • 大 struct = 大 Future
  • 在 await 前声明的变量,会活到下一次 await
  • 不必要的字段会放大内存占用

于是你会看到成熟的 async Rust 代码中:

  • 更小的作用域
  • 提前 drop
  • 把大对象放在 await 之后创建

这是为状态机瘦身


8️⃣ Tokio / async-std 做的,其实是“任务调度工程”

很多人把 Tokio 当成“异步库”。

更准确的说法是:

Tokio 是一个高性能的任务调度器 + IO 反应堆。

它解决的不是:

  • 怎么写 async

而是:

  • 怎么调度几十万 Future
  • 怎么最小化 wake 成本
  • 怎么避免线程抖动
  • 怎么在 IO 就绪时高效唤醒任务

一旦你理解 async 的本质是状态机,Tokio 的设计就会变得非常合理、甚至不可替代


9️⃣ async Rust 的“正确心智模型”

如果你只记住这一篇的一句话,那应该是:

async Rust = 显式状态机 + 显式调度成本 + 显式内存模型

它不帮你:

  • 隐藏堆分配
  • 隐藏生命周期
  • 隐藏调度
  • 隐藏唤醒成本

但作为回报,它给你:

  • 可预测的性能
  • 精确的资源控制
  • 在极端负载下仍可推理的行为

结语:async Rust 难,是因为它是“系统级 async”

很多语言的 async,是“应用级 async”: 方便、好用、但代价模糊。

Rust 的 async,是“系统级 async”:

  • 每一个成本都真实存在
  • 每一个抽象都可以被拆穿
  • 每一个性能问题都能被定位

这也是为什么:

一旦你真正理解 async Rust,你会发现它反而很诚实。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论