Tokio 源码深度解析
第1章 Tokio 在 Rust 异步生态中的位置
本章要点
- Rust 异步模型的一个独特选择:语言定义 Future trait,运行时留给生态
- Tokio 从 2014 年的 mio 一路演化到 2024 年 1.40 版本的关键技术节点
- Tokio 与 smol、async-std、monoio、glommio 的真实差异(不是"哪个更好",而是"哪个场景用哪个")
- Tokio 的顶层架构:Runtime、Scheduler、Driver、Task 四大子系统,以及 Waker 这个贯穿始终的"神经"
- 一次
.await在这张架构图上的完整走位 - 本专栏后续 19 章如何对应这张地图的每个区域
1.1 一个反直觉的选择:Rust 把运行时踢出了语言
写过 Go 的人会习惯一件事:你写 go f(),就有一个 goroutine 被调度、被栈管理、被 GMP 分派;你写 time.Sleep(...),就有一个 timer 被 runtime 管理;你写 conn.Read(...),底下是 netpoller 在用 epoll/kqueue 等你的数据。
所有这些"运行时基础设施"都藏在了 Go 的语言运行时里。你不需要导入任何库,go、chan、select 是 Go 关键字,runtime 是标准库的一部分。这给 Go 带来了无与伦比的易用性,也带来了"所有 Go 程序都带着一个同款运行时"的代价——当你想在嵌入式设备上跑 Go、或者想让 Go 程序不自带 GC,你就会撞墙。
Rust 做了一个反过来的选择。Rust 语言只给了你两样东西:
Futuretrait(定义在core::future中):一个可以被.poll()的状态机抽象async/await语法糖:编译器把async fn展开成实现了Futuretrait 的匿名状态机类型
语言不给你调度器、不给你 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 在裸机环境下通常没有现成的堆分配器。Python / JS / Go 的异步模型都依赖堆分配——Rust 不依赖。embassy 运行时让你在 32 KB RAM 的单片机上跑 async Rust——这是 Python / JS / Go 的异步模型做不到的。
红利二:WebAssembly 场景有原生支持
WASM 没有线程(大部分情况下)、没有传统 epoll,只有 event loop。Rust 的 Future 模型天然适应这种环境——wasm-bindgen-futures 把 Rust Future 桥接到 JS 的 Promise / event loop,代码可以在浏览器和服务端共用同一套 async 代码。
红利三:同一套 async 代码可以跑在完全不同的运行时上
如果你的库基于 futures 的 AsyncRead / AsyncWrite 这类运行时无关的 trait 而不是 Tokio 具体类型,它就能被 smol 等不同运行时的用户使用;Tokio 自己的 AsyncRead / AsyncWrite 是另一套 trait,Tokio 用户要经 tokio-util 的 compat 层(tokio-util/src/compat.rs)转接。好的库作者会尽量泛化运行时,保留灵活性。坏的库作者会绑死 Tokio(当然大部分时候这也没问题)。
红利四:运行时本身是可演化的 JavaScript 的 event loop 由宿主环境(浏览器、Node.js)提供,应用换不掉。Tokio 调度器则经历过多轮大改(见第 5 章)。语言不绑运行时,意味着运行时可以快速进化。
一句话:Rust 把运行时留给生态这个选择,是长期思维——短期看它付出了生态成熟得慢的代价,但换来的是十年、二十年维度的灵活性。这种"牺牲短期换长期"的工程决策,正是 Rust 设计哲学里最珍贵的部分。
1.2 Tokio 的前世:从 mio 到 1.40 的十年
要理解 Tokio 为什么是现在这个样子,得看它是从什么演化过来的。这不是掉书袋——很多 Tokio 当下的设计决策,只有知道它的历史才能理解"为什么不是另一种做法"。
2014-2016:mio —— 一切的起点
Tokio 还不存在时,Carl Lerche 先写了 mio(Metal I/O)——mio 仓库的初始提交是 2014 年 8 月 19 日、作者正是 Carl Lerche,0.4.0 在 2015 年 7 月就已发布,比 tokio 仓库最早的那个提交(2016 年 7 月 30 日)早了两年。mio 做一件事:把 Linux 的 epoll、macOS/BSD 的 kqueue、Windows 的 IOCP 抽象成一套统一的跨平台非阻塞 I/O API。
mio 本身没有"Future"、没有"async"的概念,它的 API 长这样:
// 伪代码,示意 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 提出了 futures crate——也就是 Future trait 的第一个版本:
// 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 异步写起来很痛苦:
// 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 模块(library/core/src/future/future.rs:39 那行 stable 标记写的正是 futures_api / 1.36.0),Rust 1.39 把 async/await 语法稳定(compiler/rustc_feature/src/accepted.rs:75:(accepted, async_await, "1.39.0", Some(50547)))。新的 Future trait 长这样:
// 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 性能:一系列增量优化,细节见第 5 章。注意 LIFO slot 本身早于 1.0:它正是 2019 年那轮 work-stealing 调度器改造(#1657,2019 年 10 月合入,随 11 月的 0.2.0 发布;即博客《Making the Tokio scheduler 10x faster》描述的那一轮)带来的——当时它是 tokio-0.2.0 里
runtime::thread_pool::queue模块Worker结构体上注释为 "Task to pop next" 的next字段(该模块在 1.40.0 中已不存在),名字里还没有 "LIFO";2020 年 3 月的 scheduler v2.5(#2273,随 0.2.14 发布)重整队列时,才给它写上 "LIFO slot. Cannot be stolen." 这行注释。此后一路都在给它加护栏:同属 0.2.14 的 #2349(2020 年 3 月 28 日,标题 "cap fifo scheduler slot to avoid starvation")用 coop 预算限制连续从 slot 取任务;1.21.0 加了disable_lifo_slot这个 unstable 开关(#4936);1.29.0 的 #5712 又补了一个连续 poll 次数上限MAX_LIFO_POLLS_PER_TICK = 3——三者都是为了防止"两个任务互相 wake 把一个 worker 焊死"的饿死场景。 这个上限今天还在:tokio/src/runtime/scheduler/multi_thread/worker.rs:241的const MAX_LIFO_POLLS_PER_TICK: usize = 3; tracing整合:1.7.0(2021 年 6 月)加入tracing: instrument task wakers(#3836),为tokio-console这类工具铺路- Runtime metrics:1.16.0(2022 年 1 月)overhaul 了 metrics API(#4373)、1.18.0(2022 年 4 月)加入 I/O driver metrics(#4507);到 1.38.0 才开始逐项 stabilize(
RuntimeMetrics::worker_count,#6556) JoinSet:1.17.0(2022 年 2 月)以 unstable 引入(#4335)、1.21.0(2022 年 9 月)连同AbortHandle一起稳定(#4920)- runtime builder 细粒度配置:worker 线程数、blocking 线程数、事件间隔、公平性、线程名等全部可配
- io_uring 实验性支持:
tokio-uring作为补充存在,但 Tokio 主线仍然以 epoll 为主
Tokio 1.40.0(2024 年 8 月 30 日发布) 是本专栏的分析锚点。它的架构在 1.0 的骨架上增量演化,理解了 1.0 的设计,后续版本对你不再陌生。
1.3 为什么 Rust 异步比其他语言成熟得慢
读完 1.2 节的演化史,你可能会注意到一个反常现象:JavaScript 在 2015 年(ES6)标准化了 Promise、2017 年(ES2017)有了 async/await,Python 3.5 在 2015 年稳定了 async/await,Rust 却等到 2019 年——而且从 2019 的 async/await 稳定到 2020 的 Tokio 1.0 还花了一年多,周边生态走向成熟又用了几年。Rust 异步的成熟速度明显慢于其他主流语言,这不是因为 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 设计也经过反复讨论和改版,直到 Rust 1.33 才稳定。《Rust 编译器与运行时揭秘》第 10 章详拆了 Pin 的完整语义。
原因三:trait 系统的缺环 2015-2017 的 Rust 类型系统里缺了几个支撑异步 trait 的关键特性:
- GAT(Generic Associated Types)—— async trait 方法的返回类型需要携带生命周期,Rust 1.65(2022 年 11 月)才稳定
impl Traitin trait 方法(RPITIT)与async fn in trait—— Rust 1.75(2023 年 12 月)才稳定- Type-level async—— 部分特性至今还在 nightly
Rust 社区不愿意在这些基础不牢的情况下稳定 async trait——代价是等待,但换来的是长期的 API 稳定。
原因四:Rust 的民主化开发模式 Rust 所有重要决策都走 RFC 流程,几十人到几百人讨论数月。JavaScript(TC39 分阶段提案)和 Python(PEP)也有提案流程,但决策更集中——Python 引入 async/await 的 PEP 492,当年由 BDFL Guido van Rossum 拍板接受。民主化慢但稳,最终决策质量更高。
总结:Rust 异步是业界最野心勃勃的异步模型——要零成本、要类型安全、要编译期验证。这些目标之间相互制约,每个都需要语言和生态多年打磨。今天你写一行 .await 能跑起来、能跨线程、能零开销,是社区多年耐心打磨的结果。Tokio 作为这个生态的主运行时,每一个设计决策都浸泡在这段历史里——读它的源码时带着这份历史感,你会看到更多东西。
1.4 Rust 异步运行时的横向对比
读到这里你可能会问:既然语言把运行时留给生态,那除了 Tokio,还有什么?为什么 Tokio 最终占了主导?其他运行时在什么场景下有优势?
下面这张表梳理了 2026 年 Rust 异步生态的主要运行时:
| 运行时 | 定位 | 核心差异点 | 典型场景 |
|---|---|---|---|
| Tokio | 通用、工业级 | 多线程 work-stealing + 经 mio 覆盖 epoll/kqueue/IOCP 各平台 + 大而全的生态 | 绝大多数 Rust 后端 |
| smol | 极简、模块化 | 由 async-executor、async-io、blocking 等小 crate 组合而成,各组件可独立使用 | 需要自定义运行时组合的场景、学习用 |
| async-std | 模拟 std API | API 形状刻意贴近 std,async_std::fs 对标 std::fs |
已于 2025 年 3 月停止维护,官方建议迁移到 smol |
| monoio | thread-per-core + io_uring | 每个线程一个独立的 executor + reactor,不做工作窃取;以 io_uring 为主(Linux 5.6+),也可用 epoll / kqueue | 字节跳动内部的高性能 RPC/存储服务 |
| glommio | thread-per-core + io_uring | 和 monoio 类似思路,由 Datadog 发起;只支持 Linux + io_uring(5.8+);调度更偏向任务优先级 | 延迟敏感的存储 / 数据库类服务 |
| embassy | 嵌入式、no_std | 无需堆、无需线程、直接用 interrupt 作为 reactor | STM32、RP2040 等单片机 |
| pollster | 最小化阻塞 executor | 约百行,给单 Future 用 block_on | 不需要运行时的场景(脚本、工具) |
每一个运行时的深层定位
Tokio 的定位:通用后端。作者 Carl Lerche、Alex Crichton 等人 2016 年开始写,现在是 Discord、AWS、Deno、Linkerd、字节跳动 Volo、Meta(部分服务)的底层。Tokio 代表 Rust 异步生态的"主流答案"——不求最极端的性能,但要最稳定的生产体验。
smol 的定位:教育 + 模块化。作者 Stjepan Glavina 也是 async-std 早期核心贡献者,smol 走的是更精简、模块化的路线(现由 smol-rs 组织维护,smol 本身只是把 async-executor、async-io、blocking 等一组小 crate 重新导出)。smol 的代码非常可读——每个组件都短小,推荐所有想理解运行时原理的人读一遍 smol 源码作为 Tokio 的"轻量对照组"。
async-std 的定位(已 deprecated):2019 年试图做"异步版 std"——API 刻意和 std 对齐,让 std::fs::File::open 和 async_std::fs::File::open 只差一个 .await。理念好但没跑赢:维护团队已于 2025 年 3 月正式停止维护并建议迁移到 smol。官方给出的理由是把精力集中到 smol;从生态角度看,背后还有这些因素:
- 生态引力都跑去了 Tokio
- 核心维护者精力不足
- 性能调优投入不如 Tokio
技术专栏很少讲 failed project,但 async-std 的退场有教益:生态战争里"API 亲和"比不过"生态引力"。这是 Rust 生态的一个真实伤疤。
monoio 的定位:极致延迟。字节跳动出品,专门为"每核一个线程 + io_uring"架构而生。适用场景:高频交易、搜索引擎内部 RPC、大规模网关。代价是生态单薄;io_uring 要 Linux 5.6+,其他环境只能退回 epoll / kqueue,thread-per-core 模型本身不变。
glommio 的定位:同 monoio,但更偏数据库 / 存储场景。由 Datadog 发起(作者 Glauber Costa 此前在 ScyllaDB,thread-per-core 的思路一脉相承,但 ScyllaDB 自身用的是 C++ 的 Seastar 而不是 glommio)。和 monoio 在架构上几乎一样,差异在 API 风格和社区归属——你选哪个取决于你和哪个社区更贴近。不过它只支持 Linux(io_uring,内核 5.8+),而且近来提交稀少:官方仓库 master 分支最近一次提交停在 2025 年 4 月。
embassy 的定位:嵌入式 / no_std。跑在 MCU 上。绝对的特殊环境专用运行时。
pollster 的定位:最简 executor(官方 README 自称约 100 行)。不支持 I/O、不支持定时器。用途:给测试代码或纯计算 Future 一个 block_on 实现,不想引入 Tokio 那么重的依赖。
为什么 Tokio 赢了通用场景?
- 历史先发:Tokio 是第一个有完整工业栈的 Rust 运行时,早期的 Rust 后端项目(hyper、reqwest、tonic、axum)全部绑定 Tokio,形成了生态锁定
- 性能足够好:对于绝大多数业务场景,Tokio 的多线程 work-stealing 调度器已经够快;thread-per-core 的理论优势只在极端场景(延迟敏感、尾延迟敏感)才显现
- 可观测性工具链完整:
tokio-console、tracing整合、runtime metrics,线上排错所需的全套工具齐全 - 稳定性承诺:Tokio 1.x 保证无 breaking change,这对生产项目至关重要
什么时候你应该用 Tokio 之外的运行时?
- 你在做 io_uring 下的极致尾延迟优化,且接受放弃跨平台 → monoio / glommio
- 你在做嵌入式,跑 no_std 环境 → embassy
- 你做一个纯工具类程序,引入 Tokio 太重 → pollster + 少量手写 executor
- 你在做教学、研究运行时设计 → smol
对本专栏读者而言,Tokio 是最值得深入学习的对象:学懂 Tokio,其他运行时你都能看懂,反之则不一定成立。
1.5 Tokio 生态全景:站在巨人肩膀上的一整座大厦
Tokio 本身只是运行时,但真正让你用起来爽的是 Tokio 带动的整个生态。2026 年的今天,一个中高端 Rust 后端服务的典型依赖长这样:
| 层级 | 代表库 | 依赖 Tokio 的什么 |
|---|---|---|
| HTTP 服务器 | axum / actix-web / warp |
TcpListener、Task、异步 I/O |
| HTTP 客户端 | reqwest / hyper |
TcpStream、timer、并发控制 |
| gRPC | tonic |
Hyper + HTTP/2 |
| WebSocket | tokio-tungstenite |
TcpStream |
| 数据库 | sqlx / sea-orm |
连接池、异步 I/O |
| Redis | redis-rs(async) / fred |
TcpStream、RESP 协议 |
| 消息队列 | lapin(RabbitMQ)/ rdkafka(Kafka) |
TcpStream、async channel |
| 对象存储 | aws-sdk-rust / object_store |
Hyper + 信号量限流 |
| 日志 / 追踪 | tracing / tracing-subscriber |
Task-local、async span |
| 序列化 | serde / rmp-serde |
和运行时无关 |
| RPC 框架 | tarpc / volo(字节跳动) |
Tokio 全栈 |
上表里除了 serde、tracing 这类与运行时无关的库,其余几乎都以 Tokio 为默认或唯一运行时。为什么?
第一个原因:Hyper
hyper 是 Rust 的参考级 HTTP 实现,2014 年由 Sean McArthur 发起。它在 0.11 版本(2017)迁移到 Tokio(0.10 是最后一个同步阻塞版本)之后成为 Tokio 生态的最重要锚点——因为几乎所有 HTTP 相关库都依赖 Hyper。Axum、reqwest、tonic、warp——它们都在 Hyper 之上。Hyper 绑 Tokio,于是上游全都绑 Tokio。
第二个原因:兼容成本 一个库如果想同时支持 Tokio 和 smol,需要:
- 泛型化运行时抽象(写很多 trait bound)
- 保留多套 I/O 路径(Tokio 和 smol 的 AsyncRead/Write 细节略有差异)
- 文档要教用户选哪个
- CI 要跑两个 runtime 的测试
库作者发现"只支持 Tokio 就够用",因为绝大多数用户用 Tokio。这是典型的生态引力效应——一旦某个选项占了大头,新项目因为"一定要能跟已有依赖共存"也选它,比例进一步扩大。
第三个原因:性能和稳定性 Tokio 多年工业级打磨,生产环境稳定性久经检验。选 Tokio 是最小风险选项——招人好招、问题好 Google、生态丰富、性能有上下文。
Tokio 生态大厦的影响:好的和坏的
好的:你做一个新后端项目,tokio = "1" + axum + sqlx + reqwest 三下五除二搭一个完整服务栈。这体验和 Go / Node.js 打平,不再有 2018 年"Rust 写服务太累"的印象。
坏的:如果你想用 non-Tokio 运行时(比如嵌入式 + embassy、超高性能场景 + monoio),生态阻力很大——你可能要自己写或移植大量库。对已经建在 Tokio 生态上的服务来说,迁移成本通常远超那点性能收益。
你的选择:除非你有非常具体的理由(嵌入式、io_uring 极致延迟),否则拥抱 Tokio 生态。这个专栏教的就是这个生态的心脏。
一个简单例子:几行代码搭一个 HTTP 服务
// Axum + Tokio,2026 年 Rust 后端的"hello world"
// 依赖:tokio =1.40.0(features = ["full"])+ axum 0.7;axum 0.8 与 tokio =1.40.0 无法同时解析
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();
}
几行代码就是一个能跑起来的 HTTP 服务(真要上生产,还得补上错误处理、超时和优雅退出)。这就是 Tokio 生态的成熟度——几年前同样的功能还需要手写一堆 Hyper 的 make_service_fn / service_fn 样板。
1.6 Tokio 的顶层架构地图
本节是整个专栏的"骨架"——下面这张图会贯穿后续 19 章,每一章都在图上的某一块深入钻井。建议你在真正读下去之前、花五分钟把这张图在脑子里过一遍:Runtime 是容器、Scheduler 管 Task 调度、Drivers 和 OS 对接、Waker 是贯穿所有子系统的神经。这四块加一根神经就是整个 Tokio。理解了这个骨架,后面任何一章的深入细节都能找到它在全局中的位置。
graph TB
subgraph 用户代码层
UC["async fn / .await<br/>tokio::spawn<br/>TcpStream::connect"]
end
subgraph Runtime["Runtime(顶层容器)"]
BLD["Builder:配置入口"]
HND["Handle:外部句柄"]
end
subgraph Scheduler["Scheduler(调度器)"]
MT["multi_thread<br/>work-stealing"]
CT["current_thread<br/>单线程"]
LQ["Worker 本地队列"]
GQ["全局注入队列"]
LIFO["LIFO slot"]
end
subgraph Drivers["Drivers(驱动层)"]
IOD["I/O Driver<br/>(基于 mio)"]
TMD["Time Driver<br/>分层定时器轮"]
SGD["Signal Driver<br/>Unix 信号"]
end
subgraph Task["Task 与 Future"]
TK["Task 结构体<br/>(header + state + future)"]
WK["Waker<br/>vtable-based"]
JH["JoinHandle"]
end
subgraph Primitives["同步与通信原语"]
MX["Mutex / RwLock<br/>/ Semaphore"]
CH["mpsc / broadcast<br/>/ watch / oneshot"]
NT["Notify"]
end
subgraph Observability["可观测性"]
MT2["Runtime metrics"]
TR["tracing 集成"]
TC["tokio-console"]
end
UC --> BLD
UC --> HND
BLD --> MT
BLD --> CT
MT --> LQ
MT --> GQ
MT --> LIFO
MT --> TK
CT --> TK
TK --> WK
TK --> JH
WK --> MT
WK --> CT
MT --> IOD
MT --> TMD
CT --> IOD
CT --> TMD
IOD -->|epoll_wait 唤醒| WK
TMD -->|timer 到期| WK
UC --> MX
UC --> CH
MX --> NT
Runtime --> Observability
Scheduler --> Observability
这张图里有四大核心子系统(外加一层同步与通信原语)和一条贯穿的神经:
四大子系统与同步原语
1. Runtime(运行时容器)
顶层入口。tokio::runtime::Runtime 结构体持有 Scheduler(各 Driver 放在调度器内部,由它间接持有),Handle 是外部拿到 Runtime 引用的句柄。#[tokio::main] 宏展开后本质上就是 Runtime::new().block_on(async { ... })——宏的展开模板在 tokio-macros/src/entry.rs:415(Builder::new_current_thread() / new_multi_thread() → .enable_all() → .block_on(...)),而 Runtime::new() 自己就是 Builder::new_multi_thread().enable_all().build()(tokio/src/runtime/runtime.rs:181)。
第 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(驱动层) 运行时的"感官"。常打交道的有三类:
- I/O Driver:封装 mio,监听 socket 的 readable/writable 事件
- Time Driver:管理所有的定时器,基于分层时间轮
- Signal Driver:Unix 信号(SIGINT、SIGTERM)的异步化
还有第四层不常被提到的 Process Driver(Unix 上的子进程回收,叠在 Signal Driver 之上——tokio/src/runtime/driver.rs:272)。四层驱动是套娃关系而不是并列关系:tokio/src/runtime/driver.rs:16 的 Driver 只持有一个 TimeDriver,由内到外是 I/O → Signal → Process → Time。
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
- 任务自己的 vtable(
Header的vtable字段,存 poll / schedule / dealloc 等函数指针,不是 Waker 的RawWakerVTable)
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 是这条电话线的底层接口,它让 Waker 的实现可以是任何东西——不同运行时(Tokio vs smol vs embassy)可以用完全不同的 Task 表达,但都通过同一个 Waker 接口和 Future trait 对话。
第 3 章会把 Waker 拆到 vtable 字节级别。理解了 Waker,你就理解了运行时 ↔ Future 的本质契约。
1.7 一次 .await 在这张图上的完整走位
把前面的架构落到一个具体例子上:
use tokio::io::AsyncReadExt; // stream.read(...) 来自这个扩展 trait
#[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] 展开后(按 tokio-macros/src/entry.rs 的模板整理,省略了 lint 属性):
use tokio::io::AsyncReadExt;
fn main() -> std::io::Result<()> {
let body = 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(())
};
return tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed building the Runtime")
.block_on(body);
}
接下来按时间顺序展开每一步:
Step 1:Builder::build() 创建 Runtime。构建过程中:
- 初始化 multi_thread Scheduler,创建 N 个 worker 线程(默认 N = CPU 核数——
tokio/src/runtime/builder.rs:1350的self.worker_threads.unwrap_or_else(num_cpus)) - 初始化 I/O Driver:内部调
mio::Poll::new()拿到一个 epoll fd(tokio/src/runtime/io/driver.rs:95) - 初始化 Time Driver:创建分层时间轮
- 每个 worker 启动,在一个 loop 里反复尝试从本地队列、LIFO slot、全局队列拉任务
Step 2:rt.block_on(fut) 在当前线程(主线程)上运行一个 Future,直到它完成:
fut被pin!钉在当前线程的栈上——它不会被包成 Task、也不会进 Scheduler 的任何队列(那是spawn的待遇)- 用一个"unpark 本线程"的 Waker 造出
Context - 主线程进入一个循环:poll 这个 Future → 如果
Poll::Pending就park()睡在本线程上等唤醒 → 如果Poll::Ready就退出
这三步就是 CachedParkThread::block_on 的全部(tokio/src/runtime/park.rs:271);multi_thread 的 block_on 只是先 enter_runtime 再把 Future 交给它(tokio/src/runtime/scheduler/multi_thread/mod.rs:82)。
Step 3:Task 被首次 poll。执行到 TcpStream::connect(...).await:
connect内部发起一个非阻塞的 socket 连接(connect()系统调用,设置O_NONBLOCK)- 系统调用立即返回
EINPROGRESS——连接还没建立(socket 一建出来就带SOCK_NONBLOCK:mio/src/sys/unix/net.rs:28;connect把EINPROGRESS当成功放行:mio/src/sys/unix/tcp.rs:36) connect在 I/O Driver 里注册一个 "等待这个 fd 变可写"的兴趣,关联当前 Task 的 Waker- 返回
Poll::Pending - 控制权交还给
block_on的循环,主线程park()睡下;multi_thread 下 epoll_wait 由拿到 Driver 的那个 worker 线程去等
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
(这是 spawn 出来的 Task 的走位。上面例子里的 Future 是 block_on 直接驱动的,它的 Waker 不入任何队列,而是 unpark 那个正睡着的主线程——tokio/src/runtime/park.rs:290 的 UnparkThread::into_waker。两条路径的区别在第 4、5 章展开。)
Step 5:Task 被再次 poll。这次 connect() 内部发现 fd 已经可写了:
- 返回
Poll::Ready(Ok(stream)) - 用户代码拿到
stream,继续往下走 - 执行到
stream.read(&mut buf).await read内部就是对这个本来就非阻塞的 fd 调一次read(2)(mio 只是把 std 的TcpStream借出来读:mio/src/net/tcp/stream.rs:275),不靠MSG_DONTWAIT这类 per-call 标志- 如果内核没有数据:返回
EWOULDBLOCK,read同样在 I/O Driver 注册"等可读"兴趣、返回Poll::Pending - 如果内核已经有数据:直接返回
Poll::Ready(n)
Step 6:block_on 循环捕获最终结果。当最外层 Future 返回 Poll::Ready(Ok(())),block_on 退出,Runtime drop,worker 线程被 join,进程退出。
这一整个流程里,有三点需要记住:
- Future 本身是被动的——它只会在 poll 到
Pending时注册 Waker,然后"死在那里",直到被 Waker 唤醒 - Driver 是主动的——它等 OS 事件,然后主动调 Waker 唤醒 Task
- Scheduler 是撮合者——它决定哪个 worker 跑哪个 Task、什么时候 park/unpark 线程
本专栏后续 19 章,就是把这一流程中的每一步钻透。
这条路径上每一步各自"重"在哪
把 Step 1-6 按开销的量级排一遍(本专栏不给具体纳秒数——那取决于 CPU、内核版本和负载,任何抄来的数字都不可信;你应该用 perf / flamegraph 在自己的机器上量):
- Step 1 build() 初始化运行时:整条路径上唯一一次真正"重"的操作——spawn N 个线程 + 创建 epoll fd + 初始化时间轮。但它一个进程只发生一次,之后运行时本身的固定开销几乎可以忽略
- Step 2 block_on + 第一次 poll:取决于你的 Future 在第一个
.await之前做了什么 - Step 3 Future 注册 Waker + 返回 Pending:一次
Waker::clone(原子加)+ 一次写进 Driver 的数据结构。第 3 章会看到这就是几条指令 - Step 4 epoll_wait + 事件调度:
epoll_wait是系统调用,量级上比用户态的 Waker 操作大得多;拿到事件后查表找 Waker、wake()则是用户态的一次 CAS + 入队 - Step 5 Task 被 re-poll:从 scheduler 队列 pop + 进入 Future 的
poll——纯用户态 - Step 6 用户代码继续:取决于你自己的代码
结论不是某个纳秒数,而是一个结构性事实:这条路径上真正花钱的是系统调用和你的业务代码,Tokio 自己的调度与唤醒逻辑是用户态的少数几次原子操作。
这也是为什么 "Tokio 太慢"几乎从来不是真的——当你觉得 Tokio 慢,往往是你的业务代码里某处漏了 spawn_blocking,或者某个 .await 点的 Future 内部有阻塞调用。第 19 章(性能陷阱)会教你怎么辨认这些。
1.8 本专栏章节与架构地图的对应
把第 1.6 节那张架构图和后续章节对应起来:
| 子系统 | 涉及章节 |
|---|---|
| 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.9 本专栏读者的几种典型画像
本专栏不是 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.10 实测:tokio 1.51.1 的 tokio/src 共 103,088 行的"顶层架构地图"
§1.6 给出 Tokio 顶层架构图(Runtime + Scheduler + Drivers + Task)——把它们对应到 tokio-1.51.1 这棵树的 tokio/src/ 实测(只数 tokio 这一个 crate,不含同 workspace 的 tokio-util / tokio-stream,也不含 tests/)——
tokio/src 合计 103,088 行(按主要子目录拆分)——
| 子目录 | 行 | 角色 |
|---|---|---|
src/runtime/ |
28342 | 本目录最大——含 §1.6 提到的 Scheduler / Task 调度器 / I/O Driver / Blocking pool 全部基础设施 |
src/sync/ |
21346 | mpsc / broadcast / oneshot / watch / Mutex / RwLock / Notify / Semaphore——21K 行专门给"协程间同步原语" |
src/net/ |
15408 | TCP/UDP/Unix Socket 异步包装 |
src/io/ |
13070 | AsyncRead/AsyncWrite trait + AsyncBufRead + Util |
src/fs/ |
4772 | 文件 I/O(基于 spawn_blocking) |
src/task/ |
4411 | spawn / JoinHandle / yield_now / LocalSet 公共 API |
src/process/ |
3470 | 子进程异步 I/O |
src/macros/ |
2994 | select! / pin! / try_join! 等 |
src/time/ |
2184 | sleep / interval / timeout 公共 API |
src/signal/ |
2001 | 信号处理 |
| 其余(loom/util/runtime/...) | ~5090 | — |
src/runtime/ 内部进一步拆分——
| 子目录 | 文件 | 行 | 角色 |
|---|---|---|---|
runtime/scheduler/ |
29 | 5275 | §1.6 主角——current_thread + multi_thread 双调度器 |
runtime/task/ |
15 | 4753 | Task struct(Cell/Header/Core/Trailer)+ Harness + State 管理 |
runtime/io/ |
8 | 1701 | ch08 §8.20 已测的 epoll 路径 1370 + tokio/src/runtime/io/driver/uring.rs 309 + tokio/src/runtime/io/driver/signal.rs 22 |
runtime/time_alt/ |
13 | 1551 | 1.49.0 引入的备选 time wheel 实现(#7467),与旧的 runtime/time/(2091 行)并存 |
runtime/blocking/ |
5 | 817 | spawn_blocking 线程池 |
两条值得记住的物理事实——
src/runtime/28342 行 = tokio 27%——是 §1.6 顶层架构图的全部底层基础——其中scheduler/5275 +task/4753 +io/1701 +time/2091 +time_alt/1551 +blocking/817 = 16188 行是 §1.6 "Runtime + Scheduler + Drivers + Task" 四件套(连同 blocking 池)的核心;剩余 12154 行是 builder / context / coop / metrics / handle / park 等支撑代码——印证 §1.6 标题"顶层架构地图"——四件套约占 runtime 57%、其余约 43% 是 "让四件套配合工作" 的胶水src/sync/21346 行 = tokio 21%——协程间同步原语比 I/O 相关代码(1701 in runtime/io + 13070 in src/io = 14771)还重 44%——说明"Tokio 不止是 I/O 运行时、也是 async 并发原语库"——每一个 channel/Mutex 都需要把 Future 协议、Waker、抢占、cancellation safety 编织在一起、所以代码量大
对照《Rust 编译器与运行时揭秘》 专栏第 4 章 §4.12 实测的 borrowck 34458 行——tokio 这一个 crate 的 103,088 行 ≈ rustc 借用检查的 3 倍——印证 §1.1 "Rust 把运行时踢出了语言" 的工程含义:运行时由生态承担、生态自己长出了与编译器一个核心子系统相当甚至更大的工程量。
本节 runtime 28342 + sync 21346 = 49,688 行(约 50K)(ch08 §8.20 实测的 I/O Driver 1370 行已包含在 runtime 之内)——是 Tokio 异步基础设施的核心;用户写 async 代码看不见的复杂度都在这约 50K 行里。
1.11 本章小结
这里给一个诚实的劝诫:不要把"Tokio 是主流"等同于"Tokio 适合你所有场景"。做技术选型时,用下面这个清单对照一次:
- 你在做嵌入式 / MCU / no_std?→ embassy
- 你在做对 p99 尾延迟有极端要求的内部系统?→ monoio / glommio
- 你在做通用后端服务?→ Tokio,通常是默认选择
- 你在做教学或极简工具?→ smol 或 pollster
- 你在做GUI 应用?→ Tokio current_thread runtime,或者直接用 GUI 库自己的 event loop(往往不需要完整 runtime)
绝大多数读者的答案是"通用后端服务"。这个专栏给你的就是这条路的完整地图。
带走三件事:
- Rust 把运行时留给了生态——代价是显式依赖,收益是嵌入式 / WASM / 多运行时并存的灵活性。Tokio 是这个空位上最成熟的通用运行时。
- Tokio 的架构 = Runtime + Scheduler + Drivers + Task,Waker 是贯穿的神经。后续 19 章都在这张图上深入。
- 一次
.await的完整走位 = Future → Driver 注册 → OS 事件 → Waker 唤醒 → Scheduler 重调度 → Future 再 poll。这条链路上真正的开销在系统调用和你的业务代码,Tokio 自身只是用户态的少数几次原子操作。
延伸阅读
- Carl Lerche, "Announcing Tokio 1.0":Tokio 1.0 发布博文,官方对架构稳定性的承诺来源
- Tokio GitHub: tokio-rs/tokio:本专栏所有源码引用的 repo
- Alice Ryhl, "Actors with Tokio":理解 Tokio 并发模型的好素材
- 《Rust 编译器与运行时揭秘》第 10 章:
Pin、Waker、Future的编译期支撑,和本专栏是语言层 + 运行时层的互补