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>>);常量条件在单态化时就能裁掉没走的分支,只生成用到的那一套。
- 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
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 就够了:只需要每次拿到不同的数,不需要和别的内存操作排序。
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}");
}
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
三步:
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 与关闭。- 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 路径上。 - 调度。 只有
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 已经交给了别的线程 |
- 满足:
schedule_local。不是 yield 就进 LIFO 槽,槽里原来的任务挪到本地队列尾部;本地队列多了任务才叫醒别的 worker 来偷。见 worker 主循环。 - 不满足:
push_remote_task加锁挂进注入队列,notify_parked_remote通过idle.worker_to_notify挑一个睡着的 worker 叫醒(没有可叫的就不叫)。见全局注入队列和任务窃取与空闲。
第一个条件是最常被忽略的:#[tokio::main] 函数体里直接 spawn 的任务全部经过注入队列,每次都要加一次锁、可能叫醒一个线程。在已经运行的任务里 spawn,则完全不碰锁。
问: 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 进本地队列。