Tokio 源码深度解析

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

作者 杨艺韬 · 10,288 字 · 发布于 · 更新于

本章要点

  • 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 语言只给了你两样东西:

  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 在裸机环境下通常没有现成的堆分配器。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 Trait in 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 赢了通用场景?

  1. 历史先发:Tokio 是第一个有完整工业栈的 Rust 运行时,早期的 Rust 后端项目(hyper、reqwest、tonic、axum)全部绑定 Tokio,形成了生态锁定
  2. 性能足够好:对于绝大多数业务场景,Tokio 的多线程 work-stealing 调度器已经够快;thread-per-core 的理论优势只在极端场景(延迟敏感、尾延迟敏感)才显现
  3. 可观测性工具链完整:tokio-console、tracing 整合、runtime metrics,线上排错所需的全套工具齐全
  4. 稳定性承诺: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,进程退出。

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

  1. Future 本身是被动的——它只会在 poll 到 Pending 时注册 Waker,然后"死在那里",直到被 Waker 唤醒
  2. Driver 是主动的——它等 OS 事件,然后主动调 Waker 唤醒 Task
  3. 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 线程池

两条值得记住的物理事实——

  1. 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% 是 "让四件套配合工作" 的胶水
  2. 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)

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

带走三件事:

  1. Rust 把运行时留给了生态——代价是显式依赖,收益是嵌入式 / WASM / 多运行时并存的灵活性。Tokio 是这个空位上最成熟的通用运行时。
  2. Tokio 的架构 = Runtime + Scheduler + Drivers + Task,Waker 是贯穿的神经。后续 19 章都在这张图上深入。
  3. 一次 .await 的完整走位 = Future → Driver 注册 → OS 事件 → Waker 唤醒 → Scheduler 重调度 → Future 再 poll。这条链路上真正的开销在系统调用和你的业务代码,Tokio 自身只是用户态的少数几次原子操作。

延伸阅读