第十四章 async 与 Future:为什么 Rust 的异步”难写”却”值得”

你点外卖,下完单是干等着快递员敲门,还是先去刷会儿剧、他到了再开门?前者是”同步阻塞”,后者是”异步”——你发起一件耗时的小事,不傻等,先去干别的,事好了再回来收尾。写高并发服务器,异步就是命脉:一个线程上调度成千上万个”等网络”的任务,谁也不堵谁。

但凡是被 Rust 的 async 折腾过的兄弟,几乎都骂过一句:“不就是个异步吗,怎么又是 Future、又是 Pin、又是运行时,搞这么复杂?” 我懂。Rust 的异步是出了名的”入门劝退”。可墨叔得先抛个反直觉的论点:Rust 的异步难,恰恰是因为它”零成本、不替你做主”——它把调度权交还给你,代价是入门门槛高。 这一章我们把 Future 的状态机本质、async/await 的糖衣、Pin 的苦衷、以及 tokio 这种运行时的角色,一层层剥开,最后给你一个务实的入门心态。

一、Future 不是”未来的值”,是个”状态机”

别的语言里你听到 Future/Promise,直觉是”一个将来会有结果的对象”。Rust 的 Future 更底层、更诚实:它本质上是一个可以被反复唤醒、推进的状态机。

标准库里 Future 长这样(概念简化):

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

关键在 poll:外部的运行时会来”戳”这个 Future,问”好了没?” poll 返回 Poll::Ready(值) 表示干完了;返回 Poll::Pending 表示”还没好,先别管我,好了你再戳我”。注意 poll 不阻塞——它要么立刻给结果,要么立刻说”还没”,绝不干等。这就是异步的核心魔法:一个任务在等网络时,它不占着线程,运行时转头去推进别的任务;等网络数据到了,再回来 poll 它一下,推进到下一步。

打个比方。你去餐厅点单,服务员(运行时)手里攥着几十桌的订单。他走到 3 号桌问”A 菜好了没?“,厨师说”还没,别管我”(Pending),服务员扭头就去 5 号桌问、去吧台收钱——谁也没干等。等 A 菜的锅响了,服务员被”唤醒”,回来再戳 3 号桌:这回好了(Ready),上菜。一个服务员(一个线程)就这样”假装”同时伺候几十桌,全靠”谁没好就先跳过”这种非阻塞的 poll。

二、async/await:把状态机的丑东西藏起来

你肯定不想手写 poll 和状态机——那得自己维护”我执行到第几步了”。Rust 用 async/await 把这摊丑东西自动化了。

async fn fetch_data() -> String {
    // 假装这里在等网络
    "数据来了".to_string()
}
 
async fn handle() {
    let data = fetch_data().await; // 等它,但不阻塞线程
    println!("拿到: {}", data);
}

当你写 async fn,编译器在背后自动把这个函数展开成一个实现了 Future 的状态机:函数里每个 .await 点,都是状态机的一个”暂停—恢复”节点。编译器替你记着”执行到哪了、局部变量存在哪”。你写的还是顺溜的同步风格代码,跑的却是能暂停恢复的非阻塞状态机。

这就是 await 的真身:它不是”等”,是”在这个点把控制权交还运行时,等 Future ready 了再回来继续”。如果 fetch_data 还没 ready,当前任务就 Pending,线程转去干别的——所以 await 不阻塞线程,只”暂停”当前这一个任务。这一点必须刻进脑子,否则你会误以为 async 自动就快,结果写出处处 await 却没真并发的无效代码。

三、Pin:当状态机”不能搬家”时

现在到了最劝退的 Pin。墨叔先把结论给你:Pin 的出现,是为了解决一个很具体、很底层的问题——自引用结构不能移动。

什么叫自引用?状态机暂停时,要把局部变量存起来;而 await 点之后可能用到”指向这些局部变量的引用”(比如一个缓冲区,另一个字段指着它)。这个结构里,有字段的地址指向了同结构体内的另一处。一旦你把整个结构移动(memcpy 到别的内存地址),那些内部指针就全指向了旧地址——悬垂,崩。

// 伪代码示意:状态机内部可能出现"自己指自己"
struct StateMachine {
    buf: [u8; 1024],
    ptr: *const u8, // 可能指向 buf 内部
}
// 如果 StateMachine 被 move 到新地址,ptr 就悬垂了

解决办法就是 Pin:它把一个值”钉”在内存的某个地址上,保证它此后不会被移动。编译器通过 Pin<&mut Self> 约束 poll,要求 Future 在 poll 期间地址稳定,从而让那些自引用指针永远有效。你平时写 async 代码几乎不用手写 Pin,因为 await 和运行时帮你处理好了;但当你做底层封装、自己实现 Future 时,就会正面撞上它。

墨叔说实话:Pin 是 Rust 异步里最反人性的一处,因为它暴露了”零成本”的代价——别的语言在运行时用 GC 或额外分配帮你避开自引用移动问题,Rust 偏不花这个钱,于是把这个麻烦摊在你(和编译器)面前,用 Pin 在类型层面钉死。难,但是”不偷懒的难”。

如果你写过带 GC 的语言的 async(比如 JavaScript 的 Promise、Java 的 CompletableFuture),这里的心智要重装:那些语言的运行时帮你把所有异步对象安顿在堆上、由 GC 管理地址,你永远不用想”它会不会被移动”;Rust 没有 GC,于是把”地址稳定性”这件底层事实,变成了一个你(或库作者)必须面对的类型约束。GC 语言把复杂度藏进运行时,Rust 把复杂度摊到类型系统——这是两条哲学路线的分歧,不是 Rust 故意刁难。

四、tokio 这类运行时:谁来”戳”Future

Future 自己不会跑。它只是个被动的状态机,得有个运行时(runtime) 反复 poll 它、在 IO 就绪时唤醒它、把成千上万个任务调度到一个或几个线程上。这个运行时,标准库不提供——这就是 Rust 异步”难”的来源之一,也是它”灵活”的根源。

tokio 是最常用的异步运行时。你用它声明”哪段代码在异步运行时里跑”:

#[tokio::main]
async fn main() {
    // 这里面的 .await 由 tokio 运行时调度
    let result = async { 42 }.await;
    println!("{}", result);
}

tokio 给你:一个多线程/单线程的执行器(executor,负责 poll 和唤醒)、一套异步版的标准库(tokio::net、tokio::fs 等,它们的 read 是 async 的、不阻塞线程)、以及定时器、通道等基础设施。没有它,Future 就是没人戳的死机器。

你品这个设计的分权:标准库只定义 Future 这个契约(你怎么实现一个可轮询的任务),至于”谁来调度、用几个线程、怎么和 epoll/kqueue 对接”,交给社区运行时(tokio、async-std 等)。这跟 Go 把 goroutine 和调度器焊死在语言里形成鲜明对比——Go 开箱即用但没得选,Rust 要你自己装运行时但可选可换。

五、为什么 Rust 的异步”难写”

把前面几点串起来,你就能回答开篇那个问题了。Rust 异步难,难在四处:

第一,没有内置运行时。别的语言 async 拿来就用,Rust 你得先选并引入 tokio,理解 executor、reactor 那一套,才知道代码怎么跑起来。

第二,零成本带来的 Pin 与自引用复杂度。为了不花运行时分配的冤枉钱,编译器把状态机生成在栈上,于是自引用移动问题浮出水面,逼出 Pin。GC 语言用钱(堆分配 + GC)买走了这个麻烦,Rust 让你自己扛。

第三,Send/Sync 在异步里更敏感。一个跨 .await 点持有非 Send 值的 Future,不能被派到别的线程执行——编译器又来拦你。这其实是好事(并发安全),但初学时它报的错让人一脸懵。

第四,同步/异步边界的摩擦。调用一个同步阻塞函数会卡住整个异步运行时,你得刻意区分 .await 世界和阻塞世界,用 spawn_blocking 之类小心桥接。

墨叔总结一句:Rust 的异步难,是因为它拒绝”悄悄替你做主”——它不肯为了好写而偷偷引入 GC、偷偷塞进一个你没要求的运行时、偷偷在堆上分配。它把每一个取舍都摊在你面前,于是你得懂原理才写得好。 这是哲学,不是缺陷。

六、务实的入门心态:别一上来就想懂全部

最后,墨叔给你一个能落地的心态,免得你卡在 Pin 上三个月不动弹。

  • 先当”用户”再当”实现者”。先用 tokio + async/.await 写业务,把 Future/Pin 当黑盒用。绝大多数应用层代码,你这辈子都不用手写 impl Future 或碰 Pin。等真遇到底层封装需求,再回来啃。
  • 把 await 当”让出控制权”,不是”等待”。理解它不阻塞线程,是写好并发异步的第一步。
  • 别在异步世界里调阻塞函数。这是新手最常踩的坑,会直接拖垮整个运行时的吞吐。
  • 接受”难是代价”这件事。你为 Rust 异步交的学费,换回来的是:无 GC、无内置运行时、能把调度权握在自己手里的极致控制和确定性。对一个要跑十万 QPS、延迟按微秒计的系统,这份”难”是值钱的。

所以回看开篇:“异步不就是个异步吗,至于这么复杂?” 至于。因为 Rust 选择在”好写”和”零成本可控”之间,把票投给了后者。等你用它在单线程上驯服了上万个并发连接,你会懂这份”难”背后的克制与体面。

墨叔再补一句临别的话:别被”异步必须全程 async”这个说法骗了。一个系统里,真正需要异步、需要非阻塞等待 IO 的,往往只是那一小撮网络边界代码;大量的业务逻辑、计算、序列化,其实是同步的、纯函数的,根本不需要 async。很多新手一上来把整个项目从 main 到最底层全标成 async,结果换来一堆无意义的 .await 和膨胀的状态机,性能没见好,复杂度先翻倍。务实的做法是:只在 IO 边界用异步,把核心逻辑写成普通的同步函数,需要时再在 async 世界里调用它们。 异步是工具,不是信仰。该用才用,不该用就退回简单——这也是一个老兵写了二十年代码后,最想塞给你的那点”分寸感”。

最后记住一个事实:Rust 的异步生态还在快速演进,async trait、更顺手的 Pin 替代方案都在路上。你现在啃下的这些底层原理,不会因为语法糖的演变而过时——因为变的是糖衣,不变的是”Future 是状态机、poll 驱动、零成本控制”这套骨架。把骨架吃透,糖衣怎么换你都不慌。

【思考题】

  1. poll 返回 Pending 时”不阻塞、立刻返回”。请你推一下:如果 poll 实现成”没好就死等直到好”(阻塞式),那一个运行时用”一个线程伺候几十个任务”的非阻塞模型,会被破坏成什么样?用”餐厅服务员”的比喻讲清楚。

  2. Pin 是为了解决”自引用结构移动后内部指针悬垂”。现在假设 Rust 也像 GC 语言那样,把所有 async 状态机都分配在堆上、由 GC 管地址。请你推:这种情况下 Pin 还有必要存在吗?Rust 不这么干,它”省”下了什么、“付出”了什么?

  3. 标准库不提供异步运行时,要你自选 tokio。请你对比着想:Go 把 goroutine + 调度器焊死在语言里”开箱即用”,Rust 把运行时交给你选”开箱自己装”。这两种路线,各自”赚”了什么、“亏”了什么?(不必站队,把两边的账都列清即可。)

【参考答案】

  1. 如果 poll 是阻塞式(没好就死等),非阻塞模型直接崩塌。回到餐厅比喻:服务员(一个线程)走到 3 号桌戳 A 菜,poll 阻塞了——他站在 3 号桌厨房门口干等 A 菜出锅,哪儿也不去。结果 5 号桌、吧台、其他几十桌全被晾着,没人伺候。这一个服务员原本靠”谁没好就跳过”同时照顾几十桌,现在被一桌卡死,整个并发能力归零——等价于退回”一个线程伺候一桌”的同步模型,异步的意义荡然无存。所以 poll 必须”非阻塞、Pending 就立刻放手”,运行时才能转去推进别的任务,这正是”一个线程跑万千任务”的命脉。Pending 不是”我在等”,是”我先让位,好了再叫我”。

  2. 如果状态机都在堆上由 GC 管地址,那么即便对象被移动(GC 整理内存时复制),GC 也会同步更新所有指向它的引用,内部自引用指针不会悬垂——于是**Pin 就没必要了**,语言替你在运行时把这个麻烦抹平了。Rust 不这么干,它”省”下的是:没有 GC 的运行时开销、没有堆分配的必然成本、保留了零成本抽象(状态机可以生在栈上、不花分配的钱)。它”付出”的是:把”地址稳定性”这件底层事实暴露成 Pin 这个类型约束,让写底层异步的人必须面对、必须理解自引用与移动的冲突。一句话:GC 语言用”运行时的钱 + 隐式处理”买走 Pin 的麻烦;Rust 用”把麻烦摊到类型系统”保住零成本。这是同一个取舍的两种解法。

  3. 两条路线的账:Go(焊死)赚的是”开箱即用、心智极简”——go func() 一行就起协程,调度器、网络轮询全内置,新手几乎零门槛,写高并发非常顺;亏的是”没得选”——调度策略、运行时行为语言定死,你想要极致定制的控制权拿不到,而且 goroutine 的栈和调度自带一套成本(虽然很低),你无法剥离。Rust(自选)赚的是”极致可控 + 零成本”——运行时可选(tokio/async-std/自研)、可换、可裁剪,没有强制的 GC 式开销,调度权完全在你手,适合延迟敏感、资源受限的系统;亏的是”入门劝退”——你得自己引入并理解运行时、Pin、执行器那一套,认知负担显著高于 Go。墨叔的判读:做业务后台求快上线,Go 的”焊死”是真香;做底层基础设施求确定性和控制权,Rust 的”交给你”才值钱。两条路没有高下,只有场景适配。

小结

Future 本质是个可轮询、非阻塞、能暂停恢复的状态机;async/await 把这摊状态机代码自动化成顺溜的同步风格;Pin 因自引用不可移动而生,是 Rust 零成本哲学的代价;tokio 这类运行时负责真正去”戳”Future、做调度。Rust 的异步难,是因为它拒绝偷偷引入 GC 和内置运行时、把每一个取舍摊在你面前——难,但是克制而值得。