Tokio 源码深度解析

第4章 Tokio Runtime 架构总览

作者 杨艺韬 · 13,154 字 · 发布于 · 更新于

本章要点

  • #[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 本身不可 Clone
  • EnterGuard 通过 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 行:

  1. 用 Builder::new_multi_thread() 拿到一个配了默认值的 Builder
  2. .enable_all() 打开 I/O 和 Time Driver
  3. .build() 真正构造 Runtime——这一步会 spawn worker 线程、初始化 epoll / kqueue、初始化时间轮
  4. .expect(...) 处理构造失败(极罕见,比如创建 epoll 实例或线程失败)
  5. .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 空闲度的唯一官方 hook
  • before_spawn / after_termination —— 每个 Task 创建/终结时回调(用于 tracing / metrics;对应的 setter on_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! 分支轮询顺序这类随机行为可复现(对应的 setter rng_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:

  • 当启用了 net feature,或者在 unix 上启用了 process / signal,开启 I/O Driver——cfg 里 process / signal 这两项都额外带着 unix 条件,只有 net 是无条件算数的(tokio/src/runtime/builder.rs:348)
  • 当启用了 time feature,开启 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?两个原因:

  1. 静态分派:enum match 的开销比 trait object 的虚调用小一点(特别是在 hot path 上频繁调用的 spawn)
  2. 类型信息保留:某些场景(比如 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 上下文中调用会 panic
  • Handle::try_current() —— 同上,但用 Result 返回而非 panic
  • Handle::spawn —— 在这个 Handle 对应的 runtime 上 spawn 一个 Task
  • Handle::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 要做三件事:

  1. 通知所有 worker 线程"该收工了"——通常通过给它们发一个特殊的 shutdown 信号
  2. 清空所有 Task 的队列(未完成的 Task 被 drop)
  3. 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 被关连接
  • 任何 Drop impl 都会被触发

这是 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 的完整顺序梳理清楚:

  1. 停止 scheduler 接收新 Task——关闭注入队列(tokio/src/runtime/scheduler/multi_thread/worker.rs:1135),之后任务列表也被关闭,再 spawn 的 Task 会被立刻关掉
  2. 唤醒所有被 park 的 worker——让它们从休眠中醒来
  3. 每个 worker 先把手上这次 poll 做完(poll 不能被打断),然后在 pre_shutdown 里对所有 Task 调 shutdown——未完成的 Future 被 drop(tokio/src/runtime/scheduler/multi_thread/worker.rs:988-998)
  4. 清空本地队列和全局队列——所有 Task drop(tokio/src/runtime/scheduler/multi_thread/worker.rs:1191-1210)
  5. 最后一个退出的 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 时才关
  6. 关闭 BlockingPool——等 blocking 线程 join 或超时(如上,multi_thread 的 worker 线程也是在这一步才被 join 的,所以第 3-5 步发生在这次 join 返回之前)
  7. 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 */ }
}

两个值得记住的点:

  1. 环境变量 TOKIO_WORKER_THREADS 优先于自动探测——这是 Builder 之外唯一一个能改运行时行为的口子。它写错格式(非数字、或 0)会直接 panic,不是静默忽略
  2. 自动探测用 std::thread::available_parallelism()(Rust 1.59 稳定),而不是 num_cpus crate。前者会考虑线程亲和性掩码与 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

Step 5 的微观动作:

  1. 主线程 enter 到 runtime 的 context(thread-local 被设置)
  2. body 被 pin 在栈上(或 Box 里)
  3. 创建一个"外层 Context" —— 其 Waker 会 wake 主线程(用 park/unpark 机制)
  4. 循环:poll body → Pending 就 park 主线程(Tokio 自己的 ParkThread,Mutex + Condvar,见 tokio-1.40.0/tokio/src/runtime/park.rs)→ Waker wake → unpark → poll body 继续 → 最终 Ready 返回
  5. 期间 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 本章小结

带走三件事:

  1. #[tokio::main] 没有魔法——它就是 Builder::new_multi_thread().enable_all().build().block_on(body) 的宏替你写出来
  2. 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、线程池、回调、调度调优、可观测性六组,每一组对应运行时的一类行为
  3. 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 —— 复现调试用(setter rng_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() 最简洁。


延伸阅读