Tokio 源码深度解析
第4章 Tokio Runtime 架构总览
本章要点
#[tokio::main]展开后是一段确定性的调用链——里面有Builder/Runtime/Handle三个主要对象的完整装配流程Builder有 24 个字段(其中unhandled_panic需要tokio_unstable,稳定构建下是 23 个),分成 6 组:调度器类型、Driver 开关、线程池容量、回调 Hook、可观测性、调度调优。每一组都对应运行时某个具体行为- Tokio 有三种 Scheduler:
CurrentThread、MultiThread、MultiThreadAlt(实验性)——它们在Builder::build的 match 分支里分叉 enable_all()这个名字有点骗人——它实际只打开io+time两个开关;signal/process没有单独开关,在 unix 上随 I/O Driver 一起叠起来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::main] 宏展开入手是理解 Tokio Runtime 最自然的切入点——绝大多数 Tokio 程序都是从这个宏开始的、理解了它你就知道"我的 async main 函数到底是怎么跑起来的"。这种"从用户最常见的入口反推内部机制"的阅读路径比"从最底层开始一路往上讲"高效得多——带着具体使用场景理解抽象机制,比干讲抽象好记得多。本节就从这个宏展开做第一刀、看 Tokio 是怎么从宏层面把一切串起来的。
几乎每一个用 Tokio 的 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.0,见 tokio/Cargo.toml 的 tokio-macros = { version = "~2.4.0" };展开形态见 tokio-macros/src/entry.rs:413-450):
// #[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(...)处理构造失败(极罕见,比如创建 epoll 实例或线程失败).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 工具直接看自己代码被展开成什么:
cargo install cargo-expand
cargo expand --bin your_binary
跑一下你就能看到和上面结构一致的展开结果(实际输出还多一层带 #[allow(...)] 的块和一个 return)。绝对真实,不用相信作者,自己验证即可——这是读源码这一领域最扎实的一类阅读工具。
4.2 Builder 的 24 个字段:一份运行时配置全景
Builder 的字段数量之多是 Tokio 作为成熟运行时的侧面证据——二十几个配置项对应了 Tokio 在多年生产实践中遇到的所有"哪里可以调"的需求。本节不要求你记住每个字段的名字——那没有意义。本节的真正价值在于让你形成"运行时有哪些可调维度"的地图——以后遇到性能问题、你能立刻知道"哦这问题可能跟 worker 线程数相关"、"这个阻塞问题跟 blocking pool 容量相关"——而不是面对问题一筹莫展。
打开 tokio/src/runtime/builder.rs,Tokio 1.40 的 Builder struct 长这样(原样,仅删除注释):
// 来源:tokio-rs/tokio · tokio/src/runtime/builder.rs:45-124 (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,
}
24 个字段(unhandled_panic 只在 tokio_unstable 下存在)。每一个字段都对应运行时的一种行为——没有装饰字段,没有遗留字段。我们按功能分组梳理。
组 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 最多取多少事件,默认 1024(tokio/src/runtime/builder.rs:277)start_paused: bool—— Time Driver 启动时立即暂停时钟(单元测试用)
组 3:线程池容量
worker_threads: Option<usize>—— worker 线程数。multi_thread 默认 = CPU 核数max_blocking_threads: usize—— blocking 线程池最大线程数。默认 512(tokio/src/runtime/builder.rs:289)thread_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;对应的 setteron_task_spawn/on_task_terminate需要tokio_unstable,见tokio/src/runtime/builder.rs:727)
组 5:调度调优
global_queue_interval: Option<u32>—— 每 poll 多少个任务后,强制从全局队列拉一个(避免本地队列的 Task 饿死全局队列的 Task)。默认是None,含义随调度器而异:current_thread 用常量DEFAULT_GLOBAL_QUEUE_INTERVAL = 31(tokio/src/runtime/scheduler/current_thread/mod.rs:122);multi_thread 则自调优——Stats::tuned_global_queue_interval(tokio/src/runtime/scheduler/multi_thread/stats.rs:55) 按"目标 200μs 检查一次全局队列"除以 poll 耗时的 EWMA 算出来,并 clamp 到[2, MAX_TASKS_POLLED_PER_GLOBAL_QUEUE_INTERVAL]。你显式设了值就不再自调优event_interval: u32—— 每 poll 多少个任务后,去 Driver 查一次事件。三种 Builder 构造函数都传 61;loom下换成 4 的只有new_current_thread()一个(它把值抽成了EVENT_INTERVAL常量,tokio/src/runtime/builder.rs:227),new_multi_thread()/new_multi_thread_alt()传的是写死的字面量 61(tokio/src/runtime/builder.rs:244、tokio/src/runtime/builder.rs:263)local_queue_capacity: usize—— 每个 worker 本地队列的容量。默认 256(tokio/src/runtime/builder.rs:314)。它的 setter 只在loom下存在,也只有MultiThreadAlt读它;标准 multi_thread 的本地队列容量是写死的常量(见下文)disable_lifo_slot: bool—— 关闭 LIFO 优化(第 5 章会讲为什么有些场景需要关)
为什么 event_interval = 61? 别自己编理由——源码里就写着答案,而且答案是"没什么理由":
// 来源:tokio-rs/tokio · tokio/src/runtime/builder.rs:243 · new_multi_thread()
// The number `61` is fairly arbitrary. I believe this value was copied from golang.
Builder::new(Kind::MultiThread, 61)
维护者自己写的是"这个数字相当随意,我相信是从 Go 那边抄来的"(Go runtime 的调度循环里确实有一个"每 61 次从全局队列取一个 goroutine"的常量)。new_current_thread() 和 new_multi_thread_alt() 上挂着一模一样的注释。
这是本专栏想反复强调的一种阅读收益:很多"看起来精妙的常数"背后并没有精妙的推导。与其为它编一套自洽的解释,不如去看注释——注释里写着"arbitrary"就是 arbitrary。真要调它,只有一条正确路径:在你自己的负载上做 A/B 基准。
本地队列容量 256 同理——标准 multi_thread 并不读 Builder 的 local_queue_capacity,它在 tokio/src/runtime/scheduler/multi_thread/queue.rs:63 里是一个 const LOCAL_QUEUE_CAPACITY: usize = 256,配套一个 const MASK: usize = LOCAL_QUEUE_CAPACITY - 1(tokio/src/runtime/scheduler/multi_thread/queue.rs:71);它必须是 2 的幂,因为环形缓冲区的取模是用 & MASK 做的。loom 测试下这个常量被换成 4,好让状态空间小到能穷举。
组 6:可观测性与其他
metrics_poll_count_histogram_*—— Task 单次 poll 耗时的分布直方图(名字里是 count,文档说的是 poll times;默认关闭,需 unstable metrics)seed_generator—— 内部 RNG 的种子来源,让select!分支轮询顺序这类随机行为可复现(对应的 setterrng_seed需要tokio_unstable,文档见tokio/src/runtime/builder.rs:1024-1052)keep_alive: Option<Duration>—— blocking 线程空闲多久后回收。字段默认None,实际生效的是 blocking pool 里的const KEEP_ALIVE: Duration = Duration::from_secs(10)(tokio/src/runtime/blocking/pool.rs:171,取用处在tokio/src/runtime/blocking/pool.rs:211)unhandled_panic—— 未捕获 panic 的处理策略(unstable feature)
二十几个字段看起来多,但它们基本就是运行时可调旋钮的全集——唯一一个不在 Builder 上的口子是环境变量 TOKIO_WORKER_THREADS(见 4.12 节),它在 worker_threads 没被显式设置时接管默认值。这种"可调参数集中暴露在 Builder 上"的设计让配置在代码里一目了然。Go runtime 走的是另一种取舍:调参主要靠 GOMAXPROCS、GODEBUG 这类环境变量和 runtime 包里的函数,不需要改构造代码。
4.3 Kind:三种 Scheduler 的分叉
Tokio 提供了三种 Scheduler(current_thread / multi_thread / alt multi_thread)并非简单的"单线程 vs 多线程"选择。每种都有自己的理想应用场景:current_thread 适合 CLI 工具、GUI 后端、单连接服务器;multi_thread 适合通用后端服务;alt multi_thread(实验性)针对特定工作负载做了进一步调优。理解这三种分叉让你在选型时能做出贴合场景的选择,而不是"闭眼睛用 multi_thread"。
Tokio 把 Scheduler 类型抽出来成了 Kind 枚举:
// 来源:tokio-rs/tokio · tokio/src/runtime/builder.rs:209
#[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。绝大多数服务代码用的是这个MultiThreadAlt—— 实验性的多线程调度器(tokio_unstable)。一个不同设计思路的调度器,还在孵化,不推荐生产使用
为什么分成三种而不是 Linux / Go 那样的"一种统一调度器"?
答案是 Rust 生态的多样性:一个 GUI 应用根本不想要多线程 runtime——它所有任务必须在 UI 线程上跑(因为 GUI 原生 API 不线程安全);一个 CLI 工具也不需要开 16 个 worker——多了反而增加启动开销。把"是否多线程"做成用户可选,而不是默认开,尊重了不同场景的真实需求。
Builder::build 的入口就是一个三分支 match:
// 来源:tokio-rs/tokio · tokio/src/runtime/builder.rs:793
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:
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:
- 当启用了
netfeature,或者在 unix 上启用了process/signal,开启 I/O Driver——cfg 里process/signal这两项都额外带着unix条件,只有net是无条件算数的(tokio/src/runtime/builder.rs:348) - 当启用了
timefeature,开启 Time Driver
Signal Driver(Unix 信号)是跟着 I/O Driver 一起启动的,不是独立 Driver——它在 create_io_stack 里叠在 I/O Driver 之上,Process Driver 再叠在 Signal Driver 之上(tokio/src/runtime/driver.rs:152)。
为什么要用 cfg 而不是直接开? 因为 Tokio 支持按需裁剪——你可以用 tokio = { version = "1", default-features = false, features = ["sync"] } 只开同步原语、不要 I/O、不要 Time。这种裁剪对嵌入式、WASM、极简 CLI 工具场景非常有用。
实践建议:绝大多数服务代码就用 tokio = { version = "1", features = ["full"] } + .enable_all()。只有在明确需要裁剪时才精细控制 features。
4.5 Runtime 的三件装备
Runtime 结构体里只装了三样东西:Scheduler(调度器)、Handle(外接句柄)、BlockingPool(阻塞任务专用池)。这种"最小组合"的结构让 Runtime 的生命周期管理非常清晰——Runtime Drop 时按顺序关闭这三个子系统、不会有资源泄漏。本节把这三件装备的各自职责、生命周期、协作方式讲清——这是理解 Tokio 整体架构的关键视角。
Builder 最终产出的 Runtime 结构体:
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs:97
#[derive(Debug)]
pub struct Runtime {
scheduler: Scheduler,
handle: Handle,
blocking_pool: BlockingPool,
}
三件装备:
scheduler: Scheduler—— 把 Task 调度到 worker 线程上执行的装置handle: Handle—— 运行时的外接句柄,可 clone、可跨线程blocking_pool: BlockingPool—— 专门跑阻塞任务的线程池,和 worker 线程职责分开(multi_thread 的 worker 线程本身也是经它起的,见 4.10 节)
Scheduler 是一个 enum,对应三种 Kind:
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs:125
#[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 的,它的所有副本通过
Arc共享同一套底层数据 - BlockingPool 的生命周期和 Runtime 绑定——Runtime drop 时 BlockingPool 关闭
你无法 clone 一个 Runtime——因为 clone Runtime 就会导致"两个 Runtime 拿着同一组 worker 线程的控制权",这没有清晰语义。Runtime 是独占所有权的容器(进程里可以有多个 Runtime,但每个只有一个所有者);要在多处访问它,就 clone Handle。
BlockingPool 为什么和 Scheduler 分开?
你可能会想——为什么 Tokio 要把 blocking 任务和 async 任务交给两类线程?一个池统一调度不行吗?(实现上,multi_thread 的 worker 线程也是通过 spawn_blocking 起在 BlockingPool 上的,所以 Builder 给池子的上限是 max_blocking_threads + core_threads;下面说的"两个池"指职责上的分离,不是两套互不相干的线程。)
答案是两种池的工作性质完全不同:
| 维度 | 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 能承受"混合 async + 同步 blocking"负载——而很多人写其他语言的异步代码掉进的最大坑,就是没分两个池导致的 worker 阻塞(Node.js 的 event loop 被阻塞、Python asyncio 的事件循环被同步调用卡住)。
第 16 章(spawn_blocking 与 block_in_place)会把 BlockingPool 的完整机制拆透。
scheduler::Handle 内部其实有两种形态
4.6 节的代码里 scheduler::Handle 是一个 enum,variant 分别对应 current_thread、multi_thread(以及 tokio_unstable 下的 multi_thread_alt)。每个 variant 内部都是 Arc<某个具体 Handle 类型>。
为什么要用 enum 而不是 dyn trait?两个原因:
- 静态分派:enum match 的开销比 trait object 的虚调用小一点(特别是在 hot path 上频繁调用的
spawn) - 类型信息保留:某些场景(比如 runtime metrics)需要知道具体是哪种 scheduler,enum 能直接 match,trait object 需要 downcast
这种 "对外露 trait 一致性、对内保留具体类型" 的设计再次体现了 Tokio 的工程成熟度。类似模式在 tokio/src/runtime/driver.rs 的 IoStack / IoHandle / TimeDriver 等 enum 里也能看到。
4.6 Handle:Runtime 的外接口
Handle 是 Rust 里常见的"轻量级引用句柄"模式——Runtime 本身不能被 Clone(因为它独占 Scheduler)、但它的 Handle 可以。这种"本体独占 + 句柄可克隆"的设计让你能把对 Runtime 的访问能力传递到任意地方(跨线程、跨任务),同时保证 Runtime 本身的生命周期被单一持有者控制。这种设计模式在 Rust 库里很常见,理解了它你写任何 Rust 库都能受益。
Handle 的定义简单得让人吃惊:
// 来源:tokio-rs/tokio · tokio/src/runtime/handle.rs:14
#[derive(Debug, Clone)]
pub struct Handle {
pub(crate) inner: scheduler::Handle,
}
就一个 inner 字段。scheduler::Handle 内部是一个 enum:
// 简化: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:
// 来源:tokio-rs/tokio · tokio/src/runtime/handle.rs(current:138 / try_current:149 / spawn:187 / block_on / enter:83)
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:
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 可以跨线程、可以 clone、可以放进 static
由于 Handle 内部的 scheduler::Handle 每个 variant 都是 Arc,它可以:
- Clone:零成本(一次 Arc::clone,一次原子加)
- 跨线程:
Send + Sync,因为底层 Arc 指向的各个 scheduler Handle 都是Send + Sync - 放进
static/lazy_static/OnceLock:很多库这么做,比如一个 HTTP client 库内部持有一个 Handle 用于调度后台任务
但一个坑:Handle 存进 static 后,它持有的那份共享状态(scheduler 与 driver 的句柄)在进程结束前都不会释放。更要紧的是,Handle 活着不等于 Runtime 活着:Runtime 被 drop 之后,这个 Handle 照样能调 spawn,但任务列表已关闭,新 Task 会被立刻关掉(tokio/src/runtime/task/list.rs:118-121),JoinHandle 只会拿到取消错误。对跑到进程退出的服务没影响,对测试(每个 #[tokio::test] 各有一个 runtime)就容易踩中。
实践建议:确实要存就用 OnceLock<Handle>,并确保对应的 Runtime 活得和进程一样久。
Handle vs &Runtime:该用哪个?
两个 API 看起来功能重叠:
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 里"的场景——库代码常用
性能差异可以忽略——三种最终都走同一条 Handle::spawn 路径,C 只多一次 thread-local 读取和一次 Arc::clone。选最能表达你意图的那种。
4.7 block_on:一个细节丰富的门面函数
block_on 是一个表面简单、内部复杂的函数——用户看到的就是"给它一个 Future、它把 Future 跑完返回结果",但内部要协调 EnterGuard 设置、当前线程阻塞、事件循环、Waker 注册等一系列事件。本节把 block_on 的内部流程拆给你看——你会看到这个看似简单的函数背后的精妙工程。
Runtime::block_on 的原样源码:
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs:330
#[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)
}
}
这几行代码里有三个值得说的细节:
细节 1:#[track_caller]
这个属性(tokio/src/runtime/runtime.rs:330)让 panic 时报告的行号指向调用方,而不是 block_on 内部。写过被 expect/unwrap panic 折磨过的开发者,应该对这个属性肃然起敬。Tokio 里可能 panic 的 public API 大多带 #[track_caller]——好的错误定位能省下大量调试时间。
细节 2:BOX_FUTURE_THRESHOLD
它定义在 tokio/src/runtime/mod.rs(不是 util/),就一行:
// 来源:tokio-rs/tokio · tokio/src/runtime/mod.rs:395-397
/// Boundary value to prevent stack overflow caused by a large-sized
/// Future being placed in the stack.
pub(crate) const BOX_FUTURE_THRESHOLD: usize = 2048;
2 KB 阈值:如果 Future 大小超过 2 KB 且在 debug 模式下,block_on 会自动把 Future Box::pin 到堆上。同样的判断也出现在 Runtime::spawn 和 tokio::task::spawn(tokio/src/task/spawn.rs:172 那句 if cfg!(debug_assertions) && std::mem::size_of::<F>() > BOX_FUTURE_THRESHOLD)。
为什么只在 debug 做? 源码注释给的理由就是"preventing stack overflows on debug mode"(tokio/src/task/spawn.rs:170-171):debug 构建优化少,同一个 Future 往往比 release 大,更容易把栈撑爆,Tokio 就在 debug 下自动 Box 掉大 Future。代价是 release 下即使 Future 真的很大也不会被自动 Box——它要是大到能撑爆栈,就会出现 "debug 能跑、release 栈爆" 的诡异现象,这时得自己 Box::pin。
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.8 block_on 和 spawn 的线程走向对比
很多人第一次读 Tokio 代码会混淆两件事:spawn 让 Future 在哪个线程跑? block_on 又在哪个线程跑?
spawn 的行为:Future 被装进一个 Task,放进 scheduler 的队列。它在哪个 worker 线程被 poll 是不确定的——一开始可能在当前线程(从 worker 上 spawn 时,LIFO slot 优化会让它在当前任务让出后优先在当前 worker 跑;待在 LIFO slot 里时它不会被偷),但如果它被后来的任务挤进本地队列、而当前 worker 又忙,就可能被别的 worker 偷走。你不能假设 spawn 的 Future 一定在某个特定线程上——这是 Tokio 多线程 runtime 的根本性质,也是"Send + 'static"bound 的来源。
block_on 的行为:Future 在调用 block_on 的那个线程上被"驱动"——但这不意味着 Future 的每个 .await 都只用那一个线程。具体地:
- 如果 Future 内部只
.await了计时器、channel 这类东西(比如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 在同一个函数里混用。比如这段代码:
// 错误示范:在 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.9 EnterGuard 与 thread-local 的运行时绑定
Handle::current() 依赖一个 thread-local——如何知道"当前线程绑定了哪个 runtime"?
看 EnterGuard:
// 来源:tokio-rs/tokio · tokio/src/runtime/handle.rs:32
#[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 上下文中":
let handle = rt.handle().clone(); // 从某个地方拿到 handle,比如 Runtime::handle()
let _guard = handle.enter(); // 绑定当前线程
// 这段代码块里可以用 tokio::spawn 等
tokio::spawn(async { /* ... */ });
// _guard drop,恢复
这个 pattern 在把 Tokio 集成进已有线程池 / 同步框架时非常有用。第 18 章(多 runtime 架构)的 18.6 节会再讲这个用法。
4.10 Drop:关闭 runtime 的复杂性
"优雅关闭"(graceful shutdown)是所有运行时系统最容易被低估的难点之一。Tokio 的 Runtime::Drop 要正确处理:worker 线程的有序退出、正在跑的 Task 的取消、I/O Driver 的关闭、Timer 的清理、BlockingPool 的回收——每一个都有自己的 corner case。本节把 Drop 的完整流程讲清——读完你就理解了"为什么在 Drop 之后立刻退出进程"是最可靠的做法、以及为什么在复杂场景下需要 shutdown_background / shutdown_timeout。
Runtime 的 Drop 实现:
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs:481(删去分支里的注释)
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) 本身并不阻塞——multi_thread 这条路上它只是 Handle::shutdown()(tokio/src/runtime/scheduler/multi_thread/handle.rs:47)→ close()(tokio/src/runtime/scheduler/multi_thread/worker.rs:1135):关掉注入队列、notify_all() 把 park 住的 worker 全叫醒,然后就返回了。真正等线程退出的是 Drop 的下一步——Runtime 的字段析构到 blocking_pool 时触发 BlockingPool 的 Drop(tokio/src/runtime/blocking/pool.rs:281),它调 shutdown(None),不设超时地 join;而 multi_thread 的 worker 线程本来就是用 runtime::spawn_blocking 起在这个池子上的(tokio/src/runtime/scheduler/multi_thread/worker.rs:466),所以 worker 和 blocking 线程是在同一步被 join 掉的。效果不变:如果你的 runtime 里有 Task 跑着一个很长的同步代码段(比如意外的 std::thread::sleep(1_hour)),Runtime drop 会卡 1 小时。
解决办法:Runtime::shutdown_timeout(Duration) 给你一个 deadline,到点就不再等——还没退出的线程不会被杀掉,而是被泄漏、继续在后台跑到结束:
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs:435(这是 Runtime 的另一个方法,不是 Drop)
pub fn shutdown_timeout(mut self, duration: Duration) {
// Wakeup and shutdown all the worker threads
self.handle.inner.shutdown();
self.blocking_pool.shutdown(Some(duration));
}
生产环境建议:服务接到 SIGTERM 时显式调 shutdown_timeout(Duration::from_secs(30)),给卡在同步代码里的 worker 和 blocking 任务最多 30 秒;超时就不再等。这是比依赖 Drop 更可控的模式。注意这 30 秒不是给 async Task 跑完的时间:handle.inner.shutdown() 之后,每个 async Task 都会在下一个让出点被 drop(官方说明见 tokio/src/runtime/runtime.rs:35-37);要让在途 HTTP 请求处理完,得在调它之前自己排空(CancellationToken + JoinSet 之类,见第 20 章)。
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,被取消的 Future 持有的资源就会被释放。
但这不等于取消没有心智负担。drop 发生在 Future 的某个 .await 让出点上,操作做到一半被丢掉会不会丢数据、留下不一致的状态,这叫 cancel safety,要以各 API 文档为准:AsyncReadExt::read_exact、AsyncWriteExt::write_all 的文档都写明自己不是 cancel-safe 的(被取消时可能已经读进 buf 一部分数据、或已经写出一部分)。第 14 章 §14.9 会专门讲。
Tokio 的 shutdown 依赖这个性质:它不需要给每个 Task 发一个"你被取消了"的信号,直接 drop Task 就够。这让 shutdown 协议的实现非常干净。
shutdown 的真实顺序
把 shutdown 的完整顺序梳理清楚:
- 停止 scheduler 接收新 Task——关闭注入队列(
tokio/src/runtime/scheduler/multi_thread/worker.rs:1135),之后任务列表也被关闭,再 spawn 的 Task 会被立刻关掉 - 唤醒所有被 park 的 worker——让它们从休眠中醒来
- 每个 worker 先把手上这次 poll 做完(poll 不能被打断),然后在
pre_shutdown里对所有 Task 调 shutdown——未完成的 Future 被 drop(tokio/src/runtime/scheduler/multi_thread/worker.rs:988-998) - 清空本地队列和全局队列——所有 Task drop(
tokio/src/runtime/scheduler/multi_thread/worker.rs:1191-1210) - 最后一个退出的 worker 关闭 Driver——同在
shutdown_core里,先关 Time Driver:process_at_time(0, u64::MAX)把时间推到尽头,一次性触发所有还挂着的定时器(tokio/src/runtime/time/mod.rs:180-193);再关 I/O Driver:把所有ScheduledIo标成 shutdown 并唤醒等待者(tokio/src/runtime/io/driver.rs:130-137)。epoll fd 要等 driver 本身析构、mio::Poll被 drop 时才关 - 关闭 BlockingPool——等 blocking 线程 join 或超时(如上,multi_thread 的 worker 线程也是在这一步才被 join 的,所以第 3-5 步发生在这次 join 返回之前)
- Runtime drop 返回——整个进程可以继续或退出
每一步都有可能出 bug,最常见的是第 3 步:某个 Task 在一次 poll 里卡在同步代码(无限循环、std::thread::sleep)里,worker 就一直退不出来。
shutdown_background —— 另一个你应该知道的 API
除了 Drop 和 shutdown_timeout,Runtime 还有一个 shutdown_background:
// Tokio 1.40 有这个 API(简化签名)
impl Runtime {
pub fn shutdown_background(self);
}
语义:立即返回,不等 worker 和 blocking 线程 join 完成。相当于"我不管了,自己死"。源码里它就是一句 self.shutdown_timeout(Duration::from_nanos(0))(tokio/src/runtime/runtime.rs:469)——不是另一条关闭路径,而是"超时设成 0 的 shutdown_timeout"。
什么时候用这个?当你在关闭流程里自己被时间卡得很紧、又不想为了等 blocking 任务多等几秒时。典型场景:
- 信号处理快速退出:收到 SIGKILL(好吧那你根本没机会),或者快速响应 SIGTERM 不想等
- 测试清理:测试已经通过了,cleanup 期别再阻塞测试框架
代价:如果此时有 blocking 任务还在跑(比如一个 5 秒的 spawn_blocking 调用),它们会继续跑到完成——进程不会因此 hang(Rust 里 main 返回进程就退出,不等其他线程),但可能出现 "runtime 已经关了、但某些 log 还在打"的奇怪现象。
三种 shutdown 对比:
| API | 等 worker | 等 blocking | 适用场景 |
|---|---|---|---|
Drop(直接 drop Runtime) |
✅ 完整等 | ✅ 完整等 | 开发、测试 |
shutdown_timeout(d) |
✅ 最多等 d | ✅ 最多等 d | 生产服务 graceful shutdown |
shutdown_background |
❌ 不等 | ❌ 不等 | 快速退出、测试清理 |
实际生产代码里,shutdown_timeout(30s) 是常见的选择:先在应用层把在途请求排空,再用它给剩下的线程设一个 30 秒的上限。
4.11 嵌套 runtime 的三种合法 / 非法形态
一个常见困惑:可以在 runtime 里再跑 runtime 吗? 答案微妙——Tokio 允许的组合只有特定几种。
形态 A:根本不嵌套(最常见、合法)
#[tokio::main]
async fn main() {
some_work().await;
}
main 进程只有一个 runtime。所有 async 通过 .await 串起来,没有嵌套。绝大多数服务代码应该长这样。
形态 B:在 async 里想跑同步阻塞代码(合法,用 spawn_blocking)
#[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)
// 测试函数是同步的,但想跑一段 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)
#[tokio::main]
async fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { /* ... */ }); // ❌ panic
}
这就是前面说的经典 panic。不要写这种代码。
形态 E:runtime 里创建独立 runtime 并手动管理(合法但复杂)
#[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 { /* ... */ });
drop(guard);
// 不能让 inner_rt 在 async 上下文里被普通 drop:Drop 要无限期等 blocking 线程,
// 会 panic "Cannot drop a runtime in a context where blocking is not allowed"
inner_rt.shutdown_background();
}
最后那句 shutdown_background() 省不得:少了它,inner_rt 在 main 的 async 块结束时被 drop,BlockingPool 的 Drop(tokio/src/runtime/blocking/pool.rs:281-284)调 shutdown(None),一路走到 shutdown::Receiver::wait 就 panic(tokio/src/runtime/blocking/shutdown.rs:51,tokio 1.40.0 实测)。官方在 shutdown_background 的文档里给的正是这个用法(tokio/src/runtime/runtime.rs:441-468)。
这合法,但很少需要——只有"主 runtime + 完全隔离的次 runtime(比如一个专门处理某类阻塞服务的 runtime)"场景才值得。第 18 章讲多 runtime 架构时会展开。
总结记忆
- 要在 async 里等 Future:
.await,不要用block_on - 要在 async 里跑同步代码:
tokio::task::spawn_blocking - 要在 sync 里跑 async:
Runtime::new().unwrap().block_on(...) - 要在 sync 里 spawn 到现有 runtime:
Handle::current().spawn(...)(要求当前线程已 enter)
这四条覆盖绝大多数场景。不记得时查本章,不会错。
4.12 默认 worker 线程数的选择
worker_threads = None 时走的是 crate::loom::sys::num_cpus()。这个函数不是简单的"读 CPU 核数",它有两级:
// 来源:tokio-rs/tokio · tokio/src/loom/std/mod.rs:86(示意,非逐字:省去两处 panic! 的具体格式串)
const ENV_WORKER_THREADS: &str = "TOKIO_WORKER_THREADS";
match std::env::var(ENV_WORKER_THREADS) {
Ok(s) => { /* parse 成 usize;非法值或 0 直接 panic */ }
Err(std::env::VarError::NotPresent) => {
std::thread::available_parallelism().map_or(1, NonZeroUsize::get)
}
Err(std::env::VarError::NotUnicode(e)) => { /* panic */ }
}
两个值得记住的点:
- 环境变量
TOKIO_WORKER_THREADS优先于自动探测——这是 Builder 之外唯一一个能改运行时行为的口子。它写错格式(非数字、或 0)会直接 panic,不是静默忽略 - 自动探测用
std::thread::available_parallelism()(Rust 1.59 稳定),而不是num_cpuscrate。前者会考虑线程亲和性掩码与 Linux 上的 cgroup CPU quota(std 的文档注释与实现分别在library/std/src/thread/functions.rs:652和library/std/src/sys/thread/unix.rs:165);探测失败时回退到 1(tokio/src/loom/std/mod.rs:103那句map_or(1, NonZeroUsize::get)),而不是回退到宿主机核数
这对容器部署很重要:limits.cpu: "2" 的 Pod 里,available_parallelism() 给出的是受限后的并行度,而不是宿主机那几十个核。但如果你用 .worker_threads(N) 写死过,容器 quota 变了它是不会跟着动的——写死之前先想清楚这一点。
4.13 一个完整例子:从 #[tokio::main] 到退出
把本章所有东西拼起来看一个完整生命周期:
#[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,所有字段填默认值(tokio-1.40.0/tokio/src/runtime/builder.rs:271-330 的 Builder::new):kind=MultiThread、worker_threads=None(build 时走 num_cpus():先看 TOKIO_WORKER_THREADS,否则 available_parallelism().map_or(1, NonZeroUsize::get),见 tokio-1.40.0/tokio/src/loom/std/mod.rs:85-110)、max_blocking_threads=512、keep_alive=None(实际生效值是 blocking pool 里的 KEEP_ALIVE = 10s,见 tokio-1.40.0/tokio/src/runtime/blocking/pool.rs:171 与 tokio-1.40.0/tokio/src/runtime/blocking/pool.rs:211)、global_queue_interval=None、event_interval=61(tokio-1.40.0/tokio/src/runtime/builder.rs:244,注释直言 "fairly arbitrary…copied from golang")、local_queue_capacity=256 等。
Step 3:.enable_all()
把 enable_io=true、enable_time=true(取决于 feature flag)。
Step 4:.build()
match kind → 走 build_threaded_runtime()(tokio-1.40.0/tokio/src/runtime/builder.rs:1345-1391,第 5 章详拆)。按源码里的实际先后顺序是:
core_threads = self.worker_threads.unwrap_or_else(num_cpus)(tokio-1.40.0/tokio/src/runtime/builder.rs:1350)driver::Driver::new(self.get_cfg(core_threads))?一次性建出 I/O Driver(内部mio::Poll::new()拿 epoll fd)和 Time Driver(分层时间轮)(tokio-1.40.0/tokio/src/runtime/builder.rs:1352)blocking::create_blocking_pool(self, self.max_blocking_threads + core_threads)建 BlockingPool(初始为空,按需 spawn 阻塞线程)——注意上限是max_blocking_threads + core_threads,不是单纯的 512(tokio-1.40.0/tokio/src/runtime/builder.rs:1355-1357)MultiThread::new(core_threads, driver, driver_handle, blocking_spawner, seed_generator_2, Config { .. })造出 scheduler / handle / launch——BlockingPool 的 spawner 是作为入参喂进来的,所以它必须先于 scheduler 建好(tokio-1.40.0/tokio/src/runtime/builder.rs:1359-1383)let _enter = handle.enter(); launch.launch();这时才真正 spawn N 个 worker 线程,每个 worker 有自己的本地队列(tokio-1.40.0/tokio/src/runtime/builder.rs:1387-1389)Ok(Runtime::from_parts(Scheduler::MultiThread(scheduler), handle, blocking_pool))(tokio-1.40.0/tokio/src/runtime/builder.rs:1391,build()的签名是pub fn build(&mut self) -> io::Result<Runtime>,见tokio-1.40.0/tokio/src/runtime/builder.rs:793)
Step 5:.block_on(body)
- 用
#[track_caller]标记位置(tokio-1.40.0/tokio/src/runtime/runtime.rs:330) - 如果
cfg!(debug_assertions)且size_of::<F>() > BOX_FUTURE_THRESHOLD(= 2048 字节,见tokio-1.40.0/tokio/src/runtime/mod.rs:397):body = Box::pin(body)(tokio-1.40.0/tokio/src/runtime/runtime.rs:332) - 调
block_on_inner(body)(tokio-1.40.0/tokio/src/runtime/runtime.rs:340):- multi_thread 下
body不会交给 scheduler、也不变成 Task:MultiThread::block_on进入 runtime 上下文后,用CachedParkThread::block_on在主线程上直接 poll 它(tokio-1.40.0/tokio/src/runtime/scheduler/multi_thread/mod.rs:82-89、tokio-1.40.0/tokio/src/runtime/park.rs:271) - 主线程进入等待循环:Pending 就 park;epoll_wait 由 worker 线程负责,I/O 或定时器事件到了由 worker 那边 wake 主线程
- 等
body返回Poll::Ready,返回它的 Output
- multi_thread 下
Step 5 的微观动作:
- 主线程 enter 到 runtime 的 context(thread-local 被设置)
body被 pin 在栈上(或 Box 里)- 创建一个"外层 Context" —— 其 Waker 会 wake 主线程(用 park/unpark 机制)
- 循环:poll body → Pending 就 park 主线程(Tokio 自己的
ParkThread,Mutex + Condvar,见tokio-1.40.0/tokio/src/runtime/park.rs)→ Waker wake → unpark → poll body 继续 → 最终 Ready 返回 - 期间 worker 线程并发处理
body内部spawn的其他 Task;主线程只驱动顶层 body 自己
在 multi_thread 下,主线程只有一个角色:body 的驱动器,而不是 runtime 的事件中心——I/O 和定时器事件由拿到 driver 的 worker 处理。如果你去 flamegraph 一个 Tokio 程序,主线程的调用栈大致是"block_on → poll body → park";worker 线程的栈大致是"run → poll task"(外加轮到它时的 driver park)。两者分工清晰。
Step 6:main 返回
Runtime 对象离开作用域,触发 Drop:
shutdown(handle)→ 通知所有 worker 停- worker 线程收到 shutdown → drop 所有未完成的 Task → 退出
- BlockingPool 置 shutdown 标志并
notify_all,空闲的 blocking 线程被叫醒后直接退出(不等 keep_alive 超时),Drop 再逐个 join(tokio-1.40.0/tokio/src/runtime/blocking/pool.rs:243-275) - 进程正常 exit
每一个 "step" 都是对应源码里的几十到几百行代码。后续章节会把每一步都钻进去——但现在你已经有了整张地图。这张地图是后 16 章的定位参考。
补充:为什么这个装配顺序是这个顺序
细心的读者会注意到:Builder 内部有很多字段,但 build() 要按一个特定顺序调用它们。比如:
- I/O Driver 必须在 worker 线程 spawn 之前初始化——因为 worker 里要用它(
driver是MultiThread::new的入参) - Time Driver 也必须先于 worker——它和 I/O Driver 由同一句
driver::Driver::new一起建出来 - BlockingPool 也必须先于 scheduler——
blocking_spawner同样是MultiThread::new的入参(tokio-1.40.0/tokio/src/runtime/builder.rs:1355-1364);它只是不依赖 worker 线程已经跑起来,所以排在 driver 之后即可 - scheduler::Handle 必须在 worker spawn 之前构造——
launch.launch()排在整条装配链的最后一步,每个 worker 起来就能拿到一份 Handle 的 Arc
这个依赖关系是通过 build_threaded_runtime() 里硬编码的调用顺序保证的——不是用 DI 容器、不是用显式依赖图。Rust 的资源管理让这种"硬编码顺序"成为最自然的表达方式:资源的 RAII 生命周期天然描绘了依赖关系。
这也是为什么 Tokio 没有也不需要一个"dependency injection framework"——Rust 的类型系统和所有权已经承担了 DI 框架的大部分职责。读 Tokio 代码时你很少见到"工厂"、"装配器"、"IoC 容器"这些 Java/C# 常见的概念——所有的"谁依赖谁、谁先构造"都直接反映在函数调用顺序和字段类型里。这种用代码结构本身承载架构的风格,是 Rust 系统编程的重要美学。
4.14 #[tokio::main] 背后被过度低估的三件事
#[tokio::main] 太常用了,以至于大多数人从不仔细看它。但它背后有三件事值得单独讲——每一件都会在生产环境影响你:
事一:宏默认是 multi_thread,即使你只有 1 核机器
#[tokio::main] 没指定 flavor 时展开为 Builder::new_multi_thread()。即使你跑在 1 vCPU 的廉价 VPS 上,也会是 multi_thread(只不过 worker_threads 会是 1)。
这带来微妙的开销:
- Work-stealing、
steal_work、搜索者计数这些逻辑即使偷不到东西,也仍然在 worker 主循环里跑 - 每个 worker 多一份本地队列(
LOCAL_QUEUE_CAPACITY = 256的定长环形缓冲区)+ LIFO slot 的内存
(Task 本身的内存布局两种调度器是同一个 Cell<T, S>,差别只在泛型参数 S: Schedule 落在哪个实现上——不要以为 multi_thread 的 Task"更胖"。)
对性能敏感的单线程场景(CLI 工具、嵌入式代理),明确写 #[tokio::main(flavor = "current_thread")] 能省掉这些开销。但它不会放宽 Send 要求:1.40.0 的 tokio::spawn 在两种调度器下都要求 F: Send + 'static(tokio/src/task/spawn.rs:165-169),要跑 !Send 任务得用 LocalSet(第 7 章)。
事二:enable_all 的默认启用可能超出你的需要
.enable_all() 启用 I/O + Time Driver。I/O Driver 会在 runtime 启动时创建一个 epoll/kqueue 句柄(外加一个用于唤醒的 eventfd 等)——这要走几次系统调用,不免费。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(tokio-macros/src/entry.rs:85:default_flavor 按 is_test 分叉,测试是 CurrentThread、main 是 Threaded):
// #[tokio::test] 展开后大致(简化自 tokio-macros/src/entry.rs 的 parse_knobs)
#[::core::prelude::v1::test]
fn my_test() {
let body = async { /* ... */ };
// 测试路径还会把 body pin 到栈上、再以 Pin<&mut dyn Future> 交给 block_on,
// 目的是减少 block_on 因泛型参数 F 而产生的单态化副本
return tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed building the Runtime")
.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——报的是 can call blocking only when running on the multi-threaded runtime,源码里那句注释写得很直白:This probably means we are on the current_thread runtime or in a LocalSet, where it is _not_ okay to block.(tokio/src/runtime/scheduler/multi_thread/worker.rs:386)。
踩过的人血泪:你在 main 里的逻辑跑得好好的,写测试时复用同样的代码就挂了。这时候的修法是:#[tokio::test(flavor = "multi_thread")],让测试也用 multi_thread runtime。
4.15 和这个系列的其他专栏的关联
本章讲的"Builder + Runtime + Handle + EnterGuard"的设计模式,在 Rust 生态里不是 Tokio 独创——这是一类叫"可配置资源容器"的通用模式。《Rust 编译器与运行时揭秘》专栏第 1 章(编译管线全景)里讲 rustc 驱动层时是同一套思路:一个装着 session、codegen backend、各种 Arena 的顶层对象,由一份配置结构装配出来,外部只拿到受限的句柄。两边对照着读,会看清"配置结构 → 装配 → 只暴露句柄"是这类系统的通用骨架。
这种"Builder 配置 + 组装 + Handle 外接"是 Rust 工业级 API 的标准形态。写 Rust 库时如果你做到这三件事,你的库就已经具备了进生产的基础条件。
另外,Tokio 的 Handle / thread-local / EnterGuard 这套"当前线程绑定运行时"的机制,和 《Vue 3 设计与实现》专栏第 10 章(组件系统) 里 Vue 的 currentInstance / setCurrentInstance 机制(setup 执行时动态绑定"当前组件"到一个 thread-local-ish 的全局槽)思路同构。两个系统都用隐式的 thread/task-local context 让深层调用能拿到"当前上下文"——这套机制是所有运行时型系统的共用工具箱。
4.16 本章小结
带走三件事:
#[tokio::main]没有魔法——它就是Builder::new_multi_thread().enable_all().build().block_on(body)的宏替你写出来- Builder 的 24 个字段几乎就是运行时可调旋钮的全集(结构体定义见
tokio-1.40.0/tokio/src/runtime/builder.rs:45-124,其中unhandled_panic要tokio_unstable;唯一的例外是环境变量TOKIO_WORKER_THREADS,在tokio-1.40.0/tokio/src/loom/std/mod.rs:89-91读取)——分成调度器、Driver、线程池、回调、调度调优、可观测性六组,每一组对应运行时的一类行为 - Runtime 有三件装备:Scheduler(调度执行)、Handle(外接接口,可 clone)、BlockingPool(阻塞任务专用)。三者生命周期由 Runtime 独占持有,Drop 时有序关闭
一份 "该调什么不该调" 的速查清单
本章看了太多 Builder 字段,最后给你一份务实的"调参优先级"清单,按场景分级:
绝大多数服务默认值已经最优,不要调:
global_queue_interval(Builder 默认None;current_thread 落到DEFAULT_GLOBAL_QUEUE_INTERVAL = 31,见tokio-1.40.0/tokio/src/runtime/scheduler/current_thread/mod.rs:122与tokio-1.40.0/tokio/src/runtime/scheduler/current_thread/mod.rs:136-138,multi_thread 用基于平均 poll 时间的自调优策略)、event_interval(61,tokio-1.40.0/tokio/src/runtime/builder.rs:232与tokio-1.40.0/tokio/src/runtime/builder.rs:244)、local_queue_capacity(256,tokio-1.40.0/tokio/src/runtime/builder.rs:314;设置入口带assert!(value.is_power_of_two()),见tokio-1.40.0/tokio/src/runtime/builder.rs:1187,而且只在loom下存在、只有MultiThreadAlt读取)—— 不要调,除非你有非常具体的基准测试数据max_blocking_threads(512,tokio-1.40.0/tokio/src/runtime/builder.rs:289)—— 不要调,一般也用不满nevents(1024,tokio-1.40.0/tokio/src/runtime/builder.rs:277)—— 不要调
大多数服务需要设:
worker_threads—— 容器 / K8s 下看 CPU limit 决定keep_alive(字段默认None,实际生效 10s ——KEEP_ALIVE常量在tokio-1.40.0/tokio/src/runtime/blocking/pool.rs:171,unwrap_or在tokio-1.40.0/tokio/src/runtime/blocking/pool.rs:211) —— 如果你的 blocking 任务比较频繁,设长点避免反复 spawn 线程
特定场景下设:
thread_name—— 线上方便ps/top/ perf 区分 Tokio 线程和其他after_start/before_stop—— 接入 tracing / metrics 初始化before_park/after_unpark—— 定位"worker 为什么不工作"的深度调试工具
几乎永远不设:
seed_generator—— 复现调试用(setterrng_seed需tokio_unstable),生产无意义start_paused—— 单元测试用thread_stack_size—— 默认是None(tokio-1.40.0/tokio/src/runtime/builder.rs:295,沿用std::thread的默认栈,Linux 上是 2 MiB),只在你遇到深层 async 状态机撑爆默认栈时才考虑(极罕见)
核心原则:Tokio 的默认值是多年生产实践沉淀下来的合理起点(其中一部分——比如 event_interval = 61——源码注释直言 "The number 61 is fairly arbitrary. I believe this value was copied from golang.",见 tokio-1.40.0/tokio/src/runtime/builder.rs:243,那就更没有理由凭直觉去动它)。除非你能用 tokio-console / perf / flamegraph 拿到明确证据说某个默认值在你的场景下次优,否则不要调。一句话:没有 profile 数据支撑的调参,通常只是把默认值换成一个你更不了解的值。
4.17 Runtime::new() 是什么的简称
最后一个小知识点:你可能见过 tokio::runtime::Runtime::new() 这种写法(不经过 Builder)。它等价于:
// 来源:tokio-rs/tokio · tokio/src/runtime/runtime.rs:181
pub fn new() -> std::io::Result<Runtime> {
Builder::new_multi_thread().enable_all().build()
}
注意它没有 current_thread 的回退分支——#[cfg(feature = "rt-multi-thread")] 是挂在这个方法自己头上的(tokio/src/runtime/runtime.rs:179;impl Runtime 块本身没有被 cfg 包住,文件顶上那个 cfg_rt_multi_thread! 只圈了几行 use),也就是说没开 rt-multi-thread feature 时根本不存在 Runtime::new() 这个方法(编译期就找不到,不是运行时降级)。想要单线程 runtime 只能显式写 Builder::new_current_thread()。当你不需要任何非默认配置时,直接用 Runtime::new() 最简洁。
延伸阅读
- 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 把源码推过一条固定管线的方式,可与 Tokio 的 Runtime 驱动任务对照着读