Skip to content

第4章 Tokio Runtime 架构总览

"Every magic in software is just unread code." —— 笔者

本章要点

  • #[tokio::main] 展开后是一段确定性的 15 行代码——里面有 Builder / Runtime / Handle 三个主要对象的完整装配流程
  • Builder25+ 个可配置字段,分成 6 组:调度器类型、Driver 开关、线程池容量、回调 Hook、可观测性、调度调优。每一组都对应运行时某个具体行为
  • Tokio 有三种 SchedulerCurrentThreadMultiThreadMultiThreadAlt(实验性)——它们在 Builder::build 的 match 分支里分叉
  • enable_all() 这个名字有点骗人——它实际只启用 io + time 两个 Driver,因为 signal / process 需要 unix feature 才自动开
  • Runtime::block_on 的真实代码里有一个debug 下自动 Box 大 Future 的优化(BOX_FUTURE_THRESHOLD)——这个细节解释了 "为什么 debug 能跑但 release 栈爆" 的诡异现象
  • Handle外界访问 Runtime 的唯一合法入口——可以 Clone、可以跨线程传、可以放进 staticRuntime 本身不可 Clone
  • EnterGuard 通过 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 行

  1. Builder::new_multi_thread() 拿到一个配了默认值的 Builder
  2. .enable_all() 打开 I/O 和 Time Driver
  3. .build() 真正构造 Runtime——这一步会 spawn worker 线程、初始化 epoll / kqueue、初始化时间轮
  4. .expect(...) 处理构造失败(极罕见,通常是 fd 耗尽)
  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 工具直接看自己代码被展开成什么:

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 最多取多少事件,默认 1024
  • start_paused: bool —— Time Driver 启动时立即暂停时钟(单元测试用)

组 3:线程池容量

  • worker_threads: Option<usize> —— worker 线程数。multi_thread 默认 = CPU 核数
  • max_blocking_threads: usize —— blocking 线程池最大线程数。默认 512
  • 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)

组 5:调度调优

  • global_queue_interval: Option<u32> —— 每 poll 多少个任务后,强制从全局队列拉一个(避免本地队列的 Task 饿死全局队列的 Task)。默认 31
  • event_interval: u32 —— 每 poll 多少个任务后,去 Driver 查一次事件。默认 61。这两个数都是质数——避免"周期性同步"带来的抖动
  • local_queue_capacity: usize —— 每个 worker 本地队列的容量。默认 256
  • disable_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
  • 当启用了 time feature,开启 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?两个原因:

  1. 静态分派:enum match 的开销比 trait object 的虚调用小一点(特别是在 hot path 上频繁调用的 spawn
  2. 类型信息保留:某些场景(比如 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 上下文中调用会 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:

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_onspawn 的线程走向对比

很多人第一次读 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 了同步风格的东西(比如 sleeponeshot::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_onspawn 在同一个函数里混用。比如这段代码:

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::spawnHandle::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 要做三件事

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

这是 Rust 异步最被低估的优势之一。对比 Python / JavaScript 的协程取消——需要用 CancelledError 异常穿透整个调用栈,容易漏、容易忘 try-finally。Rust 的"drop = 取消"是零额外心智负担的——只要你的资源类正确实现了 Drop(Rust 类型系统的基础要求),取消就自动正确。

Tokio 的 shutdown 依赖这个性质:它不需要给每个 Task 发一个"你被取消了"的信号,直接 drop Task 就够。这让 shutdown 协议的实现非常干净。

shutdown 的真实顺序

把 shutdown 的完整顺序梳理清楚:

  1. 停止 scheduler 接收新 Task——已经 spawn 但未开始的 Task 进入 shutdown 队列
  2. 唤醒所有被 park 的 worker——让它们从休眠中醒来
  3. 每个 worker 处理当前正在 poll 的 Task——有的能在 shutdown 超时前跑完 Ready,有的被强制 drop
  4. 清空本地队列和全局队列——所有 Task drop
  5. 关闭 BlockingPool——等 blocking 线程 join 或超时
  6. 关闭 I/O Driver——调 mio::Poll::drop,释放 epoll fd
  7. 关闭 Time Driver——drop 时间轮里所有定时器的 Waker(这些 Waker 可能 wake 已经 dead 的 Task,但 wake 是幂等的,所以无害)
  8. Runtime drop 返回——整个进程可以继续或退出

每一步都有可能出 bug。第 19 章会讲几个真实的 shutdown bug:worker 卡在无限循环、blocking 任务不响应 keep_alive、Driver drop 顺序错误等。

shutdown_background —— 另一个你应该知道的 API

除了 Dropshutdown_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 里跑 asyncRuntime::new().block_on(...)
  • 要在 sync 里 spawn 到现有 runtimeHandle::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=MultiThreadworker_threads=None(会在 build 时用 CPU 核数)、max_blocking_threads=512global_queue_interval=Noneevent_interval=61local_queue_capacity=256 等。

Step 3:.enable_all()enable_io=trueenable_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 的微观动作

  1. 主线程 enter 到 runtime 的 context(thread-local 被设置)
  2. body 被 pin 在栈上(或 Box 里)
  3. 创建一个"外层 Context" —— 其 Waker 会 wake 主线程(用 park/unpark 机制)
  4. 循环:poll body → Pending 就 park 主线程(std:🧵:park)→ Waker wake → unpark → poll body 继续 → 最终 Ready 返回
  5. 期间 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 章(编译管线全景)里讲 rustcCompiler 对象时用的是同一套思路:rustcCompiler 内部有 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 本章小结

带走三件事:

  1. #[tokio::main] 没有魔法——它就是 Builder::new_multi_thread().enable_all().build().block_on(body) 的宏替你写出来
  2. Builder 的 25+ 字段是运行时所有可调旋钮的完整集合——分成调度器、Driver、线程池、回调、调度调优、可观测性六组,每一组对应运行时的一类行为
  3. 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 的任何诡异波形里看出调度问题。


延伸阅读

基于 VitePress 构建