Tokio 源码深度解析

第7章 current_thread runtime 与 LocalSet

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

本章要点

  • current_thread runtime 只跑在一个线程上——调用 block_on 的那个线程同时扮演 worker 和主驱动者。没有 worker thread spawn、没有 work-stealing、没有 LIFO slot
  • Core 结构简化成一个 VecDeque<Notified> 队列 + 一个 tick 计数 + 一个 Driver——总共 6 个字段,比 multi_thread 的 Core 少 5 个
  • block_on 用 AtomicCell<Core> 实现所有权传递:多个线程可以同时调 block_on,但只有一个能拿到 Core;其他线程阻塞等待(期间仍会 poll 自己的 future)
  • 主循环结构和 multi_thread 骨架一致——tick → poll 顶层 future → next_task → 运行 → park,但去掉了 steal_work 和 idle protocol
  • LocalSet 不是一个独立 runtime——它是一个可以放进任何 runtime 的 "!Send Future 容器"。你可以在 #[tokio::main] 多线程 runtime 里用 LocalSet::new().run_until(...) 跑 !Send 代码
  • spawn_local 依赖thread-local 里保存的当前 LocalSet 引用——调用时不在 LocalSet 的 run_until / block_on 里会 panic
  • LocalSet 实现了 Future——它的 poll 推进内部就绪的 !Send Task(每轮最多 61 个)、然后根据是否有剩余 Task 返回 Pending 或 Ready

7.1 为什么要有 current_thread runtime

"多线程就一定好"是并发领域最常见的误解之一。对于很多场景——CLI 工具、GUI 后端、游戏引擎、嵌入式应用——多线程反而是负担:工作不够大、拆分开销反而拖累、同步原语也变成多余的成本。current_thread runtime 就是为这些场景而生:单线程、无 work-stealing、调度路径上几乎没有跨线程同步(spawn 仍要求 Send,真正的 !Send 任务交给 LocalSet,见 7.6 节)。这一节讲清"在哪些场景下 current_thread 才是最优选"——避免用"全场通用"的 multi_thread 解决所有问题的过度工程倾向。

到这里你可能会问:既然 multi_thread 这么厉害——work-stealing、LIFO slot、能吃满多核——为什么还要搞一个单线程 runtime?

答案是不是每个场景都需要、也不是每个场景都允许多线程。至少有四类真实场景单线程 runtime 是最优解、甚至是唯一解:

场景一:GUI 主线程

几乎所有 GUI 框架(iced、egui、GTK、Windows WPF、Cocoa)都要求所有 UI 操作在 UI 线程上执行——这是操作系统层面的硬约束。如果你在 worker thread 上更新 UI,轻则奇怪的 bug,重则崩溃。

如果 GUI 应用还想跑异步(比如后台 HTTP 请求),一种自然的做法是:UI 线程运行 current_thread runtime,async fn 里的 .await 让出给 UI 事件循环。各 GUI 框架自己的异步集成并不统一(例如 iced 的 tokio 执行器用的就是默认 multi_thread 的 Runtime::new()),落地时以所用框架为准。

场景二:CLI 工具和简单脚本

cargo xx 类工具、一次性数据处理脚本、小型 CLI——它们跑一个任务就退出。为此开一圈 worker 线程完全是浪费:线程创建是系统调用,而整个脚本可能几十毫秒就结束了。

current_thread 启动明显更快——Builder::build 走 build_current_thread_runtime 时一个 worker 线程都不 spawn(block_on 的调用者就是唯一的 worker),也就没有 Launch::launch 那一串 spawn_blocking。对于"主函数跑几下就 exit"的场景明显更合适。

场景三:嵌入已有事件循环

Electron、Flutter、游戏引擎、数据库服务器——这些系统自己有复杂的主事件循环,不允许 Tokio 另外起 worker thread(会和主循环抢 GIL / mutex / 资源)。

这种情况下把 Tokio 作为"可驱动的子引擎":用 current_thread runtime 的 block_on 把 async 代码从主循环里跑起来、跑完返回主循环。deno 就走这条路:它的 deno_runtime 建的是 current_thread runtime,JS/V8 始终跑在调用 block_on 的那个线程上。

场景四:需要访问 !Send 类型

有些类型不能跨线程——Rc、RefCell、一些 GUI 句柄、未同步的 FFI 对象。tokio::spawn 要求 Future 是 Send + 'static,上面这些类型直接卡住。

注意这一点两种 runtime 都一样:1.40.0 里 current_thread 的 spawn 同样要求 Send(7.6 节细讲)。单线程场景下真正放开 Send 的是 LocalSet 的 spawn_local(仍要求 'static),block_on 的顶层 future 本身也不要求 Send。current_thread + LocalSet 是 !Send 代码最自然的落脚点,而 LocalSet(本章后半讲)也能放进多线程 runtime 里用。

四种场景的统一底色

四种看似不同的场景,底层都是一个理由:"多线程在这里不是优势,是负担"。

  • GUI 场景:多线程不被允许
  • CLI 场景:多线程没有收益
  • 嵌入式场景:多线程破坏架构
  • !Send 场景:多线程不可行

Tokio 认识到这一点并专门提供单线程运行时——这是对工程现实的尊重。对比一些运行时"只支持多线程、嵌入式 /!Send 不管"的做法,Tokio 的多样性承诺了它"几乎在任何场景都可用"。


7.2 #[tokio::main(flavor = "current_thread")] 展开出了什么

把第 4 章讲的 #[tokio::main] 宏展开放到当前上下文里再看一次。加 flavor = "current_thread" 参数后,展开结果是:

// #[tokio::main(flavor = "current_thread")] 展开
fn main() {
    let body = async { /* 你的 async 代码 */ };

    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("Failed building the Runtime")
        .block_on(body)
}

和多线程版的唯一区别是 new_current_thread() 代替了 new_multi_thread()。这一个改动会让第 4 章图里所有的 scheduler 分叉走向 current_thread 一支——用第 7.3 节的结构代替第 5 章的结构。

实际工程经验:在一个典型的 Rust 项目里,测试代码用 #[tokio::test](默认 current_thread)、服务入口用 #[tokio::main](默认 multi_thread)。这个约定让单元测试快速、让生产服务利用多核——一个项目里两种 runtime 并存是常态。

如果你的单元测试代码里有 block_in_place,记得给测试加 #[tokio::test(flavor = "multi_thread")]——否则在 current_thread 下会 panic(spawn_blocking 不受影响,两种 runtime 都有 blocking 线程池)。这是 4.14 节埋下的伏笔、这里正式点破。


7.3 CurrentThread / Core / Handle / Shared 的四件套

current_thread 的四件套结构看起来和 multi_thread 有相似之处——都有 Core、Shared、Handle——但内部复杂度低得多:没有 worker 数组、没有 Remote、没有 steal 机制、没有 LIFO slot、没有 Parker/Unparker 协议。这种"移除不必要的复杂度"很直观地反映在代码量上:current_thread 整个调度器就是一个 795 行的 tokio/src/runtime/scheduler/current_thread/mod.rs,而 multi_thread 目录(worker.rs 1261 行 + queue.rs 608 行 + idle.rs 240 行 + park.rs 242 行 + …)合计约 2800 行。本节把这四件套的各自职责讲清——读完你会对"单线程 runtime 最小可行实现"有完整心智模型。

打开 tokio/src/runtime/scheduler/current_thread/mod.rs,四个核心 struct(CurrentThread 在 tokio/src/runtime/scheduler/current_thread/mod.rs:26、Handle 在 tokio/src/runtime/scheduler/current_thread/mod.rs:36、Core 在 tokio/src/runtime/scheduler/current_thread/mod.rs:55、Shared 在 tokio/src/runtime/scheduler/current_thread/mod.rs:79;下面按讲解顺序重排了,字段本身原样):

// 来源:tokio-rs/tokio · tokio/src/runtime/scheduler/current_thread/mod.rs (tokio-1.40.0)

pub(crate) struct CurrentThread {
    /// Core scheduler data is acquired by a thread entering `block_on`.
    core: AtomicCell<Core>,

    /// Notifier for waking up other threads to steal the driver.
    notify: Notify,
}

struct Core {
    tasks: VecDeque<Notified>,
    tick: u32,
    driver: Option<Driver>,
    metrics: MetricsBatch,
    global_queue_interval: u32,
    unhandled_panic: bool,
}

pub(crate) struct Handle {
    shared: Shared,
    pub(crate) driver: driver::Handle,
    pub(crate) blocking_spawner: blocking::Spawner,
    pub(crate) seed_generator: RngSeedGenerator,
    pub(crate) task_hooks: TaskHooks,
}

struct Shared {
    inject: Inject<Arc<Handle>>,
    owned: OwnedTasks<Arc<Handle>>,
    woken: AtomicBool,
    config: Config,
    scheduler_metrics: SchedulerMetrics,
    worker_metrics: WorkerMetrics,
}

对比 multi_thread 的相同结构(第 5 章):

字段 multi_thread current_thread
任务队列 queue::Local<...> 256 容量环形 + LIFO slot VecDeque<Notified> 动态增长
Worker 线程数 N = CPU 核数 1(就是 block_on 调用者)
工作窃取 ✅ 有 remotes + Steal ❌ 无
LIFO slot ✅ 有 ❌ 无
Idle 协议 ✅ 复杂的 searching counter ❌ 无(notify: Notify 只用于多个 block_on 之间交接 Core)
全局队列 ✅ inject ✅ inject(但作用窄)
OwnedTasks ✅ 有 ✅ 有

current_thread 版的 Core 少了三样:没有 run_queue 的环形缓冲结构(用普通 VecDeque 就够)、没有 LIFO slot(单线程不需要)、没有 is_searching(单线程永远不需要从别处偷)。

AtomicCell<Core>:核心的所有权传递

注意 CurrentThread 里 core 是 AtomicCell<Core>——这和 multi_thread 的 Worker 里的 AtomicCell<Core> 一样,但用途微妙不同。

multi_thread 里 AtomicCell 的用途是配合 block_in_place 交接 Core:当前线程把 Core 放回 worker.core,新线程在 run 开头 take 走它(第 5 章)。current_thread 里的用途更有意思——它允许多个线程同时调 block_on:

// 某个线程 A
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
rt.block_on(async { /* ... */ });

// 另一个线程 B 也可能拿到同一个 Runtime 的引用(比如 Arc<Runtime>
// 或 std::thread::scope 借出去的 &rt)调 Runtime::block_on
// 虽然罕见,但合法。注意 Handle::block_on 不参与抢 Core——
// 按 tokio/src/runtime/handle.rs 的文档,在 current_thread 上它驱动不了 I/O 和定时器

AtomicCell 保证只有一个线程拿到 Core——其他调用 block_on 的线程会被 park,等 Core 被释放后再去抢。只是一次只有一个能工作,但 API 允许多线程同时调用、不会 panic。


7.4 block_on:等待 Core 可用

current_thread 的 block_on 有一个 multi_thread 版本没有的特殊语义——因为只有一个线程能同时持有 Core、多次嵌套 block_on 需要精心处理。这节讲的就是这个"只有一把钥匙"的协议是怎么设计的、为什么嵌套 block_on 在 current_thread 上会 panic、以及这个限制背后的合理性。

看 current_thread 的 block_on 真实代码。原样:

// 来源:tokio/src/runtime/scheduler/current_thread/mod.rs:175
#[track_caller]
pub(crate) fn block_on<F: Future>(&self, handle: &scheduler::Handle, future: F) -> F::Output {
    pin!(future);

    crate::runtime::context::enter_runtime(handle, false, |blocking| {
        let handle = handle.as_current_thread();

        // Attempt to steal the scheduler core and block_on the future if we can
        // there, otherwise, lets select on a notification that the core is
        // available or the future is complete.
        loop {
            if let Some(core) = self.take_core(handle) {
                handle
                    .shared
                    .worker_metrics
                    .set_thread_id(thread::current().id());
                return core.block_on(future);
            } else {
                let notified = self.notify.notified();
                pin!(notified);

                if let Some(out) = blocking
                    .block_on(poll_fn(|cx| {
                        if notified.as_mut().poll(cx).is_ready() {
                            return Ready(None);
                        }
                        if let Ready(out) = future.as_mut().poll(cx) {
                            return Ready(Some(out));
                        }
                        Pending
                    }))
                    .expect("Failed to `Enter::block_on`")
                {
                    return out;
                }
            }
        }
    })
}

一眼看不透?拆开来看。这段代码做两件事:

分支 A:拿到 Core self.take_core(handle)(tokio/src/runtime/scheduler/current_thread/mod.rs:217)尝试从 AtomicCell 取 Core。如果成功(当前没有其他线程占着),就调 core.block_on(future)(CoreGuard::block_on,tokio/src/runtime/scheduler/current_thread/mod.rs:682)——真正的主循环在这里。

分支 B:没拿到 Core 别的线程正在用 Core。此时当前线程做什么?它不闲等——进入一个 fallback 路径:

  • 等 self.notify.notified() 信号(其他线程释放 Core 后会通过 Notify 唤醒)
  • 同时也 poll 当前的 future——如果 future 自己能 Ready 就直接返回(不需要 Core)
  • 两个条件任一满足就退出

为什么要这么复杂? 因为 current_thread runtime 可能被多个线程同时 block_on(一种罕见但合法的用法)——线程 A 正在用 Core、线程 B 又调了 block_on。B 不能直接 panic,也不该死等——所以 B 进入 "并行 poll 自己的 future" 模式,如果自己的 future 恰好不需要 Core 就直接完成。

这是 Tokio 对"多线程调 block_on"的优雅解。当然,大多数用户的代码只在主线程调 block_on 一次,走分支 A。但这个分支 B 的存在让边缘场景也安全,不会产生锁死或 panic。

track_caller:一次良心的 API 设计

注意 #[track_caller]——第 4 章讲过,它让 panic 的行号指向调用方。这个属性在 block_on 的链式调用里救命:如果 block_on 在 runtime 内被嵌套调用会 panic,panic 消息指向你代码的那一行而不是 Tokio 内部。


为什么要 AtomicCell 而不是 Mutex

一个合理的问题:既然 Core 是"谁拿到谁用"的独占资源,为什么不用 Mutex<Core>?这样 lock().unwrap() 就拿到了。

答案是性能和语义的双重考虑:

语义上:Mutex::lock 的语义是"我会一直等到拿到锁"。但 block_on 的语义是"如果拿不到 Core,我可以改做别的事(比如 poll 我自己的 future)"——上面分支 B 那段双轨 poll 就是这个语义。AtomicCell::take 返回 Option<Box<T>>——None 时调用者自己决定等待策略,完美符合 block_on 的需求。要用 Mutex 表达同样的语义得写 try_lock + 手写等待逻辑,绕一大圈。

实现上:Tokio 的 AtomicCell<T>(tokio/src/util/atomic_cell.rs:6)整个文件连 Drop 一起才 51 行,内部就是一个 AtomicPtr<T>:

// 来源:tokio/src/util/atomic_cell.rs:6 (tokio-1.40.0,节选)
pub(crate) struct AtomicCell<T> {
    data: AtomicPtr<T>,
}

impl<T> AtomicCell<T> {
    pub(crate) fn swap(&self, val: Option<Box<T>>) -> Option<Box<T>> {
        let old = self.data.swap(to_raw(val), AcqRel);
        from_raw(old)
    }
    pub(crate) fn set(&self, val: Box<T>) { let _ = self.swap(Some(val)); }
    pub(crate) fn take(&self) -> Option<Box<T>> { self.swap(None) }
}

take() 就是一次 AtomicPtr::swap(null, AcqRel)——所有权在指针交换里完成转移,Box::into_raw / Box::from_raw 一进一出。这是"用一个原子指针表达 Option<Box<T>> 的独占所有权"的最小实现。

这个选择反映了 Rust 生态对原子原语的细分:Mutex / RwLock / Atomic* / AtomicCell / Cell / RefCell 每种都有明确的定位和适用场景。Tokio 源码是这类分类学的教科书——每个同步原语的使用都恰到好处。

take_core 和 Notify 的协作

上面的分支 B 里最有意思的是:没拿到 Core 的线程不是死等,而是进入一个"既等 Notify、又 poll future"的双轨 poll。这需要 Notify::notified()——Tokio 提供的一个"一次性通知"原语。

Notify::notified() 返回一个 Notified Future:

  • poll 这个 Future 会把 Waker 注册到 Notify
  • 当别人调 notify.notify_one() 时,Notify 会 wake 这个 Waker
  • 这次 poll 返回 Ready

CurrentThread.notify 在 Core 被释放时触发——具体是 core.block_on 的结尾、drop(core) 时(CoreGuard 在 tokio/src/runtime/scheduler/current_thread/mod.rs:675,它的 Drop 在 tokio/src/runtime/scheduler/current_thread/mod.rs:782,notify_one() 在 tokio/src/runtime/scheduler/current_thread/mod.rs:792):

// 示意,非逐字:简化自 CoreGuard 的 drop 逻辑
impl Drop for CoreGuard {
    fn drop(&mut self) {
        if let Some(core) = self.core.take() {
            self.scheduler.core.set(core);  // 把 Core 放回 AtomicCell
            self.scheduler.notify.notify_one();  // 通知一个等待的线程
        }
    }
}

双轨 poll 的语义:

  • 如果 notified 先 Ready(Core 空闲了),当前线程下一轮循环尝试 take_core
  • 如果 future 先 Ready,直接返回结果——说明它不需要本线程持有 Core 也能完成(它依赖的 I/O、定时器由持有 Core 的线程驱动)

这个设计的好处:block_on 不会因为抢不到 Core 而卡死。即使某个线程拿着 Core 跑了一个慢任务,其他线程的 block_on 也有机会推进自己的 future(如果不需要 runtime 功能)。

但代价:一段时间内可能有多个线程都在 poll——每个线程 poll 自己的 future。这不是理想状态(效率降低),但比死锁好。


7.5 主循环:和 multi_thread 的差异

current_thread 的主循环是 multi_thread 主循环的简化版——没有 work-stealing、没有 LIFO slot、没有 searching 计数。这个简化不是"阉割"、而是"在单线程场景下这些机制本来就没意义":work-stealing 只在多个 worker 之间才有意义、LIFO slot 是为了让本 worker 上刚被唤醒的任务插队(而且不被别的 worker 偷走)——在 current_thread 这些都退化成"就是从本地队列取"这个简单操作。把 multi_thread 的主循环和 current_thread 的主循环并排对比读、你会获得对两种 runtime 设计差异的立体理解。

拿到 Core 后,进入真正的主循环。以下为节选:删除了 metrics 样板和 #[track_caller],中文注释为本专栏所加,结尾的 match 写成了等价的 unwrap_or_else:

// 来源:tokio/src/runtime/scheduler/current_thread/mod.rs:682(`CoreGuard::block_on`)
fn block_on<F: Future>(self, future: F) -> F::Output {
    let ret = self.enter(|mut core, context| {
        let waker = Handle::waker_ref(&context.handle);
        let mut cx = std::task::Context::from_waker(&waker);

        pin!(future);

        'outer: loop {
            let handle = &context.handle;

            // 1. 检查是否需要 poll 顶层 future
            if handle.reset_woken() {
                let (c, res) = context.enter(core, || {
                    crate::runtime::coop::budget(|| future.as_mut().poll(&mut cx))
                });
                core = c;
                if let Ready(v) = res {
                    return (core, Some(v));
                }
            }

            // 2. 跑 event_interval 个 Task
            for _ in 0..handle.shared.config.event_interval {
                if core.unhandled_panic { return (core, None); }
                core.tick();
                let entry = core.next_task(handle);
                let task = match entry {
                    Some(entry) => entry,
                    None => {
                        // 没任务了,park
                        core = if !context.defer.is_empty() {
                            context.park_yield(core, handle)
                        } else {
                            context.park(core, handle)
                        };
                        continue 'outer;
                    }
                };
                let task = context.handle.shared.owned.assert_owner(task);
                let (c, ()) = context.run_task(core, || { task.run(); });
                core = c;
            }

            // 3. event_interval 次后强制 yield + 处理 driver 事件
            core = context.park_yield(core, handle);
        }
    });
    ret.unwrap_or_else(|| panic!("..."))
}

三个阶段循环:

  1. poll 顶层 future:如果它被 woken 过,poll 一次;Ready 就退出整个 block_on
  2. 跑 Task:最多连跑 event_interval(默认 61,tokio/src/runtime/builder.rs:232)个 Task;队列空了就 park
  3. 强制 yield:每 61 个 Task 后去一次 Driver 查 I/O 事件

和 multi_thread 主循环的对比

multi_thread 版本(第 5 章回顾)的主循环:

  • 三条腿:next_task / steal_work / park
  • 复杂的 transition_to_searching 协议

current_thread 版本:

  • 两条腿:next_task / park(没有 steal)
  • 简单的 reset_woken 检查(单线程不需要 searching 状态机)
  • 顶层 future 是一等公民——每轮外层循环开头就检查它,保证 block_on 的 future 不会饿死在其他 Task 队列后面

这种"顶层 future 优先"的调度是 current_thread 的语义保证:你调 block_on(async { ... }) 时,那段 async 代码被唤醒后,最多等本轮跑完 event_interval 个 Task 就会被 poll,哪怕其他 Task 塞满了队列。multi_thread 的情况不同:顶层 future 根本不进调度器,它由调用线程上的 CachedParkThread 直接 poll,和 worker 上的 Task 并行推进。

reset_woken 的意义

这一行:

if handle.reset_woken() {
    // poll 顶层 future
}

Handle.shared 里有一个 woken: AtomicBool。全文件只有两处会把它置 true,而且分工很清楚。wake 路径上只有一处——impl Wake for Handle 的 wake_by_ref(tokio/src/runtime/scheduler/current_thread/mod.rs:666),也就是顶层 future 手里那个 Waker被触发的时候;普通 Task 被 wake 走的是 Schedule::schedule(同文件 :596),只把任务推进本地队列或 inject,全程不碰这个标志。另一处不在 wake 路径上:Handle::waker_ref 进 block_on 时会先把它置成 true(同文件 :520,注释写着 "be polled for the first time when enter loop"),这是初始化语义——保证顶层 future 第一轮一定被 poll 到,而不是等一次真正的 wake。reset_woken(tokio/src/runtime/scheduler/current_thread/mod.rs:525)就是一次 swap(false, AcqRel)——原子地读并清零。

意义:只有顶层 future 自己被 wake 过才去 poll 它——避免无谓的轮询。每次循环都 poll 顶层 future 会浪费 CPU;只 poll 被 wake 的才高效。


context.defer 的角色

主循环里有一行你可能没注意:

core = if !context.defer.is_empty() {
    context.park_yield(core, handle)
} else {
    context.park(core, handle)
};

context.defer 是一个 Defer(tokio/src/runtime/scheduler/defer.rs:4),内部就是 RefCell<Vec<Waker>>(tokio/src/runtime/scheduler/defer.rs:5)。默认构建下往里塞东西的入口只有一个——tokio::task::yield_now()(tokio/src/task/yield_now.rs:45-61):

// 来源:tokio/src/task/yield_now.rs:45-61 (tokio-1.40.0)
impl Future for YieldNow {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        ready!(crate::trace::trace_leaf(cx));

        if self.yielded {
            return Poll::Ready(());
        }

        self.yielded = true;

        context::defer(cx.waker());

        Poll::Pending
    }
}

注意最后那两步:context::defer(cx.waker()) 不是 wake,是"把 Waker 存起来待会儿再 wake",然后照常返回 Poll::Pending。

为什么要绕这一下? 如果 yield_now 直接 cx.waker().wake_by_ref(),这个 Task 会立刻回到队列,调度器可能马上又把它取出来——"让出"就没让出去。把 Waker 存进 defer,等这一轮任务批次跑完、准备去 park 之前才统一 defer.wake(),让出的语义才成立。Defer::defer(tokio/src/runtime/scheduler/defer.rs:15)还顺手去了个重:如果最后一个 Waker 就 will_wake 当前这个,就不重复压栈(tokio/src/runtime/scheduler/defer.rs:20)。

主循环里那个 if !context.defer.is_empty() 的判断就是这个意思——有待唤醒的 yield 任务时,用 park_yield(park_timeout(0),不真睡)而不是 park(可能一直睡),否则那些 yield 的任务就要等到下一个 I/O 事件才能被唤醒。

(说"默认构建下"是因为还有一条只在 tokio_taskdump 里编进来的路径:抓完调用栈的 trace_leaf 也会往 defer 里塞 Waker,tokio/src/runtime/task/trace/mod.rs:197,那段注释自己写着 "Use the same logic that yield_now uses to send out wakeups after"。默认构建里 trace_leaf 是个直接返回 Ready 的空实现。)

multi_thread 用的是同一个 Defer 类型(Context.defer 字段),park 和 park_timeout 之后同样会 self.defer.wake()(tokio/src/runtime/scheduler/multi_thread/worker.rs:753)。两种 runtime 对 yield_now 的处理是共用一套机制的。


7.6 spawn 到 current_thread 为什么仍然要 Send?

这一节回答的是 Rust 新手经常有的一个困惑:"既然 current_thread 只有一个线程,为什么往里 spawn 一个用了 Rc 的 Future 还是编译不过?"。答案在 spawn 的 trait bound:1.40.0 里 spawn 只有一个签名,不分 runtime 种类,都要求 Future: Send + 'static;真正只要求 'static、不要求 Send 的是 LocalSet 的 spawn_local。Send 这道关卡由类型系统在编译期把守——错用直接编译失败(实测报 future cannot be sent between threads safely),不需要任何运行时检查。

tokio::runtime::Builder::new_current_thread().build() 得到的 runtime,spawn 的签名和 multi_thread 版完全一样:

// 来源:tokio/src/runtime/handle.rs:187(同一个 spawn 函数在两种 runtime 下)
pub fn spawn<F>(&self, future: F) -> JoinHandle<F::Output>
where F: Future + Send + 'static, F::Output: Send + 'static
{ /* ... */ }

注意仍然要 Send + 'static!即使是 current_thread runtime。

为什么? 因为 Handle 可以 clone 到任意线程——一个线程拿到 current_thread runtime 的 Handle,可以从非 runtime 线程调 spawn。这个跨线程传递要求 Future Send。即使实际执行在单线程,"从外部 spawn 进来"这个动作跨了线程。

这就是为什么你会看到一个"反直觉"现象:current_thread runtime 也要求 spawn 的 Future 是 Send。

那么怎么 spawn 真正 !Send 的 Future? 答案是 LocalSet——下半章的主角。


Send 约束的类型系统层级

再深入一层:要理解为什么 current_thread runtime 也要 Send,你需要明白 Rust 类型系统里 "数据结构所有权" 和 "数据结构使用" 的分离。

Handle::spawn(future) 的签名要求 future: F where F: Send——这是调用点的约束。这个约束存在不是因为 future 会真的跨线程移动,而是因为:

  • spawn 会把 future 装进 Task<F, S>
  • Task 被塞进 scheduler 的某个队列
  • scheduler 的队列可能被另一个线程访问(即使 current_thread runtime,Handle 可以跨线程)
  • 因此 F 必须 Send——否则类型系统不允许这种"可能跨线程"的操作

实际执行阶段:也并不总是"只在一个线程上"。7.3 节讲过,Core 装在 AtomicCell 里、谁调 Runtime::block_on 谁拿走,所以同一个任务这次在线程 A 的 block_on 里被 poll,下次完全可能在线程 B 的 block_on 里被 poll。何况类型系统不区分"可能" 和 "实际",只按 最严约束。

这是 Rust 类型系统的保守性——它宁愿让合法的使用多一些约束,也不放松约束留 UB 空间。对比 C++ 的 "程序员负责"、Go 的"运行时检查"——Rust 把安全推到编译期,代价是类型约束看起来多余。理解这个哲学,你就理解了 Rust 很多看似"啰嗦"的约束为什么存在。

跨线程 spawn 的 Trampoline

理解了上面"spawn 仍要 Send"这件事后,你可以想象一个巧妙的 pattern:从线程 A 调 Handle::spawn(future),future 在 current_thread runtime 的主线程 B 上执行。

这是完全合法的 Tokio 用法:

let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let handle = rt.handle().clone();

// 线程 A
std::thread::spawn(move || {
    let fut = async { /* Send + 'static */ };
    handle.spawn(fut);   // ← future 被送到 current_thread 主线程 B 执行
});

rt.block_on(async { /* ... */ });  // 主线程 B 阻塞等 runtime

这种 pattern 让你可以从外部线程"投送"任务到单线程 runtime。用场:

  • 一个 GUI 主线程上的 runtime,接收来自 worker 线程的异步请求
  • 嵌入式场景,中断处理 handler 往 main loop runtime 投送任务
  • 测试场景,主 runtime 跑主流程,后台线程 spawn 测试任务

这就是 Send 约束的用处——它让这种"跨线程投送"类型安全。如果 current_thread 放弃 Send 约束,上面这个模式就不可能。


7.7 LocalSet:任何 runtime 都能跑 !Send Future

LocalSet 是 Tokio 里一个极其有用但极易被忽略的工具——它让你能在 multi_thread runtime 里也跑 !Send Future。机制是:LocalSet 自己在一个线程上维护一个单线程的"子 scheduler"、spawn_local 的 Future 进这个子 scheduler、永远不跨线程移动。有了 LocalSet、你就能在一个主要使用 multi_thread 的应用里局部用 Rc / RefCell——两边共享同一个 runtime、但 !Send 代码被隔离在 LocalSet 这个小岛上。这种"一个 runtime 内部支持两种执行模型"的灵活性正是 LocalSet 的价值所在。

LocalSet 解决一个核心问题:让 !Send 的 Future 可以在 Tokio 的任何 runtime 上跑,包括多线程 runtime。

看它的定义:

// 来源:tokio/src/task/local.rs:218(`Context` 在 tokio/src/task/local.rs:231)
pub struct LocalSet {
    tick: Cell<u8>,
    context: Rc<Context>,
    _not_send: PhantomData<*const ()>,
}

struct Context {
    shared: Arc<Shared>,
    unhandled_panic: Cell<bool>,
}

两个关键点:

  1. _not_send: PhantomData<*const ()>:这个 ZST 让 LocalSet 自身不是 Send——编译器不让你把 LocalSet 跨线程传递。这保证 "LocalSet 绑定到一个线程" 的语义
  2. context: Rc<Context>:用 Rc 而不是 Arc——单线程引用计数,比 Arc 快(不需要原子操作)

spawn_local 的 thread-local 魔法

spawn_local 的工作逻辑:

// 来源:tokio/src/task/local.rs:365
#[track_caller]
pub fn spawn_local<F>(future: F) -> JoinHandle<F::Output>
where F: Future + 'static, F::Output: 'static
{
    if cfg!(debug_assertions) && std::mem::size_of::<F>() > BOX_FUTURE_THRESHOLD {
        spawn_local_inner(Box::pin(future), None)
    } else {
        spawn_local_inner(future, None)
    }
}

注意 bound:F: Future + 'static——没有 Send。只要 'static。

spawn_local_inner 通过一个 thread-local 变量找到"当前线程激活的 LocalSet",把 Future 推给它。spawn_local_inner 的正文只有一个 match——thread-local 里没有活跃 LocalSet 就直接 panic:

// 来源:tokio/src/task/local.rs:379
pub(super) fn spawn_local_inner<F>(future: F, name: Option<&str>) -> JoinHandle<F::Output>
where F: Future + 'static, F::Output: 'static
{
    match CURRENT.with(|LocalData { ctx, .. }| ctx.get()) {
        None => panic!("`spawn_local` called from outside of a `task::LocalSet`"),
        Some(cx) => cx.spawn(future, name)
    }
}

除了 run_until / block_on 会设置这个 thread-local,LocalSet::enter()(tokio/src/task/local.rs:460;Tokio 1.21.0 加入,PR #4765)返回的 LocalEnterGuard 也可以显式进入 LocalSet 上下文。


LocalSet 内部的队列设计

LocalSet 内部维护两个独立的队列——都不是 Tokio 主 runtime 的 inject queue:

// 来源:tokio/src/task/local.rs:241(`LocalState` 在 tokio/src/task/local.rs:261)
struct Shared {
    /// # Safety
    ///
    /// 这个字段**只能**从拥有该 LocalSet 的线程访问
    /// (即 `Thread::current().id() == owner`)——源码 :242-245
    local_state: LocalState,

    /// Remote run queue sender.
    queue: Mutex<Option<VecDeque<task::Notified<Arc<Shared>>>>>,

    /// Wake the `LocalSet` task.
    waker: AtomicWaker,
    // cfg(tokio_unstable) 下还有 unhandled_panic
}

struct LocalState {
    owner: ThreadId,                                              // 拥有者线程
    local_queue: UnsafeCell<VecDeque<task::Notified<Arc<Shared>>>>, // 本地队列,无锁
    owned: LocalOwnedTasks<Arc<Shared>>,                          // !Send 版本的名册
}

为什么要两个队列?

  • local_state.local_queue:UnsafeCell<VecDeque>,完全没有锁。因为它只被"拥有 LocalSet 的那个线程"访问——LocalState 上还存了 owner: ThreadId 用来在 debug 下断言这一点。同线程内的 wake 走这条路,零同步开销
  • queue:Mutex<Option<VecDeque>>,给跨线程的 wake 用。一个 local task 完全可能被别的线程唤醒(比如它 .await 了一个 tokio::sync::oneshot,发送方在另一个 worker 上),这时候只能通过带锁的远程队列排进来,再由 waker: AtomicWaker 把 LocalSet 这个 Future 自身唤醒
  • Option<...> 是给关闭用的:LocalSet drop 时把它置 None,之后任何往里 push 的尝试都知道"这个 LocalSet 已经死了"

两个队列由 next_task(tokio/src/task/local.rs:704)交替消费,规则和调度器的反饥饿逻辑同构:

// 来源:tokio/src/task/local.rs:397 与 tokio/src/task/local.rs:708(节选)
const REMOTE_FIRST_INTERVAL: u8 = 31;

let task = if tick % REMOTE_FIRST_INTERVAL == 0 {
    // 每 31 次先看远程队列
    self.context.shared.queue.lock().as_mut()
        .and_then(|queue| queue.pop_front())
        .or_else(|| self.pop_local())
} else {
    // 平时先本地
    self.pop_local().or_else(|| {
        self.context.shared.queue.lock().as_mut().and_then(VecDeque::pop_front)
    })
};

——和 current_thread 调度器的 DEFAULT_GLOBAL_QUEUE_INTERVAL = 31(tokio/src/runtime/scheduler/current_thread/mod.rs:122)是同一个数、同一个用意。


7.8 LocalSet 作为一个 Future

LocalSet 本身实现了 Future trait——它是一个可以被 poll 的容器。当你在 multi_thread runtime 的某个 async 上下文里 .await 它(或者用 run_until),它就像一个普通 Future 一样被所在任务 poll(它自己是 !Send,不能 tokio::spawn 出去)。这种"容器也是 Future"的设计让 LocalSet 能无缝嵌入任何 runtime——不需要新的 API、不需要特殊 hook、利用的都是 Future 的标准能力。这是 Rust 异步"组合性"的漂亮示范——任何东西都可以变成 Future、Future 又是通用可组合原语。

LocalSet 的精妙设计是:它自己就是一个 Future。

// 来源:tokio/src/task/local.rs:869
impl Future for LocalSet {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
        self.context.shared.waker.register_by_ref(cx.waker());

        if self.with(|| self.tick()) {
            cx.waker().wake_by_ref();
            Poll::Pending
        } else if unsafe { self.context.shared.local_state.owned_is_empty() } {
            Poll::Ready(())
        } else {
            Poll::Pending
        }
    }
}

poll 做三件事:

  1. 注册 cx 的 Waker:当内部 Task 需要唤醒 LocalSet 时用
  2. tick 一轮:从队列里取就绪的 Task 来跑,最多 61 个(self.with(|| self.tick()) 会在 thread-local 里临时把 LocalSet 设为"当前",然后 tick 一次)
  3. 判断返回:
    • tick 返回 true(还有更多工作)→ wake 自己 + Pending(保证下一轮 poll)
    • owned tasks 空了 → Ready(())(LocalSet 完成)
    • 否则 Pending(有 Task 但都在等待)

用法:run_until

最常见的用法:

let local = tokio::task::LocalSet::new();

local.run_until(async move {
    let rc = Rc::new(42);   // ← !Send 类型

    tokio::task::spawn_local(async move {
        println!("{}", rc);
    });

    // 等 spawn_local 的 Task 完成
    tokio::task::spawn_local(async { /* ... */ }).await.unwrap();
}).await;

工作原理:

  • run_until 是个 async fn,内部构造一个 RunUntil<F> Future 并 .await 它
  • 每次 poll 这个 Future 时(tokio/src/task/local.rs:979):先 poll 你的参数 Future,没 Ready 再 tick 一轮 LocalSet(推进最多 61 个 local task)
  • 参数 Future Ready 时返回 Output

结果:在多线程 runtime 的上下文里(比如 #[tokio::main]),你用 LocalSet::new().run_until(...) 就能跑 !Send 代码。LocalSet 本身是一个 Future,它不改变底层 runtime、只是给当前 Task 开一个"!Send 子世界"。


register_by_ref 做了什么

LocalSet 的 poll 第一行是 self.context.shared.waker.register_by_ref(cx.waker())。这用的是 AtomicWaker——Tokio 提供的一个可原子替换的 Waker 槽。

AtomicWaker::register_by_ref(tokio/src/sync/task/atomic_waker.rs:171)的语义:

  • 用一次 CAS 把状态从 WAITING 改成 REGISTERING,抢到"登记权"
  • 每次都把 cx.waker() clone 一份存进槽里,替换掉旧的——1.40.0 的实现里没有 will_wake 比较,不会因为"还是同一个 Waker"而跳过 clone
  • 登记期间如果另一个线程恰好来 wake,登记方在收尾时发现状态里多了 WAKING 位,就自己把刚存的 Waker 取出来 wake 掉,保证这次唤醒不丢

所以 LocalSet 每被 poll 一次,就付出一次 Waker clone 的代价;换来的是:不管 LocalSet 这次被哪个任务、哪个 Waker poll,远程唤醒(Shared::schedule 里的 self.waker.wake())总能找到最新的那一个。

在 Tokio 里,AtomicWaker 用在"只有一个等待者"的地方:除了 LocalSet,还有 mpsc 的接收端(tokio/src/sync/mpsc/chan.rs)和定时器条目(tokio/src/runtime/time/entry.rs)。多等待者的场景(Notify、信号量等)用的是等待者链表,不是 AtomicWaker。


7.9 LocalSet 的几种常见用法

LocalSet 在真实项目里的典型使用模式有四种,下面逐个给出最小骨架。理解这几种模式能帮你把 LocalSet 从"一个神秘 API"变成"解决具体问题的熟悉工具"。

用法 A:和 multi_thread 配合跑 !Send 子任务

#[tokio::main]
async fn main() {
    let local = tokio::task::LocalSet::new();
    local.run_until(async {
        let conn = UnsyncDatabase::connect().await;   // 假设 UnsyncDatabase 是 !Send
        
        tokio::task::spawn_local(async move {
            conn.query("SELECT ...").await
        }).await.unwrap();
    }).await;
}

用法 B:作为一个独立的单线程 runtime

// Builder::new_current_thread() + LocalSet 提供真正的单线程环境
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
let local = tokio::task::LocalSet::new();

local.block_on(&rt, async {
    // 这里 spawn_local 和 spawn 都可用
});

用法 C:spawn_local 但保持和 spawn 混用

#[tokio::main]
async fn main() {
    let local = tokio::task::LocalSet::new();
    
    local.run_until(async {
        // spawn_local 跑 !Send 代码
        let local_task = tokio::task::spawn_local(async {
            let rc = Rc::new(vec![1, 2, 3]);
            // 操作 !Send 类型
        });
        
        // spawn 同时跑 Send 代码(跑在 multi_thread worker 上)
        let send_task = tokio::spawn(async {
            // 这段跑在其他 worker,并行执行
            heavy_computation().await
        });
        
        // 两者都 await
        let _ = local_task.await;
        let _ = send_task.await;
    }).await;
}

这个模式非常强大——你可以并行混合 Send 和 !Send 任务。spawn 的任务用 multi_thread runtime 的 worker 跑,spawn_local 的任务用 LocalSet 在当前线程跑。两个世界和谐共存。

用法 D:集成进 GUI 事件循环

// iced 或 egui 的 update 函数里
let local = tokio::task::LocalSet::new();

// 每次 update 时推进 LocalSet 一点
// local 里 spawn 的任务可以直接操作 UI 状态(!Send)

这几种用法覆盖了绝大部分实际的 !Send 需求。


7.10 LocalSet 和 Arc / Rc 的微妙关系

LocalSet 的 Shared 是 Arc<Shared>,而不是 Rc<Shared>。为什么在一个单线程的结构里要用 Arc?

答案是:Task 内部持有 scheduler 的反向引用(第 6 章讲过 Core<T, S> 里的 scheduler: S)。对于 LocalSet,这个 S 就是 Arc<Shared>。Task 的 Waker 可以被带到别的线程上 wake(比如 oneshot 的发送方在另一个 worker 上),wake 时要经由 S 调 Schedule::schedule,把任务推进 Shared.queue 这个带锁的远程队列——所以 Schedule trait 要求 Sync + Sized + 'static(tokio/src/runtime/task/mod.rs:265),引用计数也得是跨线程安全的 Arc。Shared 里那个只许本线程碰的 local_state,则靠 unsafe impl Sync for Shared(tokio/src/task/local.rs:1062)加上"只在 owner 线程访问"的约定来兜住。

但外层的 context: Rc<Context> 是 Rc——因为 Context 只在 LocalSet 自己的线程内用,不被 Task 结构持有。

这种"LocalSet 自己持有的 Context 用 Rc、被 Task 持有的 Shared 用 Arc"的混合策略是 LocalSet 实现里最精细的设计之一。读 local.rs 源码时注意每个类型是 Arc 还是 Rc——每个选择都对应一个具体约束。


7.11 LocalSet 的 tick 方法里做什么

LocalSet Future impl 里 self.with(|| self.tick()) 是核心。tick 方法做一轮工作:

简化逻辑(对应 tokio/src/task/local.rs:679):

// 来源:tokio/src/task/local.rs:394 与 tokio/src/task/local.rs:679(删掉了安全性注释)
const MAX_TASKS_PER_TICK: usize = 61;

fn tick(&self) -> bool {
    for _ in 0..MAX_TASKS_PER_TICK {
        assert!(!self.context.unhandled_panic.get(), "a spawned task panicked and the LocalSet is configured to shutdown on unhandled panic");

        match self.next_task() {
            // 注意包了一层 coop::budget——local task 同样受协作式预算约束
            Some(task) => crate::runtime::coop::budget(|| task.run()),
            None => return false,   // 队列抽干了
        }
    }

    true   // 跑满 61 个还有剩,让外层再 poll 一轮
}

关键点:

  • 每次 tick 限制处理任务数(避免一个 LocalSet 的 tick 占用 CPU 太久)
  • 处理完限额或队列空 → 返回
  • 返回 true 表示"我还有活"——LocalSet 的 poll 会 wake 自己触发下一轮 poll
  • 返回 false 表示"我没活了"——LocalSet 的 poll 根据 owned_is_empty 决定是 Ready 还是 Pending(Pending 等后续 spawn_local)

这种"有限步长的推进 + 完成度反馈"是 Rust async 世界的标准 Future 组合原语模式。你自己写类似的"驱动内部多个 future"的 Future 时可以复用这个模式。


7.12 和这个系列的其他专栏的关联

本章讲的 PhantomData<*const ()> 让类型自动 !Send 的技巧,属于 Rust 的 auto trait 传染规则:Send / Sync 是编译器自动推导的,一个结构体只要含有 !Send 的字段(裸指针就是最典型的 !Send 类型),整体就自动变成 !Send——不需要写 impl !Send(那是 nightly 的负实现语法)。这套 marker trait 与 vtable、trait object 的关系,可以配合 《Rust 编译器与运行时揭秘》专栏第 8 章(trait object 与 vtable) 读。

《Vue 3 设计与实现》专栏第 10 章(组件系统) 里讲的 currentInstance——"渲染期间把当前组件实例挂到一个全局变量上,setup 里的 API 靠它找到自己属于谁"——和 spawn_local 靠 thread-local 找到"当前 LocalSet"是同一个套路:用一个隐式的当前上下文,换取调用点 API 的简洁,代价是脱离上下文调用就会报错。


7.13 current_thread vs multi_thread 的性能对照

"到底哪个快"是用户最关心的实用问题。但任何脱离具体机器和负载的数字都是骗人的——这一节只谈结构性差异,也就是"为什么会有差异",具体数字请你自己在目标机器上用 benches/rt_current_thread.rs 和 benches/rt_multi_threaded.rs 跑(cargo bench),那才是能拿去做决策的数据。

结构性差异有四条,都能直接从前面几节的源码推出来:

启动成本:build_current_thread_runtime 一个 worker 线程都不 spawn;build_threaded_runtime 要走 Launch::launch,把 N 个 worker 逐个提交给 blocking 线程池。线程创建是系统调用,这条差异是量级上的。

吞吐:multi_thread 能吃满多核,current_thread 只有一个线程——但加速比达不到核数,因为 worker 之间的偷任务、idle 协议、跨核 cache 一致性都要收税(第 5 章讲过)。

单请求延迟:current_thread 的调度路径短得多——next_task 就是 VecDeque::pop_front,没有 CAS、没有偷、没有 LIFO slot、没有 searching 状态机;Task 也永远不换线程,L1/L2 cache 一直是热的。所以"低 QPS 但延迟敏感"的服务,current_thread 未必吃亏。

内存占用:multi_thread 每个 worker 都有一个 Box<[UnsafeCell<MaybeUninit<Notified>>; 256]> 的定长环形缓冲(每个 worker 就是 256 个指针大小的槽位,不管用不用都占着),外加每 worker 的 WorkerMetrics;current_thread 只有一个按需增长的 VecDeque(初始容量 INITIAL_CAPACITY = 64,tokio/src/runtime/scheduler/current_thread/mod.rs:117,用在 tokio/src/runtime/scheduler/current_thread/mod.rs:159)。

经验法则

简单的选型清单:

  • 长期服务、CPU 核心多、高吞吐目标:multi_thread(默认选择)
  • CLI / 短寿命进程:current_thread(启动快)
  • 延迟敏感、吞吐不高:current_thread 可能更优(别凭直觉用 multi_thread)
  • 需要 !Send 类型:LocalSet(常与 current_thread 搭配)
  • 嵌入 GUI / 游戏引擎 / 其他事件循环:current_thread
  • WASM 环境:current_thread(WASM 原本就单线程)
  • 内存受限:current_thread

7.14 两种 runtime 的共享之处

虽然调度器不同,两种 runtime 共享大量代码:

完全共享的部分:

  • Task 结构(第 6 章讲的 Cell + Header + State):两种 runtime 的 Task 几乎一模一样,只是 scheduler 泛型参数 S 不同
  • Waker 实现(第 3 章的 vtable):同一套 WAKER_VTABLE
  • I/O Driver / Time Driver(第 8-11 章):两种 runtime 都用 mio 和分层时间轮
  • Blocking Pool(第 16 章):两种都有 spawn_blocking 能力
  • OwnedTasks 名册:两种都维护全局 Task 列表

差异部分:

  • Scheduler:multi_thread 有 N 个 Worker(N 默认等于可用并行度)、current_thread 只有 1 个 Core
  • Queue:环形缓冲 vs VecDeque
  • Wake 路径的最后一步:multi_thread 把 Task 推给某个 worker,current_thread 推进共用队列

这种"绝大部分共享、只在调度层分叉"的架构让 Tokio 的代码总量可控——两种 runtime 不是两套独立实现,Task、Waker、Driver、blocking pool 全都是同一份代码。你学会 multi_thread 基本就会 current_thread。

这是抽象边界划分得好的标志:trait Schedule 定义了调度行为的契约,上层(Task、Waker)都通过这个 trait 工作,两种 scheduler 是 trait 的两种实现。


7.15 小而重要的决策:LocalSet 的 tick: Cell<u8>

LocalSet 结构里有个不起眼的 tick: Cell<u8> 字段(tokio/src/task/local.rs:220)。用 u8 而不是 u32——因为它唯一的用途是 tick % REMOTE_FIRST_INTERVAL(31),u8 的 wrapping_add 完全够用。

这是 Tokio 源码里反复出现的经验——能用最小的类型就用最小的。LocalSet 的 tick 是 u8、CurrentThread 的 tick 是 u32、multi_thread Core 的 tick 是 u32(第 5 章讲过理由)。每处都按实际需求选择位宽——不盲目用 usize、也不盲目用 u64。

这种对每个字段的精打细算在整个 Tokio 源码里贯穿。读过之后你写自己的 Rust 代码时,会开始下意识地问"这里真的需要 usize 吗?u8 够不够?"——这是工程美学的潜移默化。


7.16 本章小结

本章完整讲了 current_thread runtime 的设计、实现、LocalSet 的工作机制、以及它们在真实场景下的典型用法。重点不是记住具体 API——是理解"为什么 Tokio 要同时提供两种 runtime"这个架构决策。关键 take-home:runtime 不是越复杂越好、要匹配场景选择最简单的那个够用方案。过度工程化的现象在并发领域尤其严重——"多线程,work-stealing,lock-free"这些字眼听起来很帅、但用在不合适的场景反而拖累。current_thread 提醒我们:有时候最好的设计就是"没有的那部分设计"。

带走三件事:

  1. current_thread runtime 是 multi_thread 的简化版——只有一个线程、一个 VecDeque 队列、没有 work-stealing、没有 LIFO slot。适合 GUI、CLI、嵌入场景,因为完全不用 spawn worker 线程所以启动更快。延迟敏感且吞吐不高的服务,current_thread 未必吃亏
  2. spawn 到 current_thread runtime 依然要 Send——因为 Handle 可以跨线程 clone。想真正在单线程 spawn !Send Future,用 LocalSet + spawn_local
  3. LocalSet 是一个 Future,自己可以 .await。它给调用线程开一个 "!Send 子世界"——本线程内的 Future 可以用 Rc / RefCell / 其他 !Send 类型。可以和 multi_thread runtime 组合使用——spawn_local 跑 !Send、spawn 跑 Send,共存无碍

一个总结性的认知升级

读完本章你应该对 Tokio 有一个升级的认知:Tokio 不是"一个" runtime,它是"一组"可组合的 runtime 组件——Builder 是组装入口、Scheduler 有两种、LocalSet 是横切的 !Send 扩展、Handle 是跨线程的门面。你可以按需挑选组合:

  • new_multi_thread() + enable_all() → 典型后端服务
  • new_current_thread() + enable_all() → CLI / GUI 主线程
  • new_multi_thread() + LocalSet.run_until() → !Send 子世界包裹 + 多核利用
  • new_current_thread() + 纯 LocalSet → 极简异步工具

这种可组合性是 Tokio 适用场景广泛的根源。把这层组合关系理解清楚,以后你面对任何异步需求都能从 Builder 开始自上而下设计,而不是凭直觉复制粘贴代码。

7.17 单线程 runtime 里的 .await 其实更"顺畅"

一个读者不常注意的现象:同样的 async 代码,在 current_thread runtime 里可能比 multi_thread runtime 跑得更"流畅"。

原因在于调度行为的差异:

  • multi_thread 下,你的 async fn 的不同 .await 点之间,Task 可能被迁移到不同 worker。这带来:
    • CPU cache miss(每次换 worker 都要重新 warm up L1/L2 cache)
    • 可能的内存访问跨 NUMA node(如果服务器有多个 CPU socket)
    • 原子操作在 worker 之间的传播开销
  • current_thread 下,所有 .await 都在同一个线程——cache 永远是热的、内存访问都在同一个 NUMA 域

代价是单实例 QPS 降低(单线程不能并行)。但如果你的 QPS 本来不高(比如内部管理服务),current_thread 可能更合适——更短的调度路径 + 更简单的架构 + 更可预测的调度行为。

沿着这条思路再走一步就是 thread-per-core 架构:每个 CPU 核跑一个独立的 current_thread runtime(或干脆一个进程),核之间不共享任务队列,用 SO_REUSEPORT 之类的机制在内核层分流连接。Rust 生态里 monoio、glommio 就是照这个模型设计的运行时。要不要走这条路,请以你自己在目标机器上的压测结果为准——本节只提供"为什么可能有差异"的机理,不提供数字。


7.18 LocalSet 的边界陷阱

LocalSet 虽然强大、但有几个容易踩的陷阱——不知道就会在生产里翻车。本节把这些陷阱拎出来:LocalSet 只在被 poll 时推进任务、drop 时会取消没跑完的 local task、嵌套 LocalSet 时 spawn_local 进的是最内层。

陷阱 1:LocalSet 不能跨越 .await 边界外

// 错误示范
let local = LocalSet::new();
some_future.await;      // await 会让 LocalSet 暂停
local.run_until(/* ... */).await;   // 这时候 LocalSet 才真正 poll

问题:some_future.await 期间 LocalSet 里的任务不会被推进(因为 run_until 还没开始)。如果你在这之前用 local.spawn_local(...) 往里放了任务,它们得等 run_until 开始才会跑;而如果是在 some_future 里调自由函数 tokio::task::spawn_local,由于此时不在任何 LocalSet 上下文里,会直接 panic。

对策:把需要 LocalSet 的代码整个包进 run_until 内部。

陷阱 2:drop LocalSet 前有未完成的 local task

let local = LocalSet::new();
local.run_until(async {
    tokio::task::spawn_local(async { 
        long_running().await 
    });
    // 这里直接 return,但 spawn_local 的 task 还没跑完
}).await;
// LocalSet drop,spawn_local 的 task 被强制 cancel

问题:LocalSet 的 Drop 实现会 close_and_shutdown_all()——所有未完成的 local task 都被强制关闭,两个队列里剩下的任务也被逐个 drop。如果你希望它们跑完,需要显式 .await 它们的 JoinHandle,或者 local.await(LocalSet 自己就是 Future,await 它会一直跑到集合里没有任务为止)。

陷阱 3:嵌套 LocalSet 会"重叠"

let outer = LocalSet::new();
outer.run_until(async {
    let inner = LocalSet::new();
    inner.run_until(async {
        // 这里 spawn_local 进哪个 LocalSet?
        tokio::task::spawn_local(async {});
    }).await;
}).await;

答案:进最内层的 LocalSet。因为 LocalSet::with 的实现是 let _guard = local_data.enter(self.context.clone());——一个 RAII guard,进入时把 thread-local 换成自己的 context,drop 时再换回去。inner 的 tick 期间,thread-local 指向的就是 inner。这通常是你想要的行为,但如果你以为 spawn 到 outer、实际上进了 inner,可能出现"外层 LocalSet 以为自己做完了、其实还有任务"的错觉。

这类陷阱不常见但存在。生产代码避免嵌套 LocalSet 是最稳的做法。


7.19 spawn_local 之外的 !Send 工具

除了 spawn_local,Tokio 还有几个专门为 !Send 场景准备的工具。你可能需要:

LocalSet::spawn_local 的独立版本

LocalSet 上还有一个 spawn_local 方法(不是全局的 tokio::task::spawn_local):

let local = LocalSet::new();
let handle = local.spawn_local(async { /* !Send */ });
local.run_until(handle).await.unwrap();

这个版本不依赖 thread-local——直接绑定到某个具体的 LocalSet。适合在 LocalSet 创建但还没 run 的时候就 spawn 任务。

tokio-util::task::LocalPoolHandle

在一个多线程 runtime 里,如果你需要多个 "!Send 子世界"(每个子世界独立),可以用 LocalPoolHandle(在 tokio-util crate 里):

use tokio_util::task::LocalPoolHandle;

let pool = LocalPoolHandle::new(4);   // 4 个 worker,每个都是 LocalSet 驱动
let handle = pool.spawn_pinned(|| async { /* !Send */ });

原理:内部 pool 跑 N 个专用线程,每个线程上跑一个 LocalSet——任务被分派到其中一个。这是 "thread-per-core + LocalSet" 的 Tokio 官方实现,适合 "!Send 资源 + 需要一定并行度" 的场景。

这些工具你不一定常用,但知道它们存在——遇到 !Send 需求时多一条路径可选。


7.20 延伸:tokio-uring 的 current_thread 模型

tokio-uring 是 Tokio 生态里专门利用 Linux io_uring 的运行时——它用的是 current_thread 模型。这节简单看看它为什么选单线程路线。

题外话但值得一提:tokio-uring 是 tokio-rs 组织下的另一个独立仓库(不在本专栏基准的 tokio 1.40.0 源码树里),它的入口 tokio_uring::start 建的是一个 current_thread runtime(外加一个 LocalSet,future 经 run_until 驱动)。

原因:io_uring 的提交队列(SQ)/ 完成队列(CQ)本质上是每个 ring 一对、面向单线程使用的结构——跨线程共用同一个 ring 需要加锁,会把 io_uring 相对 epoll 的优势吃掉不少。

所以 tokio-uring 的代码模式是:

// tokio-uring 风格
tokio_uring::start(async {
    // 这里是 current_thread runtime + io_uring driver
    let file = tokio_uring::fs::File::open("...").await.unwrap();
    let (res, buf) = file.read_at(vec![0; 4096], 0).await;
});

每个要做 I/O 的线程自己起一个 tokio-uring 实例。对于"每核一个 worker"架构(类似 monoio)这是自然的分布。

这个细节说明:current_thread runtime 不只是"简化版 multi_thread",它在某些极端场景下反而是唯一选项。


7.21 LocalSet 的历史和演化

LocalSet 不是从 Tokio 1.0 才有的。查 CHANGELOG 能查到确切的出生日期:Tokio 0.2.1(2019 年 11 月 26 日)(tokio-1.40.0/tokio/CHANGELOG.md:3314 的版本标题、:3322 的条目),条目写着

tokio::task::LocalSet provides a strategy for spawning !Send tasks(#1733)

它要解决的痛点很具体:Tokio 0.1 的 tokio::runtime::current_thread 还提供过不要求 Send 的 spawn(tokio_current_thread::spawn);0.2 重写调度器、统一成要求 Send + 'static 的 tokio::spawn 后,Rc / RefCell 这类 !Send 类型就没地方放了。放松 multi_thread 的 Send 约束会直接废掉 work-stealing(任务不能在 worker 之间搬运,偷任务就无从谈起),所以 Tokio 选择了另一条路:引入 LocalSet 作为一个 opt-in 的 escape hatch。

从 0.2.1 到 1.40,LocalSet 的核心 API 形态基本没变(LocalSet::new 在 tokio-1.40.0/tokio/src/task/local.rs:431、pub async fn run_until 在 tokio-1.40.0/tokio/src/task/local.rs:631、block_on 在 tokio-1.40.0/tokio/src/task/local.rs:592、spawn_local 方法在 tokio-1.40.0/tokio/src/task/local.rs:519,自由函数版 tokio::task::spawn_local 在 tokio-1.40.0/tokio/src/task/local.rs:365),后来主要是加东西和修 bug——比如 1.21.0(2022 年 9 月 2 日)的 LocalSet::enter(#4765,见 tokio-1.40.0/tokio/CHANGELOG.md:1203,实现在 tokio-1.40.0/tokio/src/task/local.rs:460,返回 LocalEnterGuard)、tokio_unstable 下的 LocalSet::unhandled_panic 配置(tokio-1.40.0/tokio/src/task/local.rs:828),以及 CHANGELOG 里一长串 "Fix leak in LocalSet"(#3978,tokio-1.40.0/tokio/CHANGELOG.md:1966)、"LocalSet hang under load"(#1905,tokio-1.40.0/tokio/CHANGELOG.md:3265)这类修复。能在早期就把 API 形状定对、后面只做加法,是 Tokio 在稳定性上的一贯风格。


7.22 落地:GUI 事件循环怎么和 Tokio 拼在一起

把前面所有零件拼起来,GUI 集成的骨架长这样。注意下面这段是为了讲清模式而写的示意代码,不是抄自某个具体项目:

// 示意:GUI 主循环 + current_thread runtime + LocalSet
let rt = tokio::runtime::Builder::new_current_thread()
    .enable_all()
    .build()
    .unwrap();
let local = tokio::task::LocalSet::new();

// 每一帧调用一次:给 LocalSet 一小段时间片,然后立刻回到渲染
fn on_frame(rt: &tokio::runtime::Runtime, local: &tokio::task::LocalSet) {
    local.block_on(rt, async {
        // 只让出很短一段时间,避免阻塞渲染节奏
        let _ = tokio::time::timeout(
            std::time::Duration::from_millis(1),
            std::future::pending::<()>(),
        ).await;
    });

    // …这里做正常的 UI 渲染…
}

三个要点:

  • 每帧只给 LocalSet 一小段时间——让异步任务有机会推进,但不打乱渲染的帧节奏。block_on(&rt, ...) 内部就是 rt.block_on(self.run_until(future)),run_until 每次 poll 都会先 poll 传入的 future、没完成就接着 tick 一轮 LocalSet
  • UI 状态直接放在 Rc / RefCell 里,spawn_local 出去的任务可以直接碰它们——这正是 LocalSet 存在的意义
  • 重活(HTTP、解码、文件 I/O)交给另一个 multi_thread runtime:用那个 runtime 事先 clone 好的 Handle 调 spawn(在这里直接写 tokio::spawn 只会 spawn 到当前这个 current_thread runtime 上),做完再通过 channel 把结果送回 UI 线程

current_thread + LocalSet 是 Rust GUI 异步化的一种常见解法——但各 GUI 框架自带的异步集成并不统一(7.1 节提过 iced 的 tokio 执行器就是 multi_thread),落地时以所用框架为准。

7.23 一个小练习:从头写一个极简 current_thread runtime

如果你想在离开本章前加深理解,试着在自己的电脑上写一个极简 current_thread runtime(~80 行),它能跑一个简单的 async fn。

核心骨架(留给你自己补完):

// 骨架:请你自己补完缺失的部分
struct MyRuntime {
    queue: Rc<RefCell<VecDeque<Rc<MyTask>>>>,
}

struct MyTask {
    future: RefCell<Pin<Box<dyn Future<Output = ()>>>>,
    queue: Rc<RefCell<VecDeque<Rc<MyTask>>>>,
}

impl MyRuntime {
    fn new() -> Self {
        MyRuntime { queue: Rc::new(RefCell::new(VecDeque::new())) }
    }
    
    fn spawn<F: Future<Output = ()> + 'static>(&self, future: F) {
        let task = Rc::new(MyTask {
            future: RefCell::new(Box::pin(future)),
            queue: self.queue.clone(),
        });
        self.queue.borrow_mut().push_back(task);
    }
    
    fn block_on<F: Future>(&self, mut future: F) -> F::Output {
        // 主循环:
        // 1. poll 顶层 future
        // 2. 如果 Ready,返回
        // 3. 否则 pop 一个任务 poll 一次
        // 4. 如果队列空且顶层未 Ready,怎么办?
        todo!("自己补全")
    }
}

提示:你需要一个 Waker 实现——最简的是让 Waker 把 Task push 回队列。但要注意:Waker 是 Send + Sync 的,拿到它的代码可以在任何线程上调 wake,所以不能把骨架里的 Rc<MyTask> 直接经 Waker::from_raw 包成 Waker——一旦别的线程 wake,Rc 的非原子计数和 RefCell 就会出现数据竞争,这是不健全的。稳妥的做法是把任务和队列换成 Arc + Mutex(future 相应要求 Send),为 MyTask 实现 std::task::Wake,再用 Waker::from(task) 构造;若坚持用 Rc,这个骨架只在"Waker 永远不离开当前线程"的前提下成立,而这一点编译器不替你检查。

写完后你会对为什么 Tokio 的 current_thread 是那个结构有切身的理解。花 30 分钟做这个练习,比多读一小时文字更有价值。


一个收尾的视角

到这里,本专栏讲 Runtime 和 Scheduler 子系统的部分(第 1-7 章)完成。你已经理解了 Tokio 的大脑——决定 "Task 何时、在哪执行"的那整套机制。从第 8 章起将深入 Tokio 的感官——I/O Driver、Time Driver、同步原语、channel——那是 Tokio 和真实世界(操作系统、时间、并发通信)打交道的地方。换一个视角,你会发现后面的每一个组件,其实都是在第 1-7 章建立的那个舞台上唱戏。


下一章我们离开调度器话题、进入 I/O Driver——也就是把 Tokio 和操作系统连起来的那一层。你会看到 mio 库在 Tokio 内部的位置、epoll_wait 调用的封装、一个 TcpStream::read 从 user space 到 kernel space 再回来的完整路径。


延伸阅读