首页 › programming › Future什么都不会做:拆解Rust异步运行时

Future什么都不会做:拆解Rust异步运行时

导语:一个"什么都没做"的函数

写 Rust 的人几乎都敲过 #[tokio::main] async fn main(),然后在函数体里撒满 .await。看起来像魔法——没有线程、没有回调地狱,却能同时撑住几万个网络连接。但只要你认真追问一句"这一行 .await 到底发生了什么",大多数人会卡住。死锁、莫名其妙的延迟、永远跑不完的任务……这些生产环境的噩梦,根源几乎都是同一个:把异步运行时当成了黑盒。

这正是 Rust Async Runtime Explained 这类讲解视频值得反复看的原因。它不教你写 async/await 的语法,而是把那个黑盒撬开:#[tokio::main] 实际展开成了什么?一个 async fn 编译后到底是个什么东西?为什么"阻塞一个 async 线程"是整个 Rust 异步生态的头号禁忌?本文以视频的概念拆解为骨架,再叠加 Tokio 官方工程博客里那场著名的"调度器提速 10 倍"重写的硬核细节,带你从 epoll 一路爬回到 .await。读完之后,你至少能讲清楚一句反常识的结论:一个 Future,在被 poll 之前,什么都不会做。

视频背景卡片

项目 内容
视频标题 Rust Async Runtime Explained
平台 / 链接 YouTube(watch?v=XpQpij6hWHc)
频道 Rust 技术讲解类频道(覆盖并发与底层原理)
核心主题 从 futures、executor 到 work-stealing 调度器,拆解 Rust 异步运行时底层
内容定位 原理深拆,配套本文补充 Tokio 官方调度器重写数据
备注 播放量/时长以视频页面为准;本文数字另引自 Tokio 官方博客与深度技术文献,已标注来源

说明:受搜索配额限制,频道名与精确播放量无法逐一核实,故未填具体数字以避免编造。本文所有技术结论与基准数据均来自可追溯的官方/权威来源(见文末),可放心引用。

核心内容拆解

要点一:#[tokio::main] 不是魔法,只是一个宏展开

视频开篇就拆穿最常见的错觉:#[tokio::main] 看起来像是语言特性,其实它只是个过程宏(proc-macro)。你写的:

#[tokio::main]
async fn main() {
    do_stuff().await;
}

编译期会被改写成(多线程版本大致如此):

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build().unwrap()
        .block_on(async { do_stuff().await; });
}

三件事就此发生:第一,构建一个 Runtime,它会拉起 worker 线程、IO 驱动(IO driver)、计时轮(timer wheel)——整台机器;第二,调用 block_on,它是同步世界(fn main)与异步世界之间唯一的桥梁,会阻塞当前 OS 线程直到传入的 future 完成;第三,你的异步代码从此被运行时托管。

关键洞察是:block_on 本身就是一个循环——"只要 future 没完成,就 poll 它;如果 poll 返回 Pending,就等 IO 事件;事件来了就唤醒相关任务;周而复始"。这个循环就是运行时本身,其余一切都是为了让它跑得快的配套设施。(以上为对视频论点的转述与延伸。)

要点二:async fn 是一台伪装成函数的"状态机"

这是理解整个 Rust 异步模型的钥匙,也是它与 Go、Java、Python 等语言最大的分野。当你写下:

async fn fetch_user(id: u64) -> User {
    let row = db.query(id).await;       // 让出点 1
    let profile = api.get(id).await;    // 让出点 2
    User::from(row, profile)
}

编译器不会创建线程,而是生成一个状态机枚举(enum):

enum FetchUserFuture {
    State0 { id: u64 },                       // 第一个 await 之前
    State1 { id: u64, row: DbRow },           // 正在等 api.get()
    Done,
}

每一个 .await 都是一个"让出点(yield point)"——一个 future 对运行时说"我现在没法继续,待会儿再来"的地方。编译器把恢复执行所需的一切(局部变量、上次停在哪)都塞进那个 enum 里。没有运行时栈,没有协程帧的保存/恢复开销——这就是 Rust 标榜的"零成本抽象(zero-cost abstraction)"。

但零成本的代价是:future 是惰性的(lazy)。一个躺在内存里的 future 什么都不做,直到有人来 poll 它。Tokio,就是那个负责 poll 它的角色。这一点和 JS 的 Promise 截然不同——JS 的 Promise 一创建就开始执行,而 Rust 的 Future 必须被驱动。(视频原话强调"future 是 lazy 的",延伸分析补充了与 JS Promise 的对比。)

要点三:poll——一个 Future 的全部一生

整个 Rust 异步生态都建立在一个方法上:

fn poll(&mut self, cx: &mut Context) -> Poll<Output>

它只有两种返回:Poll::Ready(v) 表示完成、值已就绪;Poll::Pending 表示"还没好,回头再来"。一个 future 的生命周期就是被反复 poll 的过程:就绪 → 完成;或者 pending → 沉睡 → 被唤醒 → 再 poll。

这里藏着一个反直觉的设计哲学:谁来决定"待会儿"? 不是 future 自己,也不是调度器去定时轮询,而是一个叫 Waker 的东西。poll 在返回 Pending 时,会顺便把一个 cx 里的 Waker 注册到它正在等待的资源上(一个 socket、一个定时器、一个 channel)。然后 future 就真的"睡死"过去——不占 CPU、不占线程。等操作系统说"你等的那个 socket 有数据了",IO 驱动就找到当初注册的 Waker,调用它,任务就被重新塞回队列。零空转、零浪费——任务只在真有事可做时才运行。(此为视频与权威文献一致的核心机制,已确认。)

要点四:Waker 与 IO 驱动——"候诊室"模型

视频里有一个非常贴切的比喻,我直接借用并展开:把调度器想象成"工作车间",把 IO 驱动想象成"候诊室"。

一个任务执行到 socket.read().await、而数据还没到时,它就走进候诊室坐下。它一直坐在那里,直到 OS 说"数据到了",IO 驱动就把它推回车间,由某个 worker 接手继续 poll。如果没有这个候诊室,每个 worker 都会傻傻地阻塞在网络数据上,新请求根本进不来。

以一句 reqwest::get("https://api.com/data").await 为例,你从没碰过 socket,但底层发生了:

  1. Worker 1 poll 你的任务 → reqwest 内部打开 TCP 连接、发送 GET、等待响应 → 返回 Poll::Pending;IO 驱动把"fd#5(该 socket)→ 你的任务的 Waker"登记在册;Worker 1 立即空出来去干别的活。
  2. 100ms 后 api.com 回包 → OS 通知 IO 驱动"fd#5 有数据" → waker.wake() → 你的任务被重新入队。
  3. Worker 2 接手 poll → reqwest 读取字节、解析 HTTP → 返回 Poll::Ready(resp)。

你看到的只是一行带 .await 的代码;背后是 socket 建立、IO 驱动守望、任务沉睡、OS 通知、任务复活的全过程,对你完全透明。在 Linux 上,这个"候诊室"靠的是 epoll(macOS 是 kqueue、Windows 是 IOCP),而更新的 Tokio 版本也在逐步整合 io_uring。(视频/技术文献一致。)

要点五:work-stealing 调度器——为什么 Tokio 快

到这里就到了视频和官方博客都浓墨重彩的部分。Tokio 的多线程运行时默认每个 CPU 核心起一个 worker 线程,每个 worker 各自带一个本地运行队列(local run queue)。如果只是"每线程一个队列",立刻会遇到麻烦:一个线程被任务淹没,其他线程却闲得发慌。

work-stealing(工作窃取)就是解药:当一个 worker 的本地队列空了,它不会立刻睡觉,而是去"偷"兄弟线程队列里的一半任务。

线程1队列: [A][B][C][D][E]   ← 过载
线程2队列: []                ← 空闲
线程3队列: [F]

线程2发现自己闲 → 偷走线程1一半的任务:
线程1队列: [A][B]
线程2队列: [C][D]            ← 偷来的
线程3队列: [F]

自动负载均衡,没有中心协调器,没有锁竞争——拥有者从队首 pop,窃取者从队尾 steal。这正是 Tokio 在混合负载下表现稳健的根本原因:CPU 密集任务不会饿死 IO 密集任务,因为闲下来的线程会主动找活干。这也是 Go、Erlang、Java(虚拟线程)等几乎所有现代通用运行时不约而同选择 work-stealing 的原因。(视频/官方博客一致。)

要点六:2019 那场"提速 10 倍"的调度器重写

如果上一节是"是什么",这一节就是"凭什么这么快"。2019 年 10 月,Tokio 作者 Carl Lerche 提交了一次调度器的彻底重写,官方博客标题就叫《Making the Tokio scheduler 10x faster》(这一节的全部数字均引自该博客)。主题可以用一个字概括:减(reduce)——"最快的代码,是不存在的代码"。

重写做了几件极其讲究工程的事:

结果如何?微基准里,chained_spawn(递归 spawn)从约 2,019,796 ns 跌到 168,854 ns(约 12 倍);ping_pong(最接近真实世界的 channel 收发)从约 1,279,948 ns 降到 562,659 ns(约 2.3 倍)。换到真实栈——用 wrk 压测 Hyper 的 "hello world" 服务,每秒请求数从 113,923 涨到 152,258(+34%);Tonic(gRPC)也快了约 10%。Carl Lerche 坦言他原本只预期 5–10% 的提升。(以上数据全部来自 Tokio 官方博客,属延伸补充。)

要点七:永远不要阻塞一个 async 线程——以及"Drop 即取消"

视频反复强调的头号实战铁律:worker 线程是共享的,阻塞它,队列上挂着的所有任务一起陪葬。

什么叫阻塞?std::thread::sleep、std::fs::read_to_string(同步文件 IO)、一个跑一百万次的 CPU 循环、一个长时间持有的 std::sync::Mutex。正确做法是:

场景 错误 正确
睡眠 std::thread::sleep tokio::time::sleep
文件 IO std::fs::read tokio::fs::read
CPU 密集 直接写在 async 里 tokio::task::spawn_blocking
跨 .await 的锁 std::sync::Mutex tokio::sync::Mutex

spawn_blocking 会把阻塞活儿丢到一个专门的阻塞线程池——那个池允许阻塞——从而让 async worker 保持空闲流畅。

另一个常被忽略的点是取消语义:在 Tokio 里,Drop 就是取消。 没有 cancel() 方法。一个 future 被 drop 时,它的状态机被销毁、当前 .await 点的工作被抛弃、没有自动清理。如果它正握着一个写了一半的数据库事务,这个事务是"被遗弃"而非"回滚"。这正是"取消安全(cancellation safety)"如此重要的原因,也是 tokio_util::sync::CancellationToken 这类优雅取消工具存在的意义。(视频/技术文献一致。)

知识结构图:Tokio 运行时全景

flowchart TB
    subgraph 编译期["编译期(零成本转换)"]
        A["async fn fetch_user()"] -->|"编译器改写"| B["状态机 enum Future<br/>每个 .await = 让出点"]
    end

    B -->|"tokio::spawn 包装"| T["Task = Future + Waker + 状态<br/>NOTIFIED/RUNNING/SLEEPING"]

    subgraph 运行时["Tokio Runtime(block_on 循环)"]
        direction TB
        subgraph 调度器["Work-Stealing 调度器(每核一个 worker)"]
            W1["Worker1<br/>本地队列 A,B"] -.->|"空了去偷一半"| W2["Worker2<br/>本地队列"]
            W2 -.->|"偷"| W3["Worker3<br/>本地队列"]
            NEXT["next-task 槽<br/>消息接收者优先"] -.-> W1
        end

        T -->|"入队"| 调度器

        subgraph 候诊室["IO 驱动(候诊室)"]
            IO["Linux: epoll<br/>macOS: kqueue<br/>Windows: IOCP<br/>(+ io_uring)"]
        end

        subgraph 计时["Timer 驱动"]
            TM["分层计时轮<br/>deadline 命中"]
        end

        subgraph 阻塞池["阻塞线程池(独立)"]
            BP["spawn_blocking<br/>允许阻塞的 OS 线程"]
        end
    end

    调度器 -->|"poll 返回 Pending<br/>注册 Waker"| 候诊室
    调度器 -->|"poll 返回 Pending<br/>注册 Waker"| 计时
    候诊室 -->|"数据到达 → waker.wake()"| 调度器
    计时 -->|"超时 → waker.wake()"| 调度器
    调度器 -->|"阻塞活儿丢出去"| 阻塞池
    阻塞池 -->|"完成后唤醒"| 调度器
    调度器 -->|"poll 返回 Ready"| DONE["任务完成 ✓"]

实战启示:把"黑盒"变成"心智模型"

1. 永远先问"这是阻塞调用吗?" 读到任何一行 async 代码,养成反射:这里有没有可能阻塞 worker?凡是 std::thread::sleep、std::fs::*、同步 Mutex 长持有、纯 CPU 死循环,都是红灯。养成静态扫描这几类模式的习惯,能挡掉大半生产事故。

2. 用"候诊室"模型诊断延迟。 当一个请求莫名变慢,画出它的旅程:它在哪一步进了候诊室(Pending)?是不是某个 IO 驱动没及时唤醒?是不是它前面排着一个偷偷阻塞了 worker 的邻居任务?有了这套模型,"间歇性延迟""任务永远不完成"就不再是玄学。

3. work-stealing 解释了"为什么 Rust 服务抗突发"。 你的服务平时几个连接、突发几万个,Tokio 不需要提前开几万个线程——M:N 模型把海量任务多路复用到少数 OS 线程上,闲核自动偷活。这意味着你可以在内存允许的范围内把并发拉得极高,但前提是任务真的会 await、会主动让出。

4. 取消安全要主动设计。 既然 Drop 即取消,任何跨 .await 持有资源(锁、事务、buffer)的代码都要想清楚"如果在这一行被 drop 会怎样"。需要清理就用 CancellationToken + select!,而不是裸依赖 Drop。

5. 基准意识。 2019 那场重写告诉我们:调度器的胜负手在 cache line、原子序、热路径上"少做一次操作"。你写的高并发服务,瓶颈往往不在业务逻辑,而在"每个任务多了一次原子加减、多了一次分配"。学会用 cargo flamegraph、Tokio Console 看任务调度,比死磕业务代码更划算。

6. 选对运行时形态。 current_thread(单线程,无 work-stealing、无 Send 约束,适合 CLI、嵌入式、极致低延迟)vs multi_thread(默认,多核扩展)——这不是"哪个更高级",而是"你的负载形态是什么"。CPU 密集选错会白白付同步开销;IO 密集选单线程能省掉所有跨核窃取。

延伸学习路径

  1. Tokio 官方博客《Making the Tokio scheduler 10x faster》(Carl Lerche,2019)——本文调度器一节的原始出处,详细到队列的原子序选择,是写调度器的人的必读。
  2. Rust 官方 Asynchronous Programming in Rust(async-book)——尤其是"Build an Executor"一章,亲手写一个能跑的极简 executor,是理解 poll/Waker 最快的路。
  3. 《Rust Tokio Runtime Deep Dive — Future, Waker, Work-Stealing, Pin, Zero-Cost Async》(Youngju Kim,2025/2026)——把 state machine、Pin、自引用结构讲透的中文友好长文。
  4. 《How Tokio Works: The Async Runtime Explained from the Ground Up》(Rustify / Max)——从 #[tokio::main] 一路到 epoll 的全景式拆解,本文概念框架的重要来源。
  5. Tokio 源码与 DeepWiki 文档——读 scheduler/multi_thread/worker.rs 与 scheduler/queue.rs,对照本文要点六,你会看到那些优化在真实代码里长什么样。

一句话总结

Rust 异步的精髓不在 async/await 语法,而在一个反常识的事实——Future 只是一台被编译器生成的惰性状态机,在被 poll 之前什么都不会做;而 Tokio 用一个 work-stealing 调度器加一套 epoll 候诊室,把成千上万个这样的状态机零成本地调度到几个线程上。

参考来源

  1. Rust Async Runtime Explained(YouTube) — 本文分析的核心视频,提供 futures/executor/调度器的概念骨架
  2. Making the Tokio scheduler 10x faster — Tokio 官方博客 — work-stealing 重写的全部技术细节与基准数据(chained_spawn 12×、Hyper +34% RPS)
  3. How Tokio Works: The Async Runtime Explained from the Ground Up — Rustify — #[tokio::main] 展开、候诊室模型、work-stealing 与常见陷阱
  4. Rust Tokio Runtime Deep Dive — Future, Waker, Work-Stealing, Pin, Zero-Cost Async — Youngju Kim — state machine 转换、Pin 与自引用结构的深度补充
  5. Asynchronous Programming in Rust — Rust 官方 async-book(Build an Executor) — Future 的惰性与手写 executor 的权威说明

本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵爱卫生(微信ID: gprshome201101)维护,聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。


完成摘要(What was done)

下一步计划(What's next)

  1. 若需精确视频元数据:可在搜索配额恢复后用 WebFetch 直接抓取 watch?v=XpQpij6hWHc 页面,回填频道名、时长、播放量到背景卡片(优先级:高,仅修正一处)。
  2. 若想升级视频版本:可考虑把锚点换成更长的 Tokio: Under the Hood Deep Dive(Ooun8wbAsPw),内容更系统,但需先验证其热度(优先级:中)。
  3. 延伸选题(Round 13 将轮到 business):可提前储备如"LMX/二级市场做市商算法""Stripe 的支付编排引擎"等未被覆盖的商业+技术交叉主题(优先级:低)。

注意事项(If applicable)

[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。

51学通信知识星球海报-扫码加入