Skip to content

第1章 Tokio 在 Rust 异步生态中的位置

"To understand where a system is going, you must know where it came from and what paths it considered but did not take." —— 笔者

本章要点

  • Rust 异步模型的一个独特选择:语言定义 Future trait,运行时留给生态
  • Tokio 从 2016 年的 mio 一路演化到 2026 年 1.40 版本的关键技术节点
  • Tokio 与 smol、async-std、monoio、glommio 的真实差异(不是"哪个更好",而是"哪个场景用哪个")
  • Tokio 的顶层架构:Runtime、Scheduler、Driver、Task 四大子系统,以及 Waker 这个贯穿始终的"神经"
  • 一次 .await 在这张架构图上的完整走位
  • 本书后续 20 章如何对应这张地图的每个区域

1.1 一个反直觉的选择:Rust 把运行时踢出了语言

写过 Go 的人会习惯一件事:你写 go f(),就有一个 goroutine 被调度、被栈管理、被 GMP 分派;你写 time.Sleep(...),就有一个 timer 被 runtime 管理;你写 conn.Read(...),底下是 netpoller 在用 epoll/kqueue 等你的数据。

所有这些"运行时基础设施"都藏在了 Go 的语言运行时里。你不需要导入任何库,gochanselect 是 Go 关键字,runtime 是标准库的一部分。这给 Go 带来了无与伦比的易用性,也带来了"所有 Go 程序都带着一个同款运行时"的代价——当你想在嵌入式设备上跑 Go、或者想让 Go 程序不自带 GC,你就会撞墙。

Rust 做了一个反过来的选择。Rust 语言只给了你两样东西:

  1. Future trait(定义在 core::future 中):一个可以被 .poll() 的状态机抽象
  2. async/await 语法糖:编译器把 async fn 展开成实现了 Future trait 的匿名状态机类型

语言不给你调度器、不给你 I/O 多路复用、不给你定时器、不给你 channel。这些全部留给第三方 crate(也就是"运行时")去实现。

这个选择在一开始被很多人批评为"增加了心智负担"、"碎片化"。但它同时换来了几个非平凡的好处:

  • 嵌入式/WASM 可用:在没有 OS、没有标准线程、没有 epoll 的环境里,你依然可以写 async fn 并自己实现一个极简的执行器。embassy 就是这么来的
  • 运行时可替换:你可以用 Tokio 跑通用后端,用 monoio 跑 thread-per-core 高性能场景,用 embassy 跑单片机,语言是同一套语言
  • 零运行时成本async fn 本身被编译成一个状态机结构体,没有额外的线程、没有额外的 GC、没有额外的栈。在嵌入式上甚至可以不使用堆
  • 语言核心可以稳定演进:因为运行时是生态的事,语言核心(Future trait)可以保持简洁、稳定,而生态可以快速迭代

代价也是真实的:

  • 你必须显式选一个运行时Cargo.toml 里没有 tokio = "1",你的 .await 就动不起来
  • 不同运行时之间有生态壁垒:一个库如果明确依赖 Tokio 的 reactor,就不能直接在 smol 上跑
  • 初学者的学习曲线陡峭:你得理解"语法糖展开 + 语言提供的 Future trait + 社区提供的运行时"这三件事的分工

Tokio 就是在这个"把运行时留给生态"的空位上长出来的。而且它长到了一个地位:绝大多数 Rust 后端代码都在 Tokio 上跑。本书的任务是把这个"绝大多数人都在用、但极少数人真正理解"的运行时讲透。

Rust 这个决策的代价和红利一起清算

我们已经看到了代价——生态碎片化的风险、陡峭的学习曲线、运行时兼容性问题。那红利是什么

红利一:Rust 可以在没有堆的环境里跑异步代码 STM32、RP2040、ESP32 这类 MCU 没有 malloc。Python / JS / Go 的异步模型都依赖堆分配——Rust 不依赖embassy 运行时让你在 32 KB RAM 的单片机上跑 async Rust——这是其他语言做不到的。

红利二:WebAssembly 场景有原生支持 WASM 没有线程(大部分情况下)、没有传统 epoll,只有 event loop。Rust 的 Future 模型天然适应这种环境——wasm-bindgen-futures 把 Rust Future 桥接到 JS 的 Promise / event loop,代码可以在浏览器和服务端共用同一套 async 代码

红利三:同一套 async 代码可以跑在完全不同的运行时上 如果你的库用 AsyncRead / AsyncWrite trait 而不是 Tokio 具体类型,它可以被 Tokio、smol、monoio 用户使用。好的库作者会尽量泛化运行时,保留灵活性。坏的库作者会绑死 Tokio(当然大部分时候这也没问题)。

红利四:运行时本身是可演化的 JavaScript 的 event loop 模型 20 年没变过。Tokio 调度器每两三年大改一次(见第 5 章)。语言不绑运行时,意味着运行时可以快速进化

一句话:Rust 把运行时留给生态这个选择,是长期思维——短期看它付出了 7 年才成熟的代价,但换来的是十年、二十年维度的灵活性。这种"牺牲短期换长期"的工程决策,正是 Rust 设计哲学里最珍贵的部分。

1.2 Tokio 的前世:从 mio 到 1.40 的十年

要理解 Tokio 为什么是现在这个样子,得看它是从什么演化过来的。这不是掉书袋——很多 Tokio 当下的设计决策,只有知道它的历史才能理解"为什么不是另一种做法"。

2016:mio —— 一切的起点

Tokio 还不存在时,Carl Lerche 先写了 mio(Metal I/O)。mio 做一件事:把 Linux 的 epoll、macOS/BSD 的 kqueue、Windows 的 IOCP 抽象成一套统一的跨平台非阻塞 I/O API

mio 本身没有"Future"、没有"async"的概念,它的 API 长这样:

rust
// 伪代码,示意 mio 早期的 API 形态
let poll = Poll::new()?;
let listener = TcpListener::bind("127.0.0.1:8080")?;
poll.registry().register(&listener, Token(0), Interest::READABLE)?;

let mut events = Events::with_capacity(128);
loop {
    poll.poll(&mut events, None)?;
    for event in &events {
        match event.token() {
            Token(0) => { /* accept a new connection */ }
            _ => { /* handle other events */ }
        }
    }
}

mio 是基础层。它的作用类似于 Node.js 的 libuv、Go runtime 的 netpoller:把操作系统级的事件通知机制统一成一套 API。

直到今天,Tokio 内部的 I/O Driver 依然建立在 mio 之上。你会在本书第 9 章看到 mio 的具体源码。

2016-2018:futures 0.1 + tokio 0.1 —— 基于 poll 函数的第一代

2016 年底,Aaron Turon、Alex Crichton 在 mio 之上提出了 futures crate——也就是 Future trait 的第一个版本:

rust
// futures 0.1 的 Future trait(简化)
pub trait Future {
    type Item;
    type Error;
    fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
}

注意这个版本:没有 Pin、没有 Waker、没有 Context。唤醒机制是通过 thread-local 存储的"当前任务句柄"实现的。这个设计简单,但有一些问题(比如不好实现自引用状态机、跨线程唤醒需要 wrapper)。

Tokio 0.1 建立在 futures 0.1 之上,提供了第一个可用的 Rust 异步运行时。但那个年代的 Rust 异步写起来很痛苦:

rust
// Tokio 0.1 时代的写法(无 async/await)
let task = socket
    .read_to_end(buf)
    .and_then(|(socket, buf)| {
        process(&buf).into_future()
    })
    .map(|result| { /* ... */ })
    .map_err(|e| { /* ... */ });
tokio::spawn(task);

这种"and_then / map / map_err 链"的写法被称为 "combinator 风格"。它能跑,但对初学者极其不友好,错误类型传递复杂,嵌套 Future 的时候更是一场噩梦。

2019:std::future::Future + async/await 语言级支持

2019 年是分水岭。Rust 1.36 把 Future trait 稳定到了 std::future 模块,Rust 1.39 把 async/await 语法稳定。新的 Future trait 长这样:

rust
// std::future::Future(今天依然是这个样子)
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

三个关键变化:

  • Pin<&mut Self>:支持自引用状态机(async fn 展开出来的状态机经常包含自引用)
  • Context<'_>:运行时通过 Context 向 Future 传递 Waker,而不再依赖 thread-local
  • 没有 Error 关联类型:错误用 Output = Result<T, E> 表达,简化了 trait

这三个变化让 Tokio 有了重生的机会。但它也意味着 Tokio 0.1 的大量代码和用户代码需要重写。

2020:Tokio 1.0 —— 工业级稳定

2020 年 12 月,Tokio 1.0 发布。这是第一个做出稳定性承诺的版本:Tokio 1.x 不会再有 breaking change。这个承诺到今天(2026 年)依然有效,Tokio 1.40 依然在 1.x 系列里。

Tokio 1.0 的核心设计在今天依然有效:

  • Runtime 是顶层抽象,封装了 Scheduler + 各 Driver
  • 两种调度器current_thread(单线程)和 multi_thread(多线程 + 工作窃取)
  • Task 是调度的单位,spawn 返回一个 JoinHandle
  • I/O Driver 基于 mio,封装 epoll/kqueue/IOCP
  • Time Driver 基于分层定时器轮(hierarchical timing wheel)
  • 同步原语 独立于 std::sync,提供 async 版本的 Mutex、Semaphore、Notify
  • channels:mpsc、broadcast、watch、oneshot 四大类

2020-2026:持续精进

Tokio 1.0 之后的 5 年多,主干没有大改动,但有许多关键的增量改进:

  • Scheduler 性能:1.6(2021)的"LIFO slot" 优化、1.15(2022)的本地队列改进
  • tokio-console:1.7(2021)引入的 tracing 整合,让可观测性工具成为可能
  • Runtime metrics:1.15(2022)起开放 worker 队列深度、阻塞线程池利用率等运行时指标
  • JoinSet:1.21(2022)引入的任务集合管理,替代手写 FuturesUnordered
  • tracing 深度整合:从 1.7 开始所有关键调度点都可以被追踪
  • runtime builder 细粒度配置:worker 线程数、blocking 线程数、事件间隔、公平性、线程名等全部可配
  • io_uring 实验性支持tokio-uring 作为补充存在,但 Tokio 主线仍然以 epoll 为主

Tokio 1.40(2026 年 4 月) 是本书的分析锚点。它的架构在 1.0 的骨架上增量演化,理解了 1.0 的设计,后续版本对你不再陌生

1.2½ 为什么 Rust 异步比其他语言成熟得慢

读完 1.2 节的演化史,你可能会注意到一个反常现象:JavaScript 在 2011 年就有了 Promise,Python 3.5 在 2015 年稳定了 async/await,Rust 却等到 2019 年——而且从 2019 的 async/await 稳定到 2020 的 Tokio 1.0 还花了一年多,整个异步生态完全"生产可用"实际上要到 2022 年。Rust 异步的成熟速度比其他主流语言慢 5-7 年,这不是因为 Rust 社区不努力,而是因为 Rust 在异步这件事上刻意选择了最难的路

几个关键原因:

原因一:零成本抽象的硬约束 JavaScript 的 Promise 是个堆分配的对象,里面有回调链、微任务队列等等。Python 的 coroutine 是个有栈的 generator。这些方案都不符合 Rust 的"零成本抽象"原则——Rust 不允许 async 引入任何"必不可少的运行时开销",包括 GC、绿色线程栈、强制堆分配。满足这个约束的唯一路径是把 async 编译成一个栈上状态机——这个 idea 直到 2017 年的 generators RFC 才被系统性论证,之后还需要编译器的大量 MIR 工作。

原因二:Pin 难题 上面状态机方案会自然产生自引用结构体(状态里既有 buffer 又有 &buffer)。自引用和 Rust 的 move 语义冲突——move 一个自引用结构会让内部指针悬垂。解决这个需要发明 Pin 这个新原语——而 Pin 本身的 API 设计被讨论了 2 年才稳定,期间反复 RFC。《Rust 编译器与运行时揭秘》第 10 章详拆了 Pin 的完整语义。

原因三:trait 系统的缺环 2015-2017 的 Rust 类型系统里缺了几个支撑异步 trait 的关键特性:

  • GAT(Generic Associated Types)—— async trait 方法的返回类型需要携带生命周期,2022 年才稳定
  • impl Trait in trait 方法 —— 2023 年才稳定
  • Type-level async—— 部分特性至今还在 nightly

Rust 社区不愿意在这些基础不牢的情况下稳定 async trait——代价是等待,但换来的是长期的 API 稳定。

原因四:Rust 的民主化开发模式 Rust 所有重要决策都走 RFC 流程,几十人到几百人讨论数月。JavaScript / Python 没有这种负担——BDFL 或少数核心团队拍板。民主化慢但稳,最终决策质量更高。

总结:Rust 异步是业界最野心勃勃的异步模型——要零成本、要类型安全、要编译期验证。这些目标之间相互制约,每个都需要语言和生态多年打磨。今天你写一行 .await 能跑起来、能跨线程、能零开销,是 2015-2022 这七年社区耐心的结果。Tokio 作为这个生态的主运行时,每一个设计决策都浸泡在这段历史里——读它的源码时带着这份历史感,你会看到更多东西。


1.3 Rust 异步运行时的横向对比

读到这里你可能会问:既然语言把运行时留给生态,那除了 Tokio,还有什么?为什么 Tokio 最终占了主导?其他运行时在什么场景下有优势?

下面这张表梳理了 2026 年 Rust 异步生态的主要运行时:

运行时定位核心差异点典型场景
Tokio通用、工业级多线程 work-stealing + epoll 兼容所有平台 + 大而全的生态90% 的 Rust 后端
smol极简、模块化核心只有几千行,executor/reactor/timer 可独立使用需要自定义运行时组合的场景、学习用
async-std模拟 std APIAPI 形状刻意贴近 std,async_std::fs 对标 std::fs已经不活跃(2024 年起 maintenance 模式)
monoiothread-per-core + io_uring每个线程一个独立的 executor + reactor,不做工作窃取;强绑 io_uring字节跳动内部的高性能 RPC/存储服务
glommiothread-per-core + io_uring和 monoio 类似思路,Datadog 主推;调度更偏向任务优先级延迟敏感的数据库、Scylla 等
embassy嵌入式、no_std无需堆、无需线程、直接用 interrupt 作为 reactorSTM32、RP2040 等单片机
pollster最小化阻塞 executor几十行,给单 Future 用 block_on不需要运行时的场景(脚本、工具)

每一个运行时的深层定位

Tokio 的定位:通用后端。作者 Carl Lerche 2016 年开始写,现在是 Discord、AWS、Deno、Linkerd、字节跳动 Volo、Meta(部分服务)的底层。Tokio 代表 Rust 异步生态的"主流答案"——不求最极端的性能,但要最稳定的生产体验。

smol 的定位:教育 + 模块化。作者 Stjepan Glavina 是 async-std 早期核心贡献者,后来觉得 async-std 太重,做了 smol 这个精简版。smol 的代码非常可读——大约 3000 行核心代码,推荐所有想理解运行时原理的人读一遍 smol 源码作为 Tokio 的"轻量对照组"。

async-std 的定位(已 deprecated):2019 年试图做"异步版 std"——API 刻意和 std 对齐,让 std::fs::File::openasync_std::fs::File::open 只差一个 .await。理念好但失败了:维护团队在 2023 年明确 deprecate 了,主要因为:

  • 生态引力都跑去了 Tokio
  • 核心维护者精力不足
  • 性能调优投入不如 Tokio

一本书很少讲 failed project,但 async-std 的失败有教益:生态战争里"API 亲和"比不过"生态引力"。这是 Rust 生态的一个真实伤疤。

monoio 的定位:极致延迟。字节跳动出品,专门为"每核一个线程 + io_uring"架构而生。适用场景:高频交易、搜索引擎内部 RPC、大规模网关。代价是生态单薄、不跨平台(只 Linux + 较新内核)。

glommio 的定位:同 monoio,但更偏数据库场景。Datadog 主推,ScyllaDB 用它。和 monoio 在架构上几乎一样,差异在 API 风格和社区归属——你选哪个取决于你和哪个社区更贴近。

embassy 的定位:嵌入式 / no_std。跑在 MCU 上。绝对的特殊环境专用运行时

pollster 的定位:最简 executor(约 30 行)。不支持 I/O、不支持定时器。用途:给测试代码或纯计算 Future 一个 block_on 实现,不想引入 Tokio 那么重的依赖。

为什么 Tokio 赢了通用场景?

  1. 历史先发:Tokio 是第一个有完整工业栈的 Rust 运行时,早期的 Rust 后端项目(hyper、reqwest、tonic、axum)全部绑定 Tokio,形成了生态锁定
  2. 性能足够好:对于 99% 的业务场景,Tokio 的多线程 work-stealing 调度器已经够快;thread-per-core 的理论优势只在极端场景(延迟敏感、尾延迟敏感)才显现
  3. 可观测性工具链完整tokio-consoletracing 整合、runtime metrics,线上排错所需的全套工具齐全
  4. 稳定性承诺:Tokio 1.x 保证无 breaking change,这对生产项目至关重要

什么时候你应该用 Tokio 之外的运行时?

  • 你在做 io_uring 下的极致尾延迟优化,且接受放弃跨平台 → monoio / glommio
  • 你在做嵌入式,跑 no_std 环境 → embassy
  • 你做一个纯工具类程序,引入 Tokio 太重 → pollster + 少量手写 executor
  • 你在做教学、研究运行时设计 → smol

对本书读者而言,Tokio 是最值得深入学习的对象:学懂 Tokio,其他运行时你都能看懂,反之则不一定成立。

1.3½ Tokio 生态全景:站在巨人肩膀上的一整座大厦

Tokio 本身只是运行时,但真正让你用起来爽的是 Tokio 带动的整个生态。2026 年的今天,一个中高端 Rust 后端服务的典型依赖长这样:

层级代表库依赖 Tokio 的什么
HTTP 服务器axum / actix-web / warpTcpListener、Task、异步 I/O
HTTP 客户端reqwest / hyperTcpStream、timer、并发控制
gRPCtonicHyper + HTTP/2
WebSockettokio-tungsteniteTcpStream
数据库sqlx / sea-orm连接池、异步 I/O
Redisredis-rs(async) / fredTcpStream、RESP 协议
消息队列lapin(RabbitMQ)/ rdkafka(Kafka)TcpStream、async channel
对象存储aws-sdk-rust / object_storeHyper + 信号量限流
日志 / 追踪tracing / tracing-subscriberTask-local、async span
序列化serde / rmp-serde和运行时无关
RPC 框架tarpc / volo(字节跳动)Tokio 全栈

这座"大厦"有 20+ 主要库,它们全部、毫无例外地绑定 Tokio。为什么?

第一个原因:Hyperhyper 是 Rust 的参考级 HTTP 实现,2014 年由 Sean McArthur 发起。它在 0.10 版本(2018)迁移到 Tokio 之后成为 Tokio 生态的最重要锚点——因为几乎所有 HTTP 相关库都依赖 Hyper。Axum、reqwest、tonic、warp——它们都在 Hyper 之上。Hyper 绑 Tokio,于是上游全都绑 Tokio。

第二个原因:兼容成本 一个库如果想同时支持 Tokio 和 smol,需要:

  • 泛型化运行时抽象(写很多 trait bound)
  • 保留多套 I/O 路径(Tokio 和 smol 的 AsyncRead/Write 细节略有差异)
  • 文档要教用户选哪个
  • CI 要跑两个 runtime 的测试

库作者发现"只支持 Tokio 就够用",因为 99% 的用户用 Tokio。这是典型的生态引力效应——一旦某个选项占了大头,新项目因为"一定要能跟已有依赖共存"也选它,比例进一步扩大。

第三个原因:性能和稳定性 Tokio 多年工业级打磨,生产环境稳定性无可争议。选 Tokio 是最小风险选项——招人好招、问题好 Google、生态丰富、性能有上下文。

Tokio 生态大厦的影响:好的和坏的

好的:你做一个新后端项目,tokio = "1" + axum + sqlx + reqwest 三下五除二搭一个完整服务栈。这体验和 Go / Node.js 打平,不再有 2018 年"Rust 写服务太累"的印象。

坏的:如果你想用 non-Tokio 运行时(比如嵌入式 + smol、超高性能场景 + monoio),生态阻力很大——你可能要自己写或移植大量库。Discord 的后端服务团队曾经一度考虑从 Tokio 迁到 monoio,最后放弃了——迁移成本远超性能收益

你的选择:除非你有非常具体的理由(嵌入式、io_uring 极致延迟),否则拥抱 Tokio 生态。这本书教的就是这个生态的心脏。

一个简单例子:5 行代码搭一个生产级 HTTP 服务

rust
// Axum + Tokio,2026 年 Rust 后端的"hello world"
use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

5 行代码,可以扛几万 QPS 的稳定 HTTP 服务。这就是 Tokio 生态的成熟度——而 3 年前同样的功能代码量是这个的 3 倍、需要手动写一堆 Hyper 配置。


1.4 Tokio 的顶层架构地图

现在我们来画 Tokio 的顶层架构图。这张图会贯穿本书 20 章——每一章都在这张图的某一块上深入。

这张图里有五个核心子系统一条贯穿的神经

五大子系统

1. Runtime(运行时容器) 顶层入口。tokio::runtime::Runtime 结构体持有 Scheduler + 各 Drivers 的所有权,Handle 是外部拿到 Runtime 引用的句柄。#[tokio::main] 宏展开后本质上就是 Runtime::new().block_on(async { ... })

第 4 章详细拆解 Runtime 的构建与生命周期。

2. Scheduler(调度器) 把"要执行的 Task"和"可用的线程资源"撮合起来。Tokio 有两种调度器:

  • multi_thread:N 个 worker 线程,每个 worker 有本地队列 + LIFO slot,worker 之间可以相互"偷"任务(work-stealing)
  • current_thread:单线程调度器,更简单、开销更低,适合只需要一个线程跑 async 的场景(比如桌面应用、GUI、测试)

第 5 章拆解 multi_thread,第 7 章拆解 current_thread。

3. Drivers(驱动层) 运行时的"感官"。Tokio 有三类 Driver:

  • I/O Driver:封装 mio,监听 socket 的 readable/writable 事件
  • Time Driver:管理所有的定时器,基于分层时间轮
  • Signal Driver:Unix 信号(SIGINT、SIGTERM)的异步化

Driver 的共同模式:被 epoll_wait / timer_fire 等系统调用"唤醒" → 找到对应的 Waker → 调 wake() → Task 被重新放回 Scheduler 队列

第 8-10 章拆解 I/O Driver,第 11 章拆解 Time Driver。

4. Task 与 Future Task 是调度的单位,不等同于 Future。一个 Task 内部拥有一个顶层 Future(通常是 async fn 展开的状态机),Task 的结构体包含:

  • header(状态标志、引用计数)
  • 调度器回指针
  • 顶层 Future
  • Waker 的 vtable(见下文)

JoinHandle 是用户侧持有的 Task 引用,可以用来 .await 等待 Task 完成、或 .abort() 取消。

第 6 章拆解 Task 结构。

5. 同步与通信原语 不是基于 Future 就能跑得好的。一个 async 任务等锁、等 channel 消息、等 notify,都需要不阻塞当前 worker 线程的实现。Tokio 的 tokio::sync::Mutex 不是 std::sync::Mutex 的 async wrapper,而是完全不同的实现:它用 Semaphore 的公平排队机制,永远不阻塞 worker,只让 Task 挂起。

第 12-13 章拆解这些原语。

一条贯穿的神经:Waker

Waker 是这整张图的神经。它的接口非常简单(就是一个 fn wake(self)),但它把 Task(被调度的对象)、Scheduler(调度器)、Driver(事件源)三者串起来了:

  • Task 被 poll 时,它收到一个 Waker 作为 Context 的一部分
  • Task 把这个 Waker 注册到某个 Driver(I/O Driver 登记"我在等这个 fd 可读",Time Driver 登记"我在等这个时间点")
  • 当 Driver 的事件发生,Driver 调 waker.wake()
  • wake() 的实现内部会把对应的 Task 重新推回 Scheduler 的队列
  • Scheduler 下次调度时会 poll 这个 Task

一个 Waker 是这三方之间的电话线RawWaker + RawWakerVTable 是这条电话线的底层 ABI,它让 Waker 的实现可以是任何东西——不同运行时(Tokio vs smol vs embassy)可以用完全不同的 Task 表达,但都通过同一个 Waker 接口和 Future trait 对话。

第 3 章会把 Waker 拆到 vtable 字节级别。理解了 Waker,你就理解了运行时 ↔ Future 的本质契约。

1.5 一次 .await 在这张图上的完整走位

把前面的架构落到一个具体例子上:

rust
#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut stream = tokio::net::TcpStream::connect("127.0.0.1:8080").await?;
    //                                                               ^^^^^ 这一刻发生了什么?
    let mut buf = vec![0u8; 1024];
    let n = stream.read(&mut buf).await?;
    //                           ^^^^^ 以及这一刻?
    println!("read {} bytes", n);
    Ok(())
}

#[tokio::main] 展开后:

rust
fn main() -> std::io::Result<()> {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()?;
    rt.block_on(async {
        let mut stream = tokio::net::TcpStream::connect("127.0.0.1:8080").await?;
        let mut buf = vec![0u8; 1024];
        let n = stream.read(&mut buf).await?;
        println!("read {} bytes", n);
        Ok::<_, std::io::Error>(())
    })
}

接下来按时间顺序展开每一步:

Step 1:Builder::build() 创建 Runtime。构建过程中:

  • 初始化 multi_thread Scheduler,创建 N 个 worker 线程(默认 N = CPU 核数)
  • 初始化 I/O Driver:内部调 mio::Poll::new() 拿到一个 epoll fd
  • 初始化 Time Driver:创建分层时间轮
  • 每个 worker 启动,在一个 loop 里反复尝试从本地队列、LIFO slot、全局队列拉任务

Step 2:rt.block_on(fut)当前线程(主线程)上运行一个 Future,直到它完成:

  • fut 包装成一个 Task
  • 把这个 Task 注册到 Scheduler
  • 主线程进入一个循环:poll 当前 Task → 如果 Poll::Pending 就让出 CPU 等待唤醒 → 如果 Poll::Ready 就退出

Step 3:Task 被首次 poll。执行到 TcpStream::connect(...).await

  • connect 内部发起一个非阻塞的 socket 连接(connect() 系统调用,设置 O_NONBLOCK
  • 系统调用立即返回 EINPROGRESS——连接还没建立
  • connect 在 I/O Driver 里注册一个 "等待这个 fd 变可写"的兴趣,关联当前 Task 的 Waker
  • 返回 Poll::Pending
  • 控制权交还给 Scheduler,主线程开始等待 epoll_wait

Step 4:三次握手完成。远端回 SYN-ACK,本地内核完成 TCP 三次握手,fd 变为可写:

  • I/O Driver 所在的线程(可能是 worker 0)的 epoll_wait 返回
  • 遍历事件,找到这个 fd,查表拿到关联的 Waker
  • waker.wake(),这个 wake 的实现把 Task 推回到某个 worker 的本地队列
  • 某个 worker(不一定还是 worker 0)pop 到这个 Task,再次 poll

Step 5:Task 被再次 poll。这次 connect() 内部发现 fd 已经可写了:

  • 返回 Poll::Ready(Ok(stream))
  • 用户代码拿到 stream,继续往下走
  • 执行到 stream.read(&mut buf).await
  • read 内部调 recv(fd, buf, MSG_DONTWAIT)
  • 如果内核没有数据:返回 EWOULDBLOCKread 同样在 I/O Driver 注册"等可读"兴趣、返回 Poll::Pending
  • 如果内核已经有数据:直接返回 Poll::Ready(n)

Step 6:block_on 循环捕获最终结果。当最外层 Task 返回 Poll::Ready(())block_on 退出,Runtime drop,worker 线程被 join,进程退出。

这一整个流程里,有三点需要记住

  1. Future 本身是被动的——它只会在 poll 到 Pending 时注册 Waker,然后"死在那里",直到被 Waker 唤醒
  2. Driver 是主动的——它等 OS 事件,然后主动调 Waker 唤醒 Task
  3. Scheduler 是撮合者——它决定哪个 worker 跑哪个 Task、什么时候 park/unpark 线程

本书后续 20 章,就是把这一流程中的每一步钻透

这条路径上每一步的"小时间常数"

把 Step 1-6 对应到现代 CPU 的时间尺度,你会有更深的敬畏:

  • Step 1 build() 初始化运行时:一次完整 build 大约 几百微秒到几毫秒——spawn N 个线程 + epoll fd 创建 + 时间轮初始化。这是 runtime 生命周期里唯一"慢"的一次,之后运行时本身几乎免费
  • Step 2 block_on + 第一次 poll:Future 的第一次 poll 耗时不定——如果它立刻走到 .await 可能只有几十纳秒,如果它做了复杂初始化可能几十微秒
  • Step 3 Future 注册 Waker + 返回 Pending:大约 50-100 纳秒(Waker clone + 注册进 Driver 的数据结构)
  • Step 4 epoll_wait + 事件调度:epoll_wait 系统调用本身 ~1 微秒;拿到事件后找 Waker 并 wake 约 100-300 纳秒
  • Step 5 Task 被 re-poll 读取数据:大约 100 纳秒(从 scheduler 队列 pop + 开始 poll Future)
  • Step 6 用户代码继续:取决于你自己的代码

总结:一个"发起 I/O → 等数据 → 继续处理"的 .await 周期,纯 Tokio 开销约 300-500 纳秒。剩下的时间全在内核(epoll、TCP/IP 栈)和你的代码上。Tokio 的运行时开销在整个异步 I/O 链路里占比 < 5%——这就是为什么它能支撑百万 QPS 级别的服务。

这也是为什么 "Tokio 太慢"几乎从来不是真的——当你觉得 Tokio 慢,往往是你的业务代码里某处漏了 spawn_blocking,或者某个 .await 点的 Future 内部有阻塞调用。第 19 章(性能陷阱)会教你怎么辨认这些。

1.6 本书章节与架构地图的对应

把第 1.4 节那张架构图和后续章节对应起来:

子系统涉及章节
Future / Waker 基础第 2-3 章(Future 与 poll 模型 + Waker 机制)
Runtime 容器第 4 章(Runtime 架构总览)
multi_thread Scheduler第 5 章(多线程 Scheduler 与工作窃取)
current_thread Scheduler第 7 章(current_thread runtime 与 LocalSet)
Task 结构第 6 章(Task:轻量级任务的生命周期)
I/O Driver第 8 章(Reactor 架构)+ 第 9 章(Mio)+ 第 10 章(TcpStream / UdpSocket)
Time Driver第 11 章(Time Driver 与分层定时器轮)
同步原语第 12 章(Mutex / RwLock / Semaphore)
channels第 13 章(mpsc / broadcast / watch / oneshot)
高级原语第 14 章(select!)+ 第 15 章(JoinHandle / JoinSet)
阻塞任务第 16 章(spawn_blocking 与 block_in_place)
可观测性第 17 章(metrics 与 tracing)
工程实践第 18-20 章(多 runtime、性能调优、设计模式)

读到任何一章感到迷失,请回到本章的架构图和 Step 1-6 的流程。每一章都是在这张图的某一块上深入,从图上找到自己的位置,就不会迷失。

1.6½ 本书读者的几种典型画像

本书不是 Tokio 入门教程(那是 Tokio 官方 tutorial 的工作),而是源码级深度剖析。为了让你读起来不走弯路,我把典型读者画像和对应的推荐阅读路径列出来:

画像一:用过 Tokio 2-3 年,想理解内部机制(本书主要目标读者)

你写过 Axum / Tonic 服务,遇到过几次生产 bug,想知道"Tokio 底下到底怎么工作"。 推荐路径:完整读第 1-10 章(核心机制),然后按需跳第 11-20 章。第 19 章(性能调优与陷阱)你会特别受益。

画像二:正要从 Go / Node.js 迁过来的资深后端

你有扎实的并发 / I/O 背景,但 Rust 是新的。 推荐路径:先读《Rust 编译器与运行时揭秘》第 9-10 章打基础,再读本书。第 1 章和第 5 章会让你快速完成 "Go GMP 到 Tokio" 的心智模型迁移。

画像三:想自己做一个运行时 / 贡献 Tokio 上游

你已经读过部分 Tokio 源码,想系统地理解架构决策。 推荐路径:第 4-6 章(Runtime + Scheduler + Task)是你的核心。第 20 章(设计模式与架构决策)是你的最终目的地。

画像四:做性能敏感服务(交易、实时通信),想调优到极致

你关心 p99 延迟、LIFO slot 行为、work-stealing 开销。 推荐路径:第 5 章 + 第 11 章(Time Driver)+ 第 16 章(blocking)+ 第 19 章(性能调优)。monoio / glommio 的对比在第 1 章有。

画像五:Rust 资深但首次深入异步

你写过 CLI 工具、系统 crate,但一直避开了 async。 推荐路径:先完整读《Rust 编译器与运行时揭秘》第 9-10 章、再读本书第 2-3 章把 Future / Waker 心智建立起来。后面的章节就顺了。

不管哪种画像,我推荐一件事打开 GitHub 上 tokio-rs/tokio 的仓库,本书每引用一段代码,你同步在 GitHub 上找到对应文件看一眼。这个同步阅读习惯对深度理解比任何讲解都管用。源码是唯一不会撒谎的老师


1.7 本章小结

这里给一个诚实的劝诫:不要把"Tokio 是主流"等同于"Tokio 适合你所有场景"。做技术选型时,用下面这个清单对照一次:

  • 你在做嵌入式 / MCU / no_std?→ embassy
  • 你在做极致尾延迟的内部系统(<100μs p99)?→ monoio / glommio
  • 你在做通用后端服务?→ Tokio,没别的选项
  • 你在做教学或极简工具?→ smol 或 pollster
  • 你在做GUI 应用?→ Tokio current_thread runtime,或者直接用 GUI 库自己的 event loop(往往不需要完整 runtime)

绝大多数读者的答案是"通用后端服务"。这本书给你的就是这条路的完整地图。


你应该带走的三件事

  1. Rust 把运行时留给了生态,这是语言的主动选择。代价是七年成熟期和显式依赖,收益是嵌入式 / WASM / 多运行时并存这种其他语言做不到的灵活性。Tokio 是这个空位上最成熟的通用运行时
  2. Tokio 的架构 = Runtime + Scheduler + Drivers + Task,Waker 是贯穿的神经。记住这张图,接下来 20 章都是在其上深入
  3. 一次 .await 的完整走位是 Future → Driver 注册 → OS 事件 → Waker 唤醒 → Scheduler 重调度 → Future 再 poll。这个循环大约 300-500 纳秒的纯 Tokio 开销,是支撑百万 QPS 服务的物质基础

最后一件需要放在心里的事:Tokio 从来不是完美的。它有陷阱(第 19 章会讲)、有 corner case、有社区至今没解决的争议(async trait、cancellation safety)。但它是迄今为止 Rust 异步生态最接近"好"的答案——带着这份清醒读完本书,你会对 Tokio 的优点和缺点都有基于事实的判断,不会被任何 hype 或 FUD 误导。

下一章我们回到 Future trait 本身,用"运行时角度"重新看一遍 pollPoll::Pending/Ready ——你以前学过的那个 Future,换一个视角看,会有全新的理解。

一个预告:后续章节会如何验证本章的所有主张

本章做了大量"Tokio 这样设计是因为 X"的断言。后续每一章都会用源码原样回来验证这些断言:

  • 本章讲"Waker 是贯穿所有子系统的神经"——第 3 章会拿出 waker.rs 127 行源码证明这点,展示 vtable 的每一个字段如何连接 Task、Scheduler、Driver
  • 本章讲"multi_thread 用 work-stealing"——第 5 章会展开 worker.rssteal_work 的随机起点、queue.rs 里的双头打包队列
  • 本章讲"I/O Driver 基于 mio"——第 8-9 章会展开 Tokio 如何把 mio 的 Poll::poll 和 Waker 系统串起来
  • 本章讲"Time Driver 是分层时间轮"—— 第 11 章会把 4 层 64 slot 的完整实现拆到位运算级别

本书和其他 Tokio 教程最大的区别:别的教程讲完"Tokio 有什么"就结束,本书讲完"Tokio 有什么"后一定会用源码证明这东西真的存在、真的那样实现。带着"我等着被说服"的批判心态读后面每一章——这是对本书和对自己最好的读法。


延伸阅读

基于 VitePress 构建