Rust in Everything
基于 tokio 1.54.1

spawn 全路径

主线位置:任务主线的起点,「tokio::spawn → 分配任务 → 进入队列」。从 #[tokio::main] 开始 用一张四行的表概括过这条链,这一章把每一跳展开。

flowchart TB
  spawn["task::spawn"] --> bx{"future 超过阈值?"}
  bx -->|是| boxed["Box::pin 装箱"]
  bx -->|否| inner[spawn_inner]
  boxed --> inner
  inner --> id[分配任务 ID]
  id --> cur[with_current 取句柄]
  cur --> disp["scheduler::Handle::spawn"]
  disp --> bnt[bind_new_task]
  bnt --> bind["OwnedTasks::bind"]
  bind --> hook[spawn 钩子]
  hook --> st[schedule_task]
  st -->|本运行时 worker 且有 Core| local[schedule_local]
  st -->|其他情况| remote[注入队列加唤醒]

第一跳:spawn 和 AutoBox

task::spawn 本身只做一件事:决定要不要先把 future 装箱。

#[track_caller]
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
{
    let fut_size = std::mem::size_of::<F>();
    if AutoBox::<F>::SHOULD_BOX {
        spawn_inner(Box::pin(future), SpawnMeta::new_unnamed(fut_size))
    } else {
        spawn_inner(future, SpawnMeta::new_unnamed(fut_size))
    }
}

阈值在 runtime/mod.rs:

/// Boundary value to prevent stack overflow caused by a large-sized
/// Future being placed in the stack.
pub(crate) const BOX_FUTURE_THRESHOLD: usize = if cfg!(debug_assertions)  {
    2048
} else {
    16384
};

为什么要装箱:future 是按值传下去的,从 spawn 到 spawn_inner、bind、new_task,最后在 Cell::new 里用 Box::new(Cell { ... }) 放上堆。这一路上每一层都可能在栈上留一份拷贝,一个几十 KB 的 future 经过几层就可能把栈撑爆。装箱以后一路上传的只是一个指针。debug 构建不做优化,这些栈上拷贝基本都不会被消除,所以 debug 下的阈值低得多。

判断是 >,所以正好 16384 字节的 future 不装箱。

判断写成关联常量 AutoBox::SHOULD_BOX,而不是运行时的 if size_of::<F>() > ...。源码注释给了原因:运行时的 if 会让两个分支都实例化,每个 future 类型生成两套任务代码(一套给 F,一套给 Pin<Box<F>>);常量条件在单态化时就能裁掉没走的分支,只生成用到的那一套。

📝NOTE
冷知识:自动装箱改过三次
  • 2021 年 8 月的 #4009(1.10.0)最早加上这个判断,标题是「quickly send task to heap on debug mode」:只在 debug 构建里、future 超过 2048 字节时装箱。
  • 2024 年 9 月的 #6826(1.41.0)去掉了「只在 debug」的限制。PR 说明引用了 issue #6057(「Stack overflow in release builds with large futures」):只在 debug 下装箱,会让代码在 debug 构建里好好的,到 release 才栈溢出,而且很难查。合并后的代码给 release 定了 16384 的阈值,debug 保持 2048。
  • 2026 年 9 月的 #8414(1.54.0)把运行时的 if 换成了这里的 AutoBox::<F>::SHOULD_BOX,装不装箱的结果不变。PR 里的测量:300 个不同的 future 类型各 spawn 一次,debug 构建,单态化出来的实例从 110,652 个降到 55,833 个,这个测试 crate 自己的 rustc 用时从 7.31 秒降到 3.86 秒。

Handle::spawn、Runtime::spawn、spawn_local、spawn_blocking、task::Builder 都有同样的判断,JoinSet::spawn 走的是 tokio::spawn,也一样。

SpawnMeta 记下装箱前的大小和调用位置(#[track_caller] 一路传下来)。原始大小只在打开 tokio_unstable 和 tracing 时进入 span,见下一节。

第二跳:spawn_inner

spawn_inner:

let id = task::Id::next();
let task = crate::util::trace::task(future, "task", meta, id.as_u64());

match context::with_current(|handle| handle.spawn(task, id, meta.spawned_at)) {
    Ok(join_handle) => join_handle,
    Err(e) => panic!("{}", e),
}

(前面还有一段只在 Linux 加 taskdump 时生效的 Trace::root 包装,这里略过。)

任务 ID。 Id::next 是一个进程级的 static AtomicU64,从 1 开始 fetch_add(1, Relaxed)。ID 存成 NonZeroU64,循环只是为了在 64 位回绕到 0 时跳过它。ID 在进程内所有运行时之间唯一,和运行时无关。Relaxed 就够了:只需要每次拿到不同的数,不需要和别的内存操作排序。

❗IMPORTANT
动脑筋:下面打印的五个 ID 各是几?
use tokio::runtime::Builder;

fn main() {
  let ct = Builder::new_current_thread().build().unwrap();
  let a = ct.block_on(async { tokio::spawn(async {}).id() });
  let b = ct.block_on(async { tokio::spawn(async {}).id() });
  println!("current_thread: {a}, {b}");

  let mt = Builder::new_multi_thread().worker_threads(4).build().unwrap();
  let c = mt.block_on(async { tokio::spawn(async {}).id() });
  println!("multi_thread(4): {c}");

  let d = ct.block_on(async { tokio::task::spawn_blocking(|| ()).id() });
  let e = ct.block_on(async { tokio::spawn(async {}).id() });
  println!("current_thread again: {d}, {e}");
}
提示:多线程运行时的 worker 线程是怎么启动的?答案在本页末尾。

tracing span。 util::trace::task 只在 tokio_unstable 加 tracing feature 下才有实际内容:用 Instrument 把 future 包进一个名为 runtime.spawn 的 span,字段有任务 ID、名字、大小、装箱前的大小、调用位置。tokio-console 就是靠这个 span 看到任务的。默认构建下它是一个原样返回的空函数。

取运行时句柄。 这里用的是 context::with_current,不是 Handle::current():

pub(crate) fn with_current<F, R>(f: F) -> Result<R, TryCurrentError>
where
    F: FnOnce(&scheduler::Handle) -> R,
{
    match CONTEXT.try_with(|ctx| ctx.current.handle.borrow().as_ref().map(f)) {
        Ok(Some(ret)) => Ok(ret),
        Ok(None) => Err(TryCurrentError::new_no_context()),
        Err(_access_error) => Err(TryCurrentError::new_thread_local_destroyed()),
    }
}

Handle::current() 会把线程本地里的句柄克隆一份出来(一次 Arc 引用计数加一),with_current 只是借用,在闭包里直接调 handle.spawn。句柄是怎么放进线程本地 CONTEXT 的,见线程本地上下文。

两种失败对应两条 panic 信息:没有进入任何运行时,是 CONTEXT_MISSING_ERROR「there is no reactor running, must be called from the context of a Tokio 1.x runtime」;线程本地变量已经析构(比如在别的线程本地变量的 Drop 里 spawn),是「The Tokio context thread-local variable has been destroyed.」。

第三跳:分派和 bind_new_task

scheduler::Handle::spawn 是一个 match,按 current_thread / multi_thread 分派。多线程这边的 multi_thread::Handle::spawn 直接转给 bind_new_task:

let (handle, notified) = me.shared.owned.bind(future, me.clone(), id, spawned_at);

me.task_hooks.spawn(&TaskMeta {
    id,
    spawned_at,
    #[cfg(all(tokio_unstable, feature = "schedule-latency"))]
    schedule_latency: None,
    _phantom: Default::default(),
});

me.schedule_option_task_without_yield(notified);

handle

三步:

  1. OwnedTasks::bind(list.rs 第 87 行):new_task 做那一次堆分配,得到同一个任务的三个引用 Task、Notified、JoinHandle(见任务的内存布局);然后 bind_inner 写入 owner id,锁住对应分片,把 Task 挂进存活任务链表。传进去的调度器是 me.clone(),即 Arc<Handle>,任务以后被唤醒时就靠它找回调度器。如果运行时已经关闭,bind_inner 当场 shutdown 这个任务并返回 None:JoinHandle 照样返回给用户,await 它会得到一个「已取消」的 JoinError。细节见 OwnedTasks 与关闭。
  2. spawn 钩子。 TaskHooks::spawn 调用 Builder::on_task_spawn 注册的回调(字段名是 before_spawn)。这个 API 要 tokio_unstable。回调在 spawn 的调用线程上同步执行,位置在 bind 之后、调度之前,所以它一定先于任务第一次被 poll。同一组钩子里还有 on_task_terminate,以及 on_before_task_poll / on_after_task_poll,它们在任务结束和每次 poll 前后由 worker 调用,不在 spawn 路径上。
  3. 调度。 只有 bind 返回了 Notified 才调度。schedule_option_task_without_yield 单独成一个函数,注释说是为了减少 bind_new_task 的 LLVM 代码量:bind_new_task 对每个 future 类型都实例化一次,把与类型无关的部分挪出去,就只编译一份。

最后一跳:schedule_task

schedule_task 决定任务进哪个队列。spawn 和唤醒都走这里(唤醒时 is_yield 为 false,yield_now 时为 true):

with_current(|maybe_cx| {
    if let Some(cx) = maybe_cx {
        // Make sure the task is part of the **current** scheduler.
        if self.ptr_eq(&cx.worker.handle) {
            // And the current thread still holds a core
            if let Some(core) = cx.core.borrow_mut().as_mut() {
                self.schedule_local(core, task, is_yield);
                return;
            }
        }
    }

    // Otherwise, use the inject queue.
    self.push_remote_task(task);
    self.notify_parked_remote();
});

这里的 with_current 和上一节那个同名函数不是一回事。它读的是线程本地的调度器上下文(context::with_scheduler),只有 worker 线程在 run 里调了 set_scheduler 才有。运行时句柄和调度器上下文是两样东西:block_on 所在的线程、Handle::enter 过的线程都有句柄,但没有调度器上下文。

走本地分支要同时满足三个条件:

条件不满足的典型情况
当前线程是一个多线程调度器的 worker#[tokio::main] 的函数体(跑在 block_on 所在的主线程上)、spawn_blocking 的线程、自己开的线程
是这个运行时的 worker(ptr_eq 比较 Handle 地址)在运行时 A 的任务里,用运行时 B 的 Handle spawn
这个 worker 此刻持有 Core任务正在 block_in_place 里,Core 已经交给了别的线程

第一个条件是最常被忽略的:#[tokio::main] 函数体里直接 spawn 的任务全部经过注入队列,每次都要加一次锁、可能叫醒一个线程。在已经运行的任务里 spawn,则完全不碰锁。

📝NOTE
没有蠢问题

问: tokio::spawn 返回的时候,任务已经开始跑了吗?

答: 不一定,两种都可能。spawn 这条路上没有一处 poll 过 future,它只是把 Notified 放进某个队列。之后谁先 poll 它,看走的是哪个分支:

  • current_thread 运行时上,不会:唯一能 poll 它的线程正在执行你的代码,要等你的任务让出。
  • 多线程运行时走注入队列时,可能已经在跑了:notify_parked_remote 当场叫醒了一个 worker,它完全可能在 spawn 返回之前就把任务取走、poll 了。
  • 多线程运行时走本地分支时,新任务通常在 LIFO 槽里,别的 worker 偷不走,要等当前任务让出。但 lifo_enabled 为假时它进的是本地队列,可以被偷。

所以代码不要依赖任何一种顺序。需要子任务先走到某一步,就用 oneshot 或 Notify 等它。

current_thread 的路径

前两跳完全一样。current_thread::Handle::spawn 把 bind、钩子、调度写在一个函数里,调度走 Schedule::schedule:

context::with_scheduler(|maybe_cx| match maybe_cx {
    Some(CurrentThread(cx)) if Arc::ptr_eq(self, &cx.handle) => {
        let mut core = cx.core.borrow_mut();

        // If `None`, the runtime is shutting down, so there is no need
        // to schedule the task.
        if let Some(core) = core.as_mut() {
            core.push_task(self, task);
        }
        self.driver.unpark_self();
    }
    _ => {
        // Track that a task was scheduled from **outside** of the runtime.
        self.shared.scheduler_metrics.inc_remote_schedule_count();

        // Schedule the task
        self.shared.inject.push(task);
        self.driver.unpark();
    }
});

结构和多线程版一样:在本运行时的线程上就进本地队列(一个 VecDeque,没有 LIFO 槽),否则进注入队列并唤醒驱动。区别在于 current_thread 的 block_on 线程本身就是唯一的「worker」,block_on 期间它持有 Core、设置了调度器上下文,所以 #[tokio::main(flavor = "current_thread")] 函数体里 spawn 走的是本地分支。详见 current_thread。

实验一:谁第一个 poll 新任务

4 个 worker。先在 block_on 的函数体里 spawn 一个任务,看它在哪个线程上第一次被 poll;再在一个任务里连续 spawn 8 个子任务,重复 200 轮,统计每个子任务和父任务在同一线程上运行的次数:

use std::thread;

fn name() -> String {
  thread::current().name().unwrap_or("?").to_string()
}

fn main() {
  let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(4)
    .thread_name_fn(|| {
      use std::sync::atomic::{AtomicUsize, Ordering};
      static N: AtomicUsize = AtomicUsize::new(0);
      format!("worker-{}", N.fetch_add(1, Ordering::Relaxed))
    })
    .build()
    .unwrap();

  rt.block_on(async {
    println!("block_on body runs on: {}", name());
    // spawn from the block_on thread (not a worker): goes to the inject queue
    let t = tokio::spawn(async { name() }).await.unwrap();
    println!("spawned from main, first polled on: {t}");

    // spawn 8 children from inside a worker task, 200 rounds
    let mut same = [0u32; 8];
    for _ in 0..200 {
      let res = tokio::spawn(async {
        let parent = name();
        let hs: Vec<_> = (0..8).map(|_| tokio::spawn(async { name() })).collect();
        let mut out = [false; 8];
        for (i, h) in hs.into_iter().enumerate() {
          out[i] = h.await.unwrap() == parent;
        }
        out
      })
      .await
      .unwrap();
      for i in 0..8 {
        if res[i] {
          same[i] += 1;
        }
      }
    }
    for i in 0..8 {
      println!("child #{i}: ran on parent's thread in {}/200 rounds", same[i]);
    }
  });
}

cargo run --release 的一次输出:

block_on body runs on: main
spawned from main, first polled on: worker-0
child #0: ran on parent's thread in 200/200 rounds
child #1: ran on parent's thread in 200/200 rounds
child #2: ran on parent's thread in 199/200 rounds
child #3: ran on parent's thread in 197/200 rounds
child #4: ran on parent's thread in 197/200 rounds
child #5: ran on parent's thread in 146/200 rounds
child #6: ran on parent's thread in 126/200 rounds
child #7: ran on parent's thread in 200/200 rounds
  • block_on 的函数体跑在 main 线程上,那里没有调度器上下文,spawn 的任务进注入队列,由被叫醒的 worker 取走。
  • 最后一个子任务 #7 留在 LIFO 槽里。LIFO 槽不能被偷,所以 200 轮全部在父任务的线程上跑。
  • #0 到 #6 被依次挤进本地队列,它们是可以被偷的。父任务第一次 await 让出后,本 worker 先跑 LIFO 槽里的 #7,再从队列头部按顺序取 #0、#1……与此同时被叫醒的 worker 在偷,越靠后的任务越可能在本 worker 取到之前被偷走。具体数字每次运行都不同,但 #7 恒为 200/200。

实验二:大 future 装箱

用一个记录分配大小的全局分配器,看 spawn 期间发生了哪些不小于 64 字节的分配:

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;

struct Rec;
static ON: AtomicBool = AtomicBool::new(false);
static SIZES: Mutex<Vec<usize>> = Mutex::new(Vec::new());

unsafe impl GlobalAlloc for Rec {
  unsafe fn alloc(&self, l: Layout) -> *mut u8 {
    if l.size() >= 64 && ON.swap(false, Ordering::SeqCst) {
      SIZES.lock().unwrap().push(l.size());
      ON.store(true, Ordering::SeqCst);
    }
    unsafe { System.alloc(l) }
  }
  unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
    unsafe { System.dealloc(p, l) }
  }
}

#[global_allocator]
static A: Rec = Rec;

fn spawn_sized<const N: usize>(rt: &tokio::runtime::Runtime) {
  let buf = [1u8; N];
  let fut = async move {
    std::hint::black_box(&buf);
  };
  let size = std::mem::size_of_val(&fut);
  SIZES.lock().unwrap().clear();
  ON.store(true, Ordering::SeqCst);
  let h = rt.spawn(fut);
  ON.store(false, Ordering::SeqCst);
  let sizes = SIZES.lock().unwrap().clone();
  println!("future {size:>5} bytes -> allocations >= 64B in spawn: {sizes:?}");
  rt.block_on(h).unwrap();
}

fn main() {
  let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
  spawn_sized::<16000>(&rt);
  spawn_sized::<16383>(&rt);
  spawn_sized::<16384>(&rt);
  spawn_sized::<17000>(&rt);
}

future 比数组多 1 字节,是 async 块的状态标记。release 构建:

future 16001 bytes -> allocations >= 64B in spawn: [16128]
future 16384 bytes -> allocations >= 64B in spawn: [16512]
future 16385 bytes -> allocations >= 64B in spawn: [16385, 128]
future 17001 bytes -> allocations >= 64B in spawn: [17001, 128]

debug 构建:

future 16001 bytes -> allocations >= 64B in spawn: [16001, 128]
future 16384 bytes -> allocations >= 64B in spawn: [16384, 128]
future 16385 bytes -> allocations >= 64B in spawn: [16385, 128]
future 17001 bytes -> allocations >= 64B in spawn: [17001, 128]
  • release 下,16384 字节及以下只有一次分配:任务 Cell 本身,future 内联在里面(16512 = 16384 + 128 字节的头尾)。
  • 超过 16384 字节,变成两次:先是 Box::pin 的 16385 字节,然后是一个只有 128 字节的 Cell,里面放的是一个指针。
  • debug 下阈值是 2048,这四个 future 全部装箱。

动脑筋答案

M2 Max、rustc 1.99.0、--release,默认 feature(没有 tokio_unstable),连跑 3 次,输出都一样:

current_thread: 1, 2
multi_thread(4): 7
current_thread again: 8, 9
  • 1、2:前两个 tokio::spawn 拿到 1 和 2。block_on 本身不占 ID:Runtime::block_on 里那次 Id::next() 只在 tokio_unstable 加 tracing 时才编译进来,给 tracing span 用。
  • 7:3 到 6 被 4 个 worker 用掉了。多线程运行时构建时,每个 worker 都是用 runtime::spawn_blocking(move || run(worker)) 在阻塞线程池里启动的,而阻塞池每接一个任务都会 task::Id::next() 一次。
  • 8、9:spawn_blocking 同样占一个 ID,所以下一个 spawn 是 9。ID 在所有运行时之间共用一个计数器,换回 current_thread 运行时也是接着数。

任务 ID 只保证唯一,不保证连续,也不等于「第几个 spawn 的任务」。block_in_place 把 Core 交给新线程时也走 spawn_blocking(同一个文件的第 506 行),也会占掉 ID。

小结

  • spawn 先看 future 大小:超过 BOX_FUTURE_THRESHOLD(release 16384,debug 2048)就 Box::pin,防止按值传递的路上撑爆栈。判断是编译期常量,只实例化一套任务代码。
  • spawn_inner 从全局 AtomicU64 拿任务 ID,借用线程本地里的运行时句柄(不克隆),取不到就 panic。
  • bind_new_task 三步:OwnedTasks::bind 分配并登记任务,调 on_task_spawn 钩子,调度。运行时已关闭时任务当场被取消。
  • schedule_task 只在「本运行时的 worker 线程,且持有 Core」时走本地分支(LIFO 槽);其余情况进注入队列并叫醒一个 worker。#[tokio::main] 函数体里的 spawn 属于后者。
  • current_thread 路径结构相同,但 block_on 线程就是它的 worker,函数体里的 spawn 进本地队列。

💬 关联讨论

发起讨论
加载中...