JoinSet、LocalSet、task_local
主线位置:任务主线的外围。
JoinSet管一组任务的「完成 → 取结果」;LocalSet是任务进队列、被 poll 的另一条路,不经过多线程调度器;task_local!作用在单次 poll 内部。
flowchart TB
wake[本地任务被唤醒] --> q1{"CURRENT 指向这个 LocalSet?"}
q1 -->|"是,且正在 poll"| lq[进本地队列]
q1 -->|否| q2{当前线程是 owner?}
q2 -->|是| lq2[进本地队列,唤醒 LocalSet]
q2 -->|否| rq[进远程队列,唤醒 LocalSet]
lq --> tick[LocalSet 的 tick]
lq2 --> tick
rq --> tick
tick -->|每次 poll 最多 61 个| run[在 owner 线程上 run]
JoinSet:只 poll 被唤醒的那几个
JoinSet 只有一个字段:
pub struct JoinSet<T> {
inner: IdleNotifiedSet<JoinHandle<T>>,
}
最朴素的实现是一个 Vec<JoinHandle<T>>,join_next 时挨个 poll。一千个任务里只有一个完成了,也要 poll 一千次。IdleNotifiedSet 解决的就是这个问题:它记住哪些 JoinHandle 被唤醒过,join_next 只看这些。
两条链表
IdleNotifiedSet 的共享部分是一把锁里的两条侵入式链表和一个 waker(ListsInner):
struct ListsInner<T> {
notified: LinkedList<ListEntry<T>>,
idle: LinkedList<ListEntry<T>>,
/// Whenever an element in the `notified` list is woken, this waker will be
/// notified and consumed, if it exists.
waker: Option<Waker>,
}
每个元素是一个 Arc<ListEntry<T>>,里面放着 JoinHandle、链表指针、指回共享部分的 Arc,以及一个 my_list 字段记录自己在哪条链表上(枚举 List:Notified、Idle 或已取出的 Neither)。元素个数 length 不在锁里,因为只有持有 &mut JoinSet 的一方会增删元素。
stateDiagram-v2 [*] --> Idle: spawn 后 insert_idle Idle --> Notified: 任务完成,entry 的 waker 被调用 Notified --> Idle: join_next 取出准备 poll Idle --> Neither: poll 得到 Ready,remove Notified --> Neither: drop JoinSet,drain Idle --> Neither: drop JoinSet,drain Neither --> [*]
插入(insert):JoinSet::spawn 就是 tokio::spawn 之后把 JoinHandle 放进 idle 链表,再用这个 entry 自己做的 waker 调 set_join_waker。任务完成时叫醒的不是用户的任务,而是这个 entry。如果设 waker 时任务已经完成了,set_join_waker 会立即唤醒一次。
唤醒(wake_by_ref):加锁,如果 entry 在 idle 链表上,就把它挪到 notified 链表头部,然后取出 JoinSet 记下的那个 waker,在锁外调用它,这才叫醒等在 join_next 上的用户任务。已经在 notified 链表上的 entry 再被唤醒,什么都不做。
取出(pop_notified):加锁,记下调用方的 waker(will_wake 相同就不替换,省一次克隆),从 notified 链表尾部弹出一个,放回 idle 链表。头部进、尾部出,所以 join_next 按被唤醒的先后顺序返回结果,先完成的先返回,不会有任务一直排不到。
快餐店给每位顾客发一个呼叫器(每个 entry 自己的 waker)。点完餐的人都坐在大厅里等(idle 链表)。谁的餐好了,谁的呼叫器就响,他走到取餐口排队(挪到 notified 链表头部),同时柜台的铃响一声,叫醒在柜台后打盹的店员(JoinSet 记下的 waker,也就是等在 join_next 上的任务)。店员只叫取餐口队伍里排得最早的那位(pop_notified 从尾部取),不用在大厅里挨桌问「你的好了没」。已经在取餐口排着的人,呼叫器再响一次,位置也不变。
比方到这里为止:真顾客取了餐就走,这里被叫到的 entry 先回到大厅(放回 idle 链表),店员 poll 它的 JoinHandle,确认真的完成了才把它 remove。预算不够时它没取到餐,呼叫器过一会儿还会再响,它再去取餐口排一次。
poll_join_next 拿到 entry 后,用 entry 的 waker poll 那个 JoinHandle:
let res = entry.with_value_and_context(|jh, ctx| Pin::new(jh).poll(ctx));
if let Poll::Ready(res) = res {
let _entry = entry.remove();
Poll::Ready(Some(res))
} else {
// A JoinHandle generally won't emit a wakeup without being ready unless
// the coop limit has been reached. We yield to the executor in this
// case.
cx.waker().wake_by_ref();
Poll::Pending
}
被唤醒的 JoinHandle 一般都已经就绪。例外是协作式预算用完:JoinHandle::poll 会扣预算(见协作式调度预算),扣不到就返回 Pending,并安排稍后唤醒 entry 的 waker,entry 会重新回到 notified 链表。JoinSet 这边叫醒自己再让出一次。
join_next 是 cancel safe 的:entry 只有在 JoinHandle 返回 Ready 的同一次 poll 里才被 remove,结果拿到手和从集合里删掉之间没有 .await。
drop 时全部 abort
impl<T> Drop for JoinSet<T> {
fn drop(&mut self) {
self.inner.drain(|join_handle| join_handle.abort());
}
}
drain 在锁里把两条链表上的 entry 全部挪到一条临时链表,再逐个取出调用闭包。这是 JoinSet 和一堆 JoinHandle 最大的区别:drop JoinHandle 只是放手(任务继续跑),drop JoinSet 会取消所有还没完成的任务。不想取消就先调 detach_all。abort 只是置位并调度任务,future 要等任务下一次被运行时处理时才 drop(见 JoinHandle、取消与 JoinError)。
实验,三个任务按 30、10、20 毫秒完成;然后 drop 一个还有任务在跑的 JoinSet:
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::sleep;
struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("dropped: {}", self.0);
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let mut set = JoinSet::new();
for (name, ms) in [("a", 30), ("b", 10), ("c", 20)] {
set.spawn(async move {
sleep(Duration::from_millis(ms)).await;
name
});
}
while let Some(r) = set.join_next().await {
print!("{} ", r.unwrap());
}
println!();
let mut set = JoinSet::new();
set.spawn(async {
let _g = Guard("task in JoinSet");
sleep(Duration::from_secs(3600)).await;
});
tokio::task::yield_now().await; // let the task start
println!("dropping JoinSet");
drop(set);
tokio::task::yield_now().await; // let the runtime process the abort
println!("after drop");
}
b c a
dropping JoinSet
dropped: task in JoinSet
after drop
结果按完成顺序返回,不是 spawn 顺序。drop JoinSet 之后,那个要睡一小时的任务在下一次调度时就被取消,它的局部变量随之析构。
LocalSet:跑在任务里的小调度器
tokio::spawn 要求 Send,因为多线程调度器会把任务交给任何一个 worker,还会被别的 worker 偷走。Rc、RefCell 这类 !Send 的值跨 .await 持有,future 就不是 Send 的。LocalSet 提供另一个执行器:任务只在创建 LocalSet 的那个线程上被 poll。
结构
pub struct LocalSet {
/// Current scheduler tick.
tick: Cell<u8>,
/// State available from thread-local.
context: Rc<Context>,
/// This type should not be Send.
_not_send: PhantomData<*const ()>,
}
(LocalSet,外层还包着一个 cfg_rt!。)跨线程共享的部分在 Shared:
| 字段 | 内容 | 谁能碰 |
|---|---|---|
local_state.owner | 创建 LocalSet 的线程 ID | 只读 |
local_state.local_queue | UnsafeCell<VecDeque>,本地队列 | 只有 owner 线程 |
local_state.owned | LocalOwnedTasks,所有存活任务 | 只有 owner 线程 |
queue | Mutex<Option<VecDeque>>,远程队列 | 任何线程 |
waker | AtomicWaker,叫醒宿主任务 | 任何线程 |
两个队列初始容量都是 INITIAL_CAPACITY = 64。远程队列是 Option,LocalSet drop 时把它取走,之后其他线程的唤醒就直接丢弃。
LocalSet 不是运行时,它没有线程,也没有 IO 和定时器驱动。它本身是一个 Future(impl Future for LocalSet),或者通过 run_until 包住一个 future;block_on 就是 rt.block_on(self.run_until(future))。宿主运行时 poll 这个 future 时,LocalSet 才去跑自己队列里的任务。因为 LocalSet 是 !Send 的,包含它的 future 也是 !Send,不能交给 tokio::spawn,通常放在某个运行时的 block_on 里(current_thread 或多线程都可以)。
问: 把 LocalSet 放进多线程运行时的 block_on,里面的本地任务会不会被别的 worker 偷走?
答: 不会,它们根本不在 worker 的队列里。spawn_local 出来的任务挂在这个 LocalSet 自己的本地队列和远程队列上,只有它的 tick 会去取。LocalSet 自己又是在 block_on 的调用线程上被 poll 的:多线程运行时的 block_on 不把 future 交给 worker,而是在当前线程上用 CachedParkThread 反复 poll 它。worker 们只跑 tokio::spawn 出来的任务。反过来,这也意味着所有本地任务都挤在这一个线程上,多线程运行时的其他 worker 帮不上忙。
tick:一次最多 61 个
tick:
fn tick(&self) -> bool {
for _ in 0..MAX_TASKS_PER_TICK {
// Make sure we didn't hit an unhandled panic
assert!(!self.context.unhandled_panic.get(), "a spawned task panicked and the LocalSet is configured to shutdown on unhandled panic");
match self.next_task() {
// ...
Some(task) => crate::task::coop::budget(|| task.run()),
// ...
None => return false,
}
}
true
}
每次被 poll 最多跑 MAX_TASKS_PER_TICK = 61 个任务,每个任务各有一份 128 的预算。61 个跑完队列还没空,就 wake_by_ref 自己、返回 Pending,把线程还给宿主运行时。这和单个任务的协作式预算是同一个道理:整个 LocalSet 在宿主看来只是一个任务,它不让出,宿主的 IO 驱动、定时器和其他任务就都动不了。
LocalSet 共用一份预算tick 里给每个任务套 budget 的写法那时已经有了,但 #2462 的 PR 说明指出它没起作用:LocalSet 自己是跑在 block_on 里的一个 future,block_on 已经设过一次预算,而当时的 budget 在已经有预算时什么也不做。于是 LocalSet 里的每个任务,加上传给 run_until 的那个 future,扣的都是同一份预算。
issue #2460 撞上的就是这个(标题是「LocalSet, JoinHandle and budget coming together can lead to hangs with 100% CPU usage.」)。run_until 先 poll 自己的 future 再跑本地任务,那个 future 是一个装着不少于 128 个 JoinHandle 的 JoinAll,每次 poll 都把这份共用的预算一口气扣光,本地任务一个也跑不了,而 JoinAll 又在等它们完成。2020 年 4 月的修复(0.2.21)让 LocalSet 里的 budget 真正给每个任务开一份新预算。1.54.1 的 budget 不管之前有没有预算,都换上一份新的,返回时再把旧的换回去。
next_task 每 REMOTE_FIRST_INTERVAL = 31 个 tick 先看远程队列,平时先看本地队列,和 current_thread 用同一个数,理由也一样:本地任务互相唤醒能让本地队列一直不空,远程队列里的任务会饿死。
use std::cell::Cell;
use std::rc::Rc;
use tokio::task::{spawn_local, yield_now, LocalSet};
fn main() {
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
let local = LocalSet::new();
local.block_on(&rt, async {
let done = Rc::new(Cell::new(0u32));
for _ in 0..200 {
let done = done.clone();
spawn_local(async move { done.set(done.get() + 1) });
}
let mut seen = Vec::new();
while done.get() < 200 {
yield_now().await;
seen.push(done.get());
}
println!("{seen:?}");
});
}
提示:RunUntil::poll 先 poll 传进来的 future,再 tick。答案在本页末尾。
!Send 的 future 为什么安全
任务的 future 不是 Send 的,但唤醒它的 Waker 一定是 Send + Sync,可能在任何线程上被调用。tokio 用下面几层保证 future 只在 owner 线程上被 poll:
LocalSet是!Send。_not_send: PhantomData<*const ()>让它不能被移到别的线程,而 poll 任务只发生在LocalSet::poll/RunUntil::poll里,也就只可能发生在创建它的线程上。spawn_local只能在LocalSet里面调。 线程本地变量CURRENT只在LocalSet被 poll 期间(或LocalSet::enter的守卫存活期间)指向它。spawn_local_inner先看当前运行时是不是LocalRuntime,不是就读CURRENT,读不到就 panic:match CURRENT.with(|LocalData { ctx, .. }| ctx.get()) { None => panic!("`spawn_local` called from outside of a `task::LocalSet` or `runtime::LocalRuntime`"), Some(cx) => cx.spawn(future.take().unwrap(), meta) }调度按线程分流。
Shared::schedule就是本页开头那张图:当前正在 poll 这个LocalSet,直接进本地队列;在 owner 线程上但没在 poll,进本地队列并唤醒LocalSet;在别的线程上,只能进加锁的远程队列并唤醒。别的线程只放入Notified,从不 poll。只有
LocalNotified能运行。 本地任务挂在LocalOwnedTasks上,它用UnsafeCell而不是锁,并且是!Send + !Sync;它的bind不要求 future 是Send。next_task取出的Notified必须经过assert_owner换成LocalNotified(带PhantomData<*const ()>,不能离开当前线程)才能run()。assert_owner用assert_eq!检查任务的 owner id 是这个LocalSet的,release 构建里也生效。调试断言。 访问本地队列和
LocalOwnedTasks前会调assert_called_from_owner_thread比较线程 ID。这是debug_assert!,只在 debug 构建里检查,真正的保证来自前面几条。
Shared 里有 UnsafeCell,本来不是 Sync,源码用一行 unsafe impl Sync for Shared {} 绕过,上面的注释是:「This is safe because (and only because) we pinky pwomise to never touch the local run queue except from the thread that owns the LocalSet.」
LocalRuntime
1.54.1 里还有 LocalRuntime,通过 Builder::build_local 创建。在这个版本里它不需要 tokio_unstable。它内部就是一个 current_thread 调度器,额外记下创建线程的 ID(local_tid),自身用 PhantomData<*mut u8> 做成 !Send + !Sync。在它上面可以直接 spawn_local,不需要套一层 LocalSet;spawn_local_inner 里 handle.is_local() 那个分支就是给它的,在别的线程上调用会 panic「Local tasks can only be spawned on a LocalRuntime from the thread the runtime was created on」。用 new_multi_thread() 调 build_local 会 panic。文档说明它和 LocalSet 不兼容,不要在 LocalRuntime 里驱动 LocalSet。
LocalRuntime 在 1.41.0 到 1.50.x 之间一直要 tokio_unstable2024 年 10 月的 #6808(1.41.0)加入 LocalRuntime 时,它是不稳定 API。提交说明记了一个实现细节:为了复用普通运行时的 Handle,在别的线程上进入这个运行时的上下文,也能调到 spawn_local,所以 spawn_local 必须先检查线程 ID。上面那句「Local tasks can only be spawned on a LocalRuntime from the thread the runtime was created on」就来自这个检查(can_spawn_local_on_local_runtime)。2026 年 4 月的 #7557(1.51.0)把它稳定下来,PR 写的理由是:这样用它就不需要再设置 RUSTFLAGS 了。
实验:
fn main() {
std::panic::set_hook(Box::new(|info| {
println!("panic: {}", info.payload().downcast_ref::<&str>().copied()
.or(info.payload().downcast_ref::<String>().map(|s| s.as_str())).unwrap_or("?"));
}));
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
// 1. inside a runtime, but not inside a LocalSet
let r = std::panic::catch_unwind(|| {
rt.block_on(async {
tokio::task::spawn_local(async {});
})
});
println!("1. ok = {}", r.is_ok());
// 2. inside a LocalSet: works, and !Send values are fine
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async {
let rc = std::rc::Rc::new(5);
let v = tokio::task::spawn_local(async move { *rc + 1 }).await.unwrap();
println!("2. spawn_local in LocalSet -> {v}");
});
// 3. LocalRuntime (no tokio_unstable needed in 1.54.1)
let lrt = tokio::runtime::Builder::new_current_thread()
.build_local(Default::default())
.unwrap();
let v = lrt.block_on(async {
let rc = std::rc::Rc::new(7);
tokio::task::spawn_local(async move { *rc * 2 }).await.unwrap()
});
println!("3. spawn_local on LocalRuntime -> {v}");
}
不加 --cfg tokio_unstable 构建运行:
panic: `spawn_local` called from outside of a `task::LocalSet` or `runtime::LocalRuntime`
1. ok = false
2. spawn_local in LocalSet -> 6
3. spawn_local on LocalRuntime -> 14
task_local!:每次 poll 换进换出
task_local! 展开成一个 static LocalKey,里面是一个普通的线程本地变量(__task_local_inner!):
$vis static $name: $crate::task::LocalKey<$t> = {
std::thread_local! {
static __KEY: std::cell::RefCell<Option<$t>> = const { std::cell::RefCell::new(None) };
}
$crate::task::LocalKey { inner: __KEY }
};
任务会在不同线程上被 poll,值不能常驻在某个线程的线程本地变量里。scope 返回一个 TaskLocalFuture,值平时存在这个 future 自己的 slot 字段里。每次被 poll 时,通过 scope_inner 把值换进线程本地变量,poll 内层 future,再换回来:
fn scope_inner<F, R>(&'static self, slot: &mut Option<T>, f: F) -> Result<R, ScopeInnerErr>
where
F: FnOnce() -> R,
{
struct Guard<'a, T: 'static> {
local: &'static LocalKey<T>,
slot: &'a mut Option<T>,
}
impl<'a, T: 'static> Drop for Guard<'a, T> {
fn drop(&mut self) {
// ...
self.local.inner.with(|inner| {
let mut ref_mut = inner.borrow_mut();
mem::swap(self.slot, &mut *ref_mut);
});
}
}
self.inner.try_with(|inner| {
inner
.try_borrow_mut()
.map(|mut ref_mut| mem::swap(slot, &mut *ref_mut))
})??;
let guard = Guard { local: self, slot };
let res = f();
drop(guard);
Ok(res)
}
- 用
mem::swap而不是「写入、之后清空」:换进去时,线程本地变量里原来的值(外层scope的值,或者None)被换到slot里暂存,换回来时原样恢复。所以嵌套的scope能正确地遮蔽和还原外层的值。 - 换回放在
Guard的Drop里:内层 future panic 时,栈展开也会把值换回slot,线程本地变量不会残留这个任务的值,被同一线程上的下一个任务看到。 - 换进去用的是
try_borrow_mut。如果正在KEY.with(|v| ...)的闭包里又进入同一个 key 的scope,RefCell已经被借用,就 panic「cannot enter a task-local scope while the task-local storage is borrowed」。
TaskLocalFuture 的 PinnedDrop 也走一次 scope_inner:drop 内层 future 时值仍然可见,内层 future 的析构函数里可以读它。
这个设计的直接后果是:值只在 TaskLocalFuture::poll 的调用栈里可见。spawn 出去的任务由调度器单独 poll,不在这个调用栈里,自然看不到;.await 让出之后,值也会被换回 slot,同一线程上的其他任务看不到。
tokio::task_local! {
static REQ_ID: u32;
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
REQ_ID.scope(42, async {
println!("inside scope: {:?}", REQ_ID.try_get());
let h = tokio::spawn(async { REQ_ID.try_get().is_ok() });
println!("in spawned task: visible = {}", h.await.unwrap());
let v = tokio::spawn(REQ_ID.scope(REQ_ID.get(), async { REQ_ID.get() }));
println!("re-scoped in task: {}", v.await.unwrap());
tokio::task::yield_now().await;
println!("after a yield: {:?}", REQ_ID.try_get());
}).await;
println!("outside scope: visible = {}", REQ_ID.try_get().is_ok());
}
inside scope: Ok(42)
in spawned task: visible = false
re-scoped in task: 42
after a yield: Ok(42)
outside scope: visible = false
要把值带进子任务,只能像第三行那样在 spawn 时显式再包一层 scope。yield_now 之后值还在,是因为下一次 poll 又换了进来。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:
[61, 122, 183, 200]
block_on第一次 pollRunUntil:先 poll 传进来的 future,它一口气spawn_local了 200 个任务(这时CURRENT指向这个LocalSet,任务直接进本地队列),然后在yield_now处返回Pending。接着tick跑了 61 个,队列没空,返回true,RunUntil叫醒自己。- 第二次 poll:future 从
yield_now醒来,看到 61,记下,再次让出;tick又跑 61 个。第三次看到 122,第四次看到 183。 - 第四次 poll 的
tick只剩 17 个,跑完队列空了。第五次 poll 时 future 看到 200,循环结束。
数组里的每一项就是 61 的整数倍,直到队列跑空。如果没有这个上限,第一次 tick 就会把 200 个全跑完,这期间宿主运行时的 IO 驱动和定时器一次也轮不到。
小结
JoinSet基于IdleNotifiedSet:每个JoinHandle用自己 entry 的 waker,任务完成时 entry 从 idle 链表挪到 notified 链表;join_next只从 notified 链表按 FIFO 取,不扫描全部任务。dropJoinSet会 abort 所有任务。LocalSet是一个 future,在宿主任务里每次 poll 最多跑 61 个任务,每 31 个 tick 先看一次远程队列。!Send的安全性靠几层:LocalSet本身!Send;spawn_local只认线程本地的CURRENT;其他线程的唤醒只进加锁的远程队列;只有 owner 线程上换出的LocalNotified能run。LocalRuntime在 1.54.1 是稳定 API,本质是记住了线程 ID 的 current_thread 运行时,可以直接spawn_local。task_local!是线程本地的RefCell<Option<T>>,TaskLocalFuture每次 poll 用mem::swap把值换进去、用 drop 守卫换出来。值不会传给 spawn 的子任务。