Appearance
第4章 Tokio Runtime 架构总览
"Every magic in software is just unread code." —— 笔者
本章要点
#[tokio::main]展开后是一段确定性的 15 行代码——里面有Builder/Runtime/Handle三个主要对象的完整装配流程Builder有 25+ 个可配置字段,分成 6 组:调度器类型、Driver 开关、线程池容量、回调 Hook、可观测性、调度调优。每一组都对应运行时某个具体行为- Tokio 有三种 Scheduler:
CurrentThread、MultiThread、MultiThreadAlt(实验性)——它们在Builder::build的 match 分支里分叉 enable_all()这个名字有点骗人——它实际只启用io+time两个 Driver,因为signal/process需要unixfeature 才自动开Runtime::block_on的真实代码里有一个debug 下自动 Box 大 Future 的优化(BOX_FUTURE_THRESHOLD)——这个细节解释了 "为什么 debug 能跑但 release 栈爆" 的诡异现象Handle是外界访问 Runtime 的唯一合法入口——可以 Clone、可以跨线程传、可以放进static。Runtime 本身不可 CloneEnterGuard通过PhantomData<&'a Handle>+ thread-local 实现"当前线程绑定到某个 runtime"——理解它你就理解了Handle::current()为什么在 runtime 外调用会 panic
4.1 从 #[tokio::main] 展开开始
几乎每一个用 Tokio 的 Rust 程序都长这样:
rust
#[tokio::main]
async fn main() {
// 你的 async 代码
some_service().await;
}这个 #[tokio::main] 是一个过程宏,由 tokio-macros crate 提供。它展开后会把整个 async fn main 改写成下面这个形式(Tokio 1.40 对应 tokio-macros 2.4 的行为):
rust
// #[tokio::main] 展开后的确定形态(默认配置)
fn main() {
let body = async {
some_service().await;
};
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed building the Runtime")
.block_on(body)
}所有 Tokio 程序的入口本质上都是这 5 行:
- 用
Builder::new_multi_thread()拿到一个配了默认值的 Builder .enable_all()打开 I/O 和 Time Driver.build()真正构造 Runtime——这一步会 spawn worker 线程、初始化 epoll / kqueue、初始化时间轮.expect(...)处理构造失败(极罕见,通常是 fd 耗尽).block_on(body)阻塞当前线程、驱动body这个顶层 Future 跑完
#[tokio::main] 本身没有任何"魔法"——它就是把这 5 行替你写出来。理解这 5 行,你就理解了 Tokio 的整个装配协议。
#[tokio::main] 的可配置项
宏可以带参数:
#[tokio::main]—— 默认多线程#[tokio::main(flavor = "current_thread")]—— 单线程 runtime#[tokio::main(worker_threads = 4)]—— 指定 worker 线程数#[tokio::main(flavor = "multi_thread", worker_threads = 4)]—— 显式多线程 + 指定线程数
这些参数只影响宏展开里 Builder 的配置链——比如 worker_threads = 4 会变成 .worker_threads(4) 调用加进展开结果里。
如果你想亲手验证
Tokio 的 #[tokio::main] 源代码在 tokio-macros/src/entry.rs。你也可以用 cargo-expand 工具直接看自己代码被展开成什么:
bash
cargo install cargo-expand
cargo expand --bin your_binary跑一下你就能逐字节看到上面那段展开结果。绝对真实,不用相信作者,自己验证即可——这是读源码这一领域最扎实的一类阅读工具。
4.2 Builder 的 25+ 字段:一份运行时配置全景
打开 tokio/src/runtime/builder.rs,Tokio 1.40 的 Builder struct 长这样(原样,仅删除注释):
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/builder.rs (tokio-1.40.0)
pub struct Builder {
kind: Kind,
enable_io: bool,
nevents: usize,
enable_time: bool,
start_paused: bool,
worker_threads: Option<usize>,
max_blocking_threads: usize,
pub(super) thread_name: ThreadNameFn,
pub(super) thread_stack_size: Option<usize>,
pub(super) after_start: Option<Callback>,
pub(super) before_stop: Option<Callback>,
pub(super) before_park: Option<Callback>,
pub(super) after_unpark: Option<Callback>,
pub(super) before_spawn: Option<TaskCallback>,
pub(super) after_termination: Option<TaskCallback>,
pub(super) keep_alive: Option<Duration>,
pub(super) global_queue_interval: Option<u32>,
pub(super) event_interval: u32,
pub(super) local_queue_capacity: usize,
pub(super) disable_lifo_slot: bool,
pub(super) seed_generator: RngSeedGenerator,
pub(super) metrics_poll_count_histogram_enable: bool,
pub(super) metrics_poll_count_histogram: HistogramBuilder,
#[cfg(tokio_unstable)]
pub(super) unhandled_panic: UnhandledPanic,
}25 个字段。每一个字段都对应运行时的一种行为——没有装饰字段,没有遗留字段。我们按功能分组梳理。
组 1:Scheduler 类型
kind: Kind—— CurrentThread / MultiThread / MultiThreadAlt
组 2:Driver 开关
enable_io: bool—— 启用 I/O Driver(epoll/kqueue/IOCP),第 8 章拆enable_time: bool—— 启用 Time Driver(定时器轮),第 11 章拆nevents: usize—— 一次 epoll_wait 最多取多少事件,默认 1024start_paused: bool—— Time Driver 启动时立即暂停时钟(单元测试用)
组 3:线程池容量
worker_threads: Option<usize>—— worker 线程数。multi_thread 默认 = CPU 核数max_blocking_threads: usize—— blocking 线程池最大线程数。默认 512thread_name/thread_stack_size—— 线程命名函数和栈大小
组 4:生命周期回调
after_start/before_stop—— 每个 worker / blocking 线程启动/停止时回调before_park/after_unpark—— worker 线程 park(无任务时挂起)/ unpark 时回调。这是观察 runtime 空闲度的唯一官方 hookbefore_spawn/after_termination—— 每个 Task 创建/终结时回调(用于 tracing / metrics)
组 5:调度调优
global_queue_interval: Option<u32>—— 每 poll 多少个任务后,强制从全局队列拉一个(避免本地队列的 Task 饿死全局队列的 Task)。默认 31event_interval: u32—— 每 poll 多少个任务后,去 Driver 查一次事件。默认 61。这两个数都是质数——避免"周期性同步"带来的抖动local_queue_capacity: usize—— 每个 worker 本地队列的容量。默认 256disable_lifo_slot: bool—— 关闭 LIFO 优化(第 5 章会讲为什么有些场景需要关)
为什么 event_interval = 61? 这个数字看起来随机,但它是 Tokio 开发团队实际基准测试跑出来的最优值。逻辑是:
- 如果这个数字太小(比如 10),worker 过于频繁地去查 I/O Driver,增加系统调用开销
- 如果太大(比如 1000),I/O 事件处理被延迟,尾延迟恶化
- 61 是个质数——避免和各种"每 N 次发生一次"的外部事件(比如每秒触发 100 次的定时器)产生共振
- 61 接近"一次 epoll_wait 能处理完的平均事件数(~64)"——两者同步得刚刚好
这种"看起来随便、实际经过大量基准测试、质数避免周期共振"的设计决策,在成熟运行时里处处可见。Linux kernel 的 jiffies 频率(HZ=100/250/1000)、Go runtime 的 GC stride、Vue 的响应式 flush batch size ——都是类似的"看起来随意、实际精调"的数字。
local_queue_capacity = 256 的选择也类似:256 是 CPU 一个 cache line(通常 64 字节)的 4 倍,恰好在"一次连续扫描不跨太多 cache line" 和 "不浪费内存" 之间取平衡。
组 6:可观测性与其他
metrics_poll_count_histogram_*—— Task poll 次数分布直方图seed_generator—— 给内部 RNG 设 seed,用于 Loom 确定性测试keep_alive: Option<Duration>—— blocking 线程空闲多久后回收。默认 10 秒unhandled_panic—— 未捕获 panic 的处理策略(unstable feature)
25 个字段看起来多,但它们是运行时所有可调旋钮的完整集合——除了这些字段,Tokio 的运行时行为没有任何"隐藏配置"。这种**"所有可调参数都可见地暴露在 Builder 上"的设计是 Tokio 工业级成熟的标志之一。对比一些早期 Rust 运行时(甚至 Go runtime)的"环境变量 + 编译标志 + 隐藏 API"三合一混乱,Tokio 的 Builder 是写过生产代码的人一看就能懂的**。
4.3 Kind:三种 Scheduler 的分叉
Tokio 把 Scheduler 类型抽出来成了 Kind 枚举:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/builder.rs
#[derive(Clone, Copy)]
pub(crate) enum Kind {
CurrentThread,
#[cfg(feature = "rt-multi-thread")]
MultiThread,
#[cfg(all(tokio_unstable, feature = "rt-multi-thread"))]
MultiThreadAlt,
}三种 scheduler:
CurrentThread—— 单线程 runtime。所有任务在调用block_on的那个线程上执行。适合:桌面应用、GUI 线程、单线程测试MultiThread—— 多线程 runtime,支持 work-stealing。99% 的服务代码用的是这个MultiThreadAlt—— 实验性的多线程调度器(tokio_unstable)。一个不同设计思路的调度器,还在孵化,不推荐生产使用
为什么分成三种而不是 Linux / Go 那样的"一种统一调度器"?
答案是 Rust 生态的多样性:一个 GUI 应用根本不想要多线程 runtime——它所有任务必须在 UI 线程上跑(因为 GUI 原生 API 不线程安全);一个 CLI 工具也不需要开 16 个 worker——多了反而增加启动开销。把"是否多线程"做成用户可选,而不是默认开,尊重了不同场景的真实需求。
Builder::build 的入口就是一个三分支 match:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/builder.rs
pub fn build(&mut self) -> io::Result<Runtime> {
match &self.kind {
Kind::CurrentThread => self.build_current_thread_runtime(),
#[cfg(feature = "rt-multi-thread")]
Kind::MultiThread => self.build_threaded_runtime(),
#[cfg(all(tokio_unstable, feature = "rt-multi-thread"))]
Kind::MultiThreadAlt => self.build_alt_threaded_runtime(),
}
}这一行 match 就是 current_thread 和 multi_thread 两条大代码路径的分叉点。后面所有的 handle.inner 都会是两种不同的 enum variant。
build_current_thread_runtime 在第 7 章详拆,build_threaded_runtime 在第 5 章详拆。本章先看 Runtime 最终长成什么样。
4.4 enable_all() 的真相
看这行被高频使用的 API:
rust
pub fn enable_all(&mut self) -> &mut Self {
#[cfg(any(
feature = "net",
all(unix, feature = "process"),
all(unix, feature = "signal")
))]
self.enable_io();
#[cfg(feature = "time")]
self.enable_time();
self
}enable_all 的名字是骗人的——它并不是"启用所有功能",而是基于 feature flag 条件式地开启 I/O Driver 和 Time Driver:
- 当启用了
net/process/signal任一 feature,开启 I/O Driver - 当启用了
timefeature,开启 Time Driver
Signal Driver(Unix 信号)是跟着 I/O Driver 一起启动的,不是独立 Driver。
为什么要用 cfg 而不是直接开? 因为 Tokio 支持按需裁剪——你可以用 tokio = { version = "1", default-features = false, features = ["sync"] } 只开同步原语、不要 I/O、不要 Time。这种裁剪对嵌入式、WASM、极简 CLI 工具场景非常有用。
实践建议:99% 的服务代码就用 tokio = { version = "1", features = ["full"] } + .enable_all()。只有在明确需要裁剪时才精细控制 features。
4.5 Runtime 的三件装备
Builder 最终产出的 Runtime 结构体:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs
#[derive(Debug)]
pub struct Runtime {
scheduler: Scheduler,
handle: Handle,
blocking_pool: BlockingPool,
}三件装备:
scheduler: Scheduler—— 把 Task 调度到 worker 线程上执行的装置handle: Handle—— 运行时的外接句柄,可 clone、可跨线程blocking_pool: BlockingPool—— 专门跑阻塞任务的线程池,和 worker 线程池分开
Scheduler 是一个 enum,对应三种 Kind:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs
#[derive(Debug)]
pub(super) enum Scheduler {
CurrentThread(CurrentThread),
#[cfg(feature = "rt-multi-thread")]
MultiThread(MultiThread),
#[cfg(all(tokio_unstable, feature = "rt-multi-thread"))]
MultiThreadAlt(MultiThreadAlt),
}这是 Tokio 架构的关键所有权关系:
- Runtime 独占持有 Scheduler、Handle、BlockingPool
- Handle 是可 clone 的,但它的所有副本都借用同一套底层数据
- BlockingPool 的生命周期和 Runtime 绑定——Runtime drop 时 BlockingPool 关闭
你无法 clone 一个 Runtime——因为 clone Runtime 就会导致"两个 Runtime 拿着同一组 worker 线程的控制权",这没有清晰语义。Runtime 是单子(singleton)形态的所有权容器;要在多处访问它,就 clone Handle。
BlockingPool 为什么和 Scheduler 分开?
你可能会想——为什么 Tokio 要把 blocking 线程和 worker 线程分开成两个池?一个池统一管理不行吗?
答案是两种池的工作性质完全不同:
| 维度 | Worker 线程池 | Blocking 线程池 |
|---|---|---|
| 数量 | 通常 = CPU 核数 | 默认最多 512 |
| 单 Task 预期时间 | 微秒到毫秒级 | 秒级甚至分钟级 |
| 任务性质 | 非阻塞 async(poll 一下就返回) | 阻塞同步(一直占用线程) |
| 调度策略 | work-stealing、LIFO slot | 直接分派、无调度 |
| 生命周期 | runtime 存在就存在 | 空闲 10 秒回收 |
如果把两者混在一起:
- 一个 blocking 任务会独占 worker 若干秒,期间这个 worker 上的所有其他 Task 都饿死
- worker 数量不能随 blocking 任务数动态扩展——而 blocking 任务天然需要"来多少开多少线程"的弹性
- Work-stealing 对 blocking 任务毫无意义——偷一个正在占线程的任务到另一个 worker 也没用
分成两个池是性能和语义的必要分离。Tokio 是最早明确做这个分离的 Rust runtime(async-std 最初没分,后来也分了)。这个设计让 Tokio 在"混合 async + 同步 blocking"负载下稳如泰山——而很多人写其他语言的异步代码掉进的最大坑,就是没分两个池导致的 worker 阻塞(Node.js 的 event loop 被阻塞、Python asyncio 的 GIL 同队列)。
第 16 章(spawn_blocking 与 block_in_place)会把 BlockingPool 的完整机制拆透。
scheduler::Handle 内部其实有两种形态
4.6 节的代码里 scheduler::Handle 是一个 enum,两个 variant 分别对应 current_thread 和 multi_thread。每个 variant 内部都是 Arc<某个具体 Handle 类型>。
为什么要用 enum 而不是 dyn trait?两个原因:
- 静态分派:enum match 的开销比 trait object 的虚调用小一点(特别是在 hot path 上频繁调用的
spawn) - 类型信息保留:某些场景(比如 runtime metrics)需要知道具体是哪种 scheduler,enum 能直接 match,trait object 需要 downcast
这种 "对外露 trait 一致性、对内保留具体类型" 的设计再次体现了 Tokio 的工程成熟度。类似模式在第 8 章(I/O Driver)和第 13 章(channel)都能看到。
4.6 Handle:Runtime 的外接口
Handle 的定义简单得让人吃惊:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/handle.rs
#[derive(Debug, Clone)]
pub struct Handle {
pub(crate) inner: scheduler::Handle,
}就一个 inner 字段。scheduler::Handle 内部是一个 enum:
rust
// 简化:scheduler::Handle(不同 scheduler 各有一种 Handle 变体)
pub(crate) enum Handle {
CurrentThread(Arc<current_thread::Handle>),
#[cfg(feature = "rt-multi-thread")]
MultiThread(Arc<multi_thread::Handle>),
// ...
}重点:每个 variant 都是 Arc<...>。Clone 一个 Handle = Arc 的引用计数 +1,成本极低(一次原子加)。
Handle 提供的主要 API:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/handle.rs
impl Handle {
pub fn current() -> Self { /* ... */ }
pub fn try_current() -> Result<Self, TryCurrentError> { /* ... */ }
pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output> { /* ... */ }
pub fn block_on<F: Future>(&self, future: F) -> F::Output { /* ... */ }
pub fn enter(&self) -> EnterGuard<'_> { /* ... */ }
}Handle::current()—— 拿到"当前线程绑定的 runtime 的 Handle"。不在 runtime 上下文中调用会 panicHandle::try_current()—— 同上,但用Result返回而非 panicHandle::spawn—— 在这个 Handle 对应的 runtime 上 spawn 一个 TaskHandle::block_on—— 在当前线程上阻塞等一个 Future 完成(注意:block_on 不是跑在 worker 上)Handle::enter—— 把当前线程临时绑定到这个 Handle 的 runtime(下节详说)
一个常见用法:把 Handle clone 到 non-Tokio 线程,让那个线程也能 spawn Tokio Task:
rust
let handle = tokio::runtime::Handle::current();
std::thread::spawn(move || {
// 这里是 std thread,不是 worker
handle.spawn(async {
// 但这个 Future 会在 runtime 的 worker 上跑
some_service().await;
});
});这是 Tokio 和 std::thread、rayon、其他库互操作的主要桥梁。Handle 是 Tokio 对外界开放的唯一合法入口。
Handle 可以跨线程、可以 clone、可以放进 static
由于 Handle 内部是 Arc<scheduler::Handle>,它可以:
- Clone:零成本(一次 Arc::clone,一次原子加)
- 跨线程:
Send + Sync,因为底层 Arc 就是 - 放进
static/lazy_static/OnceLock:很多库这么做,比如一个 HTTP client 库内部持有一个 Handle 用于调度后台任务
但一个坑:如果你把 Handle 存进 static,它会持有 Runtime 的一个引用——Runtime 在 drop 时需要所有 Handle 都释放才能完全关闭。如果 static Handle 永不释放,Runtime 的某些资源可能一直"活着"。对大多数服务(跑到进程退出)没影响,但对测试会有——你可能会看到"测试跑完但进程没退出"的现象。
实践建议:把 Handle 存进 static 用 OnceLock<Handle>,在 drop 时有清理需求则改用 Arc<OnceCell<Handle>>。这个坑到第 17 章(可观测性)时再回来。
Handle vs &Runtime:该用哪个?
两个 API 看起来功能重叠:
rust
rt.spawn(fut); // 方法 A:通过 Runtime 直接 spawn
rt.handle().spawn(fut); // 方法 B:通过 Handle
Handle::current().spawn(fut); // 方法 C:通过当前 runtime 的 Handle三种写法在 Runtime 上跑同一个 Future 时等价。但它们使用场景不同:
- A 适合已有
&Runtime的本地调用——短小直接,最常见 - B 适合需要把能力传给别人的场景——你可以 clone handle 给子模块
- C 适合你根本没 Runtime 引用、只知道"我现在在某个 runtime 里"的场景——库代码常用
没有性能差异——三种都是一次 Arc::clone + 一次 Task 入队。选最能表达你意图的那种。
4.7 block_on:一个细节丰富的门面函数
Runtime::block_on 的原样源码:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs
#[track_caller]
pub fn block_on<F: Future>(&self, future: F) -> F::Output {
if cfg!(debug_assertions) && std::mem::size_of::<F>() > BOX_FUTURE_THRESHOLD {
self.block_on_inner(Box::pin(future))
} else {
self.block_on_inner(future)
}
}这 8 行代码里有三个值得说的细节:
细节 1:#[track_caller]
这个属性让 panic 时报告的行号指向调用方,而不是 block_on 内部。写过被 expect/unwrap panic 折磨过的开发者,应该对这个属性肃然起敬。Tokio 的所有可能 panic 的 public API 几乎都带 #[track_caller]——好的错误定位就是少掉一半的调试时间。
细节 2:BOX_FUTURE_THRESHOLD
在 tokio/src/util/mod.rs 里:
rust
// 伪代码对照源码
#[cfg(any(not(debug_assertions), feature = "fixed_tokio_stack_sizes"))]
const BOX_FUTURE_THRESHOLD: usize = 2048;默认 2 KB 阈值:如果 Future 大小超过 2 KB 且在 debug 模式下,block_on 会自动把 Future Box::pin 到堆上。
为什么只在 debug 做? debug 模式下 Future 的状态机尺寸比 release 大 2-3 倍(因为没做 MIR 优化 / 没合并 variants),容易在默认栈大小(8 MB)下撑爆。Tokio 贴心地在 debug 下自动 Box 掉大 Future——让 "在 debug 能跑、在 release 栈爆" 的诡异现象不会出现(反过来的情况也不会)。
release 模式下不做 Box,因为 release 下状态机经过优化通常足够小、也不想引入额外堆分配。
这是 Tokio 对"开发者体验"下的一手细致功夫——绝大多数用户永远不会知道这个细节存在,只会感觉"Tokio 总是能跑起来"。
细节 3:一个致命陷阱
block_on 不能在 Tokio runtime 的 worker 线程上调用。你如果在 tokio::spawn(async { Handle::current().block_on(...) }) 这样调,会直接 panic:
Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.原理:block_on 会把当前线程"借"给它要跑的 Future 使用。如果这个线程已经是某个 runtime 的 worker,它就无法同时扮演两个角色。
正确的做法:block_on 只在非 runtime 线程(比如 main 函数的主线程、标准 std::thread)调。如果你在 runtime 内部想"等一个 Future 完成再继续",用 .await 就好——根本不需要 block_on。
4.7½ block_on 和 spawn 的线程走向对比
很多人第一次读 Tokio 代码会混淆两件事:spawn 让 Future 在哪个线程跑? block_on 又在哪个线程跑?
spawn 的行为:Future 被装进一个 Task,放进 scheduler 的队列。它在哪个 worker 线程被 poll 是不确定的——一开始可能在当前线程(LIFO slot 优化会让它立刻在当前 worker 跑),但如果当前 worker 忙,它可能被偷到别的 worker 上。你不能假设 spawn 的 Future 一定在某个特定线程上——这是 Tokio 多线程 runtime 的根本性质,也是"Send + 'static"bound 的来源。
block_on 的行为:Future 在调用 block_on 的那个线程上被"驱动"——但这不意味着 Future 的每个 .await 都只用那一个线程。具体地:
- 如果 Future 内部只
.await了同步风格的东西(比如sleep、oneshot::Receiver),block_on 线程会一直 park/unpark 驱动它 - 如果 Future 内部
spawn了别的 Task,那些 Task 跑在 worker 上,不在 block_on 线程 - 如果 Future 在当前线程之外被 wake(比如 I/O Driver 在另一个线程 wake),block_on 线程会被 unpark 然后继续 poll
一句话记忆:spawn 的 Future 是"调度给 runtime 的 worker 跑",block_on 是"把当前线程变成一个 single-threaded 驱动器,顺带使用 runtime 的 Driver 服务"。
这个区别重要到决定你能不能把 block_on 和 spawn 在同一个函数里混用。比如这段代码:
rust
// 错误示范:在 runtime 里 block_on,会 panic
#[tokio::main]
async fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { /* ... */ }); // ← panic: "Cannot start a runtime from within a runtime"
}这就是 4.7 节说的那个经典错误。解决方案不是"再套一层",而是根本不该套——在 async fn 里想等一个 Future 直接 .await,不需要 block_on。如果你真的有"嵌套 runtime"的需求(比如测试场景),用 tokio::task::block_in_place + Handle::block_on 的组合,第 16 章会细讲。
4.8 EnterGuard 与 thread-local 的运行时绑定
Handle::current() 依赖一个 thread-local——如何知道"当前线程绑定了哪个 runtime"?
看 EnterGuard:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/handle.rs
#[derive(Debug)]
#[must_use = "Creating and dropping a guard does nothing"]
pub struct EnterGuard<'a> {
_guard: context::SetCurrentGuard,
_handle_lifetime: PhantomData<&'a Handle>,
}构造 Handle::enter 时:把当前线程的 thread-local"当前 runtime 句柄"设成这个 Handle,同时返回一个 EnterGuard。
Guard drop 时:自动把 thread-local 恢复为上一个值(或清空)。
配合 PhantomData<&'a Handle> 的 lifetime 约束,这段代码在编译期保证:EnterGuard 不能活过它绑定的 Handle。
这个机制解释了几个常见现象:
Handle::current()在 non-Tokio 线程上 panic——因为 thread-local 是空- 手动
handle.enter()之后的代码块里可以调tokio::spawn、Handle::current()等——因为 thread-local 被临时设上 - Guard drop 后再调
Handle::current()又会 panic——因为 thread-local 被恢复
实用场景:在一个 non-Tokio 的函数里想调 tokio::spawn,但它要求 "在 runtime 上下文中":
rust
let handle = tokio::runtime::Handle::current(); // 从某个地方拿到 handle
let _guard = handle.enter(); // 绑定当前线程
// 这段代码块里可以用 tokio::spawn 等
tokio::spawn(async { /* ... */ });
// _guard drop,恢复这个 pattern 在把 Tokio 集成进已有线程池 / 同步框架时非常有用。第 18 章(多 runtime 架构)会深入这个用法。
4.9 Drop:关闭 runtime 的复杂性
Runtime 的 Drop 实现:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs
impl Drop for Runtime {
fn drop(&mut self) {
match &mut self.scheduler {
Scheduler::CurrentThread(current_thread) => {
let _guard = context::try_set_current(&self.handle.inner);
current_thread.shutdown(&self.handle.inner);
}
#[cfg(feature = "rt-multi-thread")]
Scheduler::MultiThread(multi_thread) => {
multi_thread.shutdown(&self.handle.inner);
}
#[cfg(all(tokio_unstable, feature = "rt-multi-thread"))]
Scheduler::MultiThreadAlt(multi_thread) => {
multi_thread.shutdown(&self.handle.inner);
}
}
}
}关闭一个 runtime 要做三件事:
- 通知所有 worker 线程"该收工了"——通常通过给它们发一个特殊的 shutdown 信号
- 清空所有 Task 的队列(未完成的 Task 被 drop)
- join worker 线程,等它们全部退出
shutdown(handle) 是阻塞的——它会等所有 worker 真的退出才返回。如果你的 runtime 里有 Task 跑着一个很长的同步代码段(比如意外的 std::thread::sleep(1_hour)),Runtime drop 会卡 1 小时。
解决办法:Runtime::shutdown_timeout(Duration) 给你一个 deadline,到点不管 Task 死活都退出:
rust
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs(这是 Runtime 的另一个方法,不是 Drop)
pub fn shutdown_timeout(self, duration: Duration) {
// ...
}生产环境建议:服务接到 SIGTERM 时显式调 shutdown_timeout(Duration::from_secs(30)),给正在处理的 HTTP 请求 30 秒的 graceful 时间;超时就强制关。这是比依赖 Drop 更可控的模式。
第 17 章(runtime metrics)会回到这个话题,教你怎么观察"shutdown 卡在什么上"。
Task drop 的意外之美
上面说"未完成的 Task 被 drop"——这句话有一个意外好的性质:
Rust Future 的取消 = drop。一个 Task 在 shutdown 时被强制 drop,它内部的 async fn 状态机也被 drop——状态机的所有字段走正常的 Drop 析构流程:
- 打开的
File被关 - 持有的
Mutex锁被释放 TcpStream被关连接- 任何
Dropimpl 都会被触发
这是 Rust 异步最被低估的优势之一。对比 Python / JavaScript 的协程取消——需要用 CancelledError 异常穿透整个调用栈,容易漏、容易忘 try-finally。Rust 的"drop = 取消"是零额外心智负担的——只要你的资源类正确实现了 Drop(Rust 类型系统的基础要求),取消就自动正确。
Tokio 的 shutdown 依赖这个性质:它不需要给每个 Task 发一个"你被取消了"的信号,直接 drop Task 就够。这让 shutdown 协议的实现非常干净。
shutdown 的真实顺序
把 shutdown 的完整顺序梳理清楚:
- 停止 scheduler 接收新 Task——已经 spawn 但未开始的 Task 进入 shutdown 队列
- 唤醒所有被 park 的 worker——让它们从休眠中醒来
- 每个 worker 处理当前正在 poll 的 Task——有的能在 shutdown 超时前跑完 Ready,有的被强制 drop
- 清空本地队列和全局队列——所有 Task drop
- 关闭 BlockingPool——等 blocking 线程 join 或超时
- 关闭 I/O Driver——调
mio::Poll::drop,释放 epoll fd - 关闭 Time Driver——drop 时间轮里所有定时器的 Waker(这些 Waker 可能 wake 已经 dead 的 Task,但 wake 是幂等的,所以无害)
- Runtime drop 返回——整个进程可以继续或退出
每一步都有可能出 bug。第 19 章会讲几个真实的 shutdown bug:worker 卡在无限循环、blocking 任务不响应 keep_alive、Driver drop 顺序错误等。
shutdown_background —— 另一个你应该知道的 API
除了 Drop 和 shutdown_timeout,Runtime 还有一个 shutdown_background:
rust
// Tokio 1.40 有这个 API(简化签名)
impl Runtime {
pub fn shutdown_background(self);
}语义:立即返回,不等 worker 和 blocking 线程 join 完成。相当于"我不管了,自己死"。
什么时候用这个?当你在关闭流程里自己被时间卡得很紧、又不想为了等 blocking 任务多等几秒时。典型场景:
- 信号处理快速退出:收到 SIGKILL(好吧那你根本没机会),或者快速响应 SIGTERM 不想等
- 测试清理:测试已经通过了,cleanup 期别再阻塞测试框架
代价:如果此时有 blocking 任务还在跑(比如一个 5 秒的 spawn_blocking 调用),它们会继续跑到完成——进程不会因此 hang(它们是 daemon 线程),但可能出现 "main 已经退出、但某些 log 还在打"的奇怪现象。
三种 shutdown 对比:
| API | 等 worker | 等 blocking | 适用场景 |
|---|---|---|---|
Drop(直接 drop Runtime) | ✅ 完整等 | ✅ 完整等 | 开发、测试 |
shutdown_timeout(d) | ✅ 最多等 d | ✅ 最多等 d | 生产服务 graceful shutdown |
shutdown_background | ❌ 不等 | ❌ 不等 | 快速退出、测试清理 |
实际生产代码里,shutdown_timeout(30s) 是最常见的选择:给正在处理的请求留 30 秒 grace period,超时强切。
4.9½ 嵌套 runtime 的三种合法 / 非法形态
一个常见困惑:可以在 runtime 里再跑 runtime 吗? 答案微妙——Tokio 允许的组合只有特定几种。
形态 A:根本不嵌套(最常见、合法)
rust
#[tokio::main]
async fn main() {
some_work().await;
}main 进程只有一个 runtime。所有 async 通过 .await 串起来,没有嵌套。绝大多数服务代码应该长这样。
形态 B:在 async 里想跑同步阻塞代码(合法,用 spawn_blocking)
rust
#[tokio::main]
async fn main() {
// 需要跑一段 CPU 密集同步代码
let result = tokio::task::spawn_blocking(|| {
heavy_cpu_computation() // 同步
}).await.unwrap();
}spawn_blocking 把同步任务丢给 BlockingPool,不阻塞 worker。第 16 章会详讲。
形态 C:测试中需要"在这个测试里用独立 runtime"(合法,用独立 Handle)
rust
// 测试函数是同步的,但想跑一段 async 逻辑
#[test]
fn my_test() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
run_something().await;
});
}这完全合法——测试函数是 non-Tokio 线程,block_on 把它变成 runtime 的驱动线程。这是 Rust 异步代码单元测试的标准写法之一(另一种是用 #[tokio::test] 宏)。
形态 D:runtime 里又 block_on(非法,panic)
rust
#[tokio::main]
async fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { /* ... */ }); // ❌ panic
}这就是前面说的经典 panic。不要写这种代码。
形态 E:runtime 里创建独立 runtime 并手动管理(合法但复杂)
rust
#[tokio::main]
async fn main() {
let inner_rt = tokio::runtime::Runtime::new().unwrap();
let _guard = inner_rt.enter(); // 临时把当前线程 enter 到 inner_rt
// 现在在 inner_rt 的上下文下 spawn(但 block_on 仍然 panic!)
tokio::spawn(async { /* ... */ });
}这合法,但很少需要——只有"主 runtime + 完全隔离的次 runtime(比如一个专门处理某类阻塞服务的 runtime)"场景才值得。第 18 章讲多 runtime 架构时会展开。
总结记忆
- 要在 async 里等 Future:
.await,不要用block_on - 要在 async 里跑同步代码:
tokio::task::spawn_blocking - 要在 sync 里跑 async:
Runtime::new().block_on(...) - 要在 sync 里 spawn 到现有 runtime:
Handle::current().spawn(...)(要求当前线程已 enter)
这四条覆盖 99% 的场景。不记得时查本章,不会错。
4.9¾ 默认 worker 线程数的选择
worker_threads = None 时会取 CPU 核数。但具体用 std::thread::available_parallelism()——这个 API 在 2022 年 Rust 1.59 稳定,比 num_cpus crate 更准确:
- 在 Linux 上会读 cgroup 的 CPU quota(容器环境下)
- 在 macOS / Windows 上读物理核数
这对容器部署非常重要:如果你的服务部署在 Kubernetes,limits.cpu: "2" 的话,available_parallelism() 返回 2;而旧的 num_cpus::get() 会返回宿主机的核数(可能 64)——意味着 Tokio 会错误地 spawn 64 个 worker,每个 worker 争抢 2 core 的时间片,性能反而糟糕。
Tokio 1.40 用 available_parallelism 默认是正确的。但如果你用 .worker_threads(N) 手动设过,要记得在容器环境下也要根据 quota 调整——一些团队在这里踩过坑,直到他们把 Tokio 升级到较新版本并去掉手动设置,性能才恢复正常。
4.10 一个完整例子:从 #[tokio::main] 到退出
把本章所有东西拼起来看一个完整生命周期:
rust
#[tokio::main]
async fn main() {
run_service().await;
}
async fn run_service() {
// do work
}Step 1:宏展开#[tokio::main] 替你写出 Builder::new_multi_thread().enable_all().build().block_on(body) 这 5 行。
Step 2:Builder::new_multi_thread() 创建 Builder,所有字段填默认值:kind=MultiThread、worker_threads=None(会在 build 时用 CPU 核数)、max_blocking_threads=512、global_queue_interval=None、event_interval=61、local_queue_capacity=256 等。
Step 3:.enable_all() 把 enable_io=true、enable_time=true(取决于 feature flag)。
Step 4:.build()match kind → 走 build_threaded_runtime()(第 5 章详拆)。这一步做:
- 初始化 I/O Driver:调
mio::Poll::new()拿 epoll fd - 初始化 Time Driver:创建分层时间轮
- spawn N 个 worker 线程(N = CPU 核数),每个 worker 有自己的本地队列
- 创建 BlockingPool(初始为空,按需 spawn 阻塞线程)
- 组装出
Runtime { scheduler, handle, blocking_pool } - 返回
Ok(Runtime)
Step 5:.block_on(body)
- 用
#[track_caller]标记位置 - 如果 debug && size_of(body) > 2KB:
body = Box::pin(body) - 调
block_on_inner(body):- 把
body交给 scheduler 作为"primary task" - 主线程进入等待循环:从 I/O Driver 取事件、处理 Waker、让 worker 继续推进
- 等
body返回Poll::Ready,返回它的 Output
- 把
Step 5 的微观动作:
- 主线程 enter 到 runtime 的 context(thread-local 被设置)
body被 pin 在栈上(或 Box 里)- 创建一个"外层 Context" —— 其 Waker 会 wake 主线程(用 park/unpark 机制)
- 循环:poll body → Pending 就 park 主线程(std:🧵:park)→ Waker wake → unpark → poll body 继续 → 最终 Ready 返回
- 期间 worker 线程并发处理
body内部spawn的其他 Task;主线程只驱动顶层 body 自己
这是 Tokio 主线程的"双角色":既是 block_on 的驱动器,又是 runtime 的事件中心。如果你去 flamegraph 一个 Tokio 程序,会看到主线程的调用栈永远是"block_on → poll body → park";worker 线程的栈是"worker_loop → poll task"。两者分工清晰。
Step 6:main 返回 Runtime 对象离开作用域,触发 Drop:
shutdown(handle)→ 通知所有 worker 停- worker 线程收到 shutdown → drop 所有未完成的 Task → 退出
- BlockingPool 的所有 blocking 线程收到 keep_alive 超时 → 退出
- 进程正常 exit
每一个 "step" 都是对应源码里的几十到几百行代码。后续章节会把每一步都钻进去——但现在你已经有了整张地图。这张地图是后 16 章的定位参考。
补充:为什么这个装配顺序是这个顺序
细心的读者会注意到:Builder 内部有很多字段,但 build() 要按一个特定顺序调用它们。比如:
- I/O Driver 必须在 worker 线程 spawn 之前初始化——因为 worker 里要用它
- Time Driver 也必须先于 worker——原因同上
- BlockingPool 可以最后初始化——它独立于 worker
- scheduler::Handle 必须在 worker spawn 之前构造——每个 worker 都需要拿一份 Handle 的 Arc
这个依赖关系是通过 build_threaded_runtime() 里硬编码的调用顺序保证的——不是用 DI 容器、不是用显式依赖图。Rust 的资源管理让这种"硬编码顺序"成为最自然的表达方式:资源的 RAII 生命周期天然描绘了依赖关系。
这也是为什么 Tokio 没有也不需要一个"dependency injection framework"——Rust 的类型系统和所有权已经承担了 DI 框架的大部分职责。读 Tokio 代码时你很少见到"工厂"、"装配器"、"IoC 容器"这些 Java/C# 常见的概念——所有的"谁依赖谁、谁先构造"都直接反映在函数调用顺序和字段类型里。这种用代码结构本身承载架构的风格,是 Rust 系统编程的重要美学。
4.10½ #[tokio::main] 背后被过度低估的三件事
#[tokio::main] 太常用了,以至于大多数人从不仔细看它。但它背后有三件事值得单独讲——每一件都会在生产环境影响你:
事一:宏默认是 multi_thread,即使你只有 1 核机器
#[tokio::main] 没指定 flavor 时展开为 Builder::new_multi_thread()。即使你跑在 1 vCPU 的廉价 VPS 上,也会是 multi_thread(只不过 worker_threads 会是 1)。
这带来微妙的开销:
- multi_thread 的 Task 结构体比 current_thread 的多几个字段(原子引用计数、调度器偏移)
- 所有
spawn调用都需要F: Send + 'static——即使在只有一个 worker 的情况下 - Work-stealing 逻辑即使不生效也会在调度循环里出现
对性能敏感的单线程场景(CLI 工具、嵌入式代理),明确写 #[tokio::main(flavor = "current_thread")] 能省掉这些开销。
事二:enable_all 的默认启用可能超出你的需要
.enable_all() 启用 I/O + Time Driver。I/O Driver 会在 runtime 启动时创建一个 epoll/kqueue 句柄——这是一次系统调用,不免费。Time Driver 也会启动一个分层时间轮(虽然开销很小)。
对于一个完全不做 I/O、不用定时器的纯计算 async 任务(比如跑一段 combinator 逻辑),根本不需要 enable_all。换成 Builder::new_current_thread().build() 可以省掉两个 Driver 的初始化。
实践建议:写库代码时让用户自己决定 runtime 配置,别在库里写死 #[tokio::main]。#[tokio::main] 是应用入口专用的。
事三:#[tokio::test] 是单独的宏,行为不一样
测试用的 #[tokio::test] 展开后默认是 current_thread,不是 multi_thread:
rust
// #[tokio::test] 展开后大致
fn my_test() {
let body = async { /* ... */ };
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(body)
}为什么默认 current_thread? 两个原因:
- 测试应该快:current_thread 启动比 multi_thread 快得多(不用 spawn worker 线程)
- 测试应该可预测:multi_thread 的不确定调度可能让某些并发 bug 只在特定时序下出现,current_thread 更稳定
但这意味着:#[tokio::main] 下能跑的代码,放到 #[tokio::test] 下可能跑不了——比如代码里有 tokio::task::block_in_place(...),这个 API 在 current_thread runtime 下会 panic。
踩过的人血泪:你在 main 里的逻辑跑得好好的,写测试时复用同样的代码就挂了。这时候的修法是:#[tokio::test(flavor = "multi_thread")],让测试也用 multi_thread runtime。
4.11 和这个系列的其他书的关联
本章讲的"Builder + Runtime + Handle + EnterGuard"的设计模式,在 Rust 生态里不是 Tokio 独创——这是一类叫"可配置资源容器"的通用模式。《Rust 编译器与运行时揭秘》第 1 章(编译管线全景)里讲 rustc 的 Compiler 对象时用的是同一套思路:rustc 的 Compiler 内部有 session / codegen_backend / 各种 Arena,CompilerConfig 就是一个 Builder、暴露 20+ 个配置字段。两个系统的 API 对比起来你会发现连字段分组思路都一样——都有"输入源、Driver 开关、线程池、回调 Hook、可观测性"五大组。
这种"Builder 配置 + 组装 + Handle 外接"是 Rust 工业级 API 的标准形态。写 Rust 库时如果你做到这三件事,你的库就已经具备了进生产的基础条件。
另外,Tokio 的 Handle / thread-local / EnterGuard 这套"当前线程绑定运行时"的机制,和 《Vue 3 设计与实现》第 10 章(组件系统) 里 Vue 的 getCurrentInstance 机制(组件函数执行时动态绑定"当前组件"到一个 thread-local-ish 的全局槽)思路同构。两个系统都用隐式的 thread/task-local context 让深层调用能拿到"当前上下文"——这套机制是所有运行时型系统的共用工具箱。
4.12 本章小结
带走三件事:
#[tokio::main]没有魔法——它就是Builder::new_multi_thread().enable_all().build().block_on(body)的宏替你写出来- Builder 的 25+ 字段是运行时所有可调旋钮的完整集合——分成调度器、Driver、线程池、回调、调度调优、可观测性六组,每一组对应运行时的一类行为
- Runtime 有三件装备:Scheduler(调度执行)、Handle(外接接口,可 clone)、BlockingPool(阻塞任务专用)。三者生命周期由 Runtime 独占持有,Drop 时有序关闭
一份 "该调什么不该调" 的速查清单
本章看了太多 Builder 字段,最后给你一份务实的"调参优先级"清单,按场景分级:
绝大多数服务默认值已经最优,不要调:
global_queue_interval(31)、event_interval(61)、local_queue_capacity(256)—— 不要调,除非你有非常具体的基准测试数据max_blocking_threads(512)—— 不要调,一般也用不满nevents(1024)—— 不要调
大多数服务需要设:
worker_threads—— 容器 / K8s 下看 CPU limit 决定keep_alive(10s) —— 如果你的 blocking 任务比较频繁,设长点避免反复 spawn 线程
特定场景下设:
thread_name—— 线上方便ps/top/ perf 区分 Tokio 线程和其他after_start/before_stop—— 接入 tracing / metrics 初始化before_park/after_unpark—— 定位"worker 为什么不工作"的深度调试工具
几乎永远不设:
seed_generator—— Loom 测试用,生产无意义start_paused—— 单元测试用thread_stack_size—— 只在你遇到状态机撑爆 8 MB 栈时才考虑(极罕见)
核心原则:Tokio 的默认值是几年来在海量真实服务上打磨出的中位数最优解。除非你能用 tokio-console / perf / flamegraph 拿到明确证据说某个默认值在你的场景下次优,否则不要调。这个社区有句话:"You're not Discord, stop premature-tuning your Tokio."(你不是 Discord,别过早调你的 Tokio)。
4.11 和这个系列的其他书的关联
(下一段见 4.11)
4.10¾ Runtime::new() 是什么的简称
最后一个小知识点:你可能见过 tokio::runtime::Runtime::new() 这种写法(不经过 Builder)。它等价于:
rust
// Tokio 源码里 Runtime::new() 的实际实现
pub fn new() -> io::Result<Runtime> {
#[cfg(feature = "rt-multi-thread")]
let ret = Builder::new_multi_thread().enable_all().build();
#[cfg(not(feature = "rt-multi-thread"))]
let ret = Builder::new_current_thread().enable_all().build();
ret
}即启用 rt-multi-thread feature 时是 multi_thread,否则 current_thread,并且 enable_all。它是 Builder 的 enable_all().build() 的便捷别名。当你不需要任何非默认配置时,直接用 Runtime::new() 最简洁。
下一章我们钻进 Scheduler——具体是 multi_thread 这一支。你会看到 build_threaded_runtime() 的完整流程、每个 worker 线程的 loop 长什么样、work-stealing 的具体算法、本地队列 + LIFO slot + 全局注入队列的三层结构。这一章是你读 Tokio 源码的最难一章,但也是最有回报的一章——读懂它你就能从 profile / tokio-console 的任何诡异波形里看出调度问题。
延伸阅读
- Tokio 源码:
tokio/src/runtime/builder.rs—— 本章引用的 Builder 全字段 - Tokio 源码:
tokio/src/runtime/runtime.rs—— Runtime 结构 / block_on / Drop - Tokio 源码:
tokio/src/runtime/handle.rs—— Handle / EnterGuard / current() - Tokio macros:
tokio-macros/src/entry.rs——#[tokio::main]宏展开的实现 - 《Rust 编译器与运行时揭秘》第 1 章:rustc 的 Compiler 对象与 Tokio 的 Runtime 对象对比