OwnedTasks 与关闭
主线位置:任务主线的两端。
spawn时任务被挂上链表(OwnedTasks::bind),完成时被摘下来(release);运行时关闭时,链表上剩下的任务被逐个取消。
flowchart TD drop["drop(Runtime)"] --> close["Handle::shutdown → close:关注入队列,叫醒所有 worker"] close --> m["各 worker 在 maintenance 里发现注入队列已关"] m --> pre["pre_shutdown:OwnedTasks::close_and_shutdown_all"] pre --> each["对链表上每个任务:Harness::shutdown<br/>抢 RUNNING,drop future,complete"] each --> last["shutdown_core:最后一个 worker<br/>清空所有本地队列和注入队列"] last --> wait["BlockingPool drop:等所有线程退出"]
为什么需要一张存活任务表
运行队列里只有被唤醒了的任务。一个正在等 socket 可读的任务,它的引用在 IO 驱动的 waker 里,不在任何队列上。运行时关闭时,这些任务的 future 必须被 drop(释放连接、跑析构函数),可调度器手里没有它们。
所以每个运行时有一个 OwnedTasks,挂着全部存活任务:
pub(crate) struct OwnedTasks<S: 'static> {
list: ShardedList<Task<S>>,
pub(crate) id: NonZeroU64,
closed: AtomicBool,
}
链表持有任务的一个引用(就是 new_task 返回的那个 Task<S>),这也是状态机一章里说「被遗忘的任务不会被释放,要等运行时关闭」的原因。
运行队列像前台排的队:只有按了铃(被唤醒)的客人才在里面。大多数客人在房间里睡觉(等 IO),前台的队伍里看不到他们。OwnedTasks 是住客登记簿,入住时登记(bind),退房时划掉(remove)。打烊清场(运行时关闭)时,员工照着登记簿挨个敲门(close_and_shutdown_all),而不是只看前台的队伍。
登记簿按房号分成好几本(分片),每本有自己的锁(每个分片一个 Mutex),房号(任务 ID)决定登记在哪一本。打烊时先在前台挂上「停止入住」的牌子(closed),前台登记新客人时,拿着那一本登记簿再看一眼牌子。
侵入式、分片的链表
链表的节点指针放在任务自己的 Trailer 里(owned: linked_list::Pointers<Header>),挂上、摘下都不分配内存。这是侵入式链表:代价是大量 unsafe,好处是 spawn 只需要那一次 Box 分配。
spawn 和完成在所有 worker 上都会发生,都要改这张表。一把锁会成为热点,所以它是分片的(util/sharded_list.rs 第 15 行):
pub(crate) struct ShardedList<L: ShardedListItem> {
lists: Box<[Mutex<LinkedList<L>>]>,
added: MetricAtomicU64,
count: MetricAtomicUsize,
shard_mask: usize,
}
任务进哪个分片,由任务 ID 决定(task/mod.rs 第 627 行):
unsafe impl<S> sharded_list::ShardedListItem for Task<S> {
unsafe fn get_shard_id(target: NonNull<Self::Target>) -> usize {
// SAFETY: The caller guarantees that `target` points at a valid task.
let task_id = unsafe { Header::get_id(target) };
task_id.0.get() as usize
}
}
ID 是全局计数器 fetch_add 出来的,连续 spawn 的任务落在相邻的分片上,冲突自然分散。分片数在 gen_sharded_list_size 里定:
fn gen_sharded_list_size(num_cores: usize) -> usize {
const MAX_SHARDED_LIST_SIZE: usize = 1 << 16;
usize::min(MAX_SHARDED_LIST_SIZE, num_cores.next_power_of_two() * 4)
}
worker 数向上取 2 的幂再乘 4,8 个 worker 就是 32 个分片。取 2 的幂是为了用 id & shard_mask 代替取模。注释也解释了为什么设上限:分片越多,链表节点在内存里越分散,构建时间也越长。
2023 年 12 月以前,OwnedTasks 里是一个 Mutex 包着整条链表和一个 closed: bool,每次 spawn、每次任务完成都抢这一把锁。#6001(1.35.0)的说明是:多线程并发 tokio::spawn 时这把锁竞争很激烈,办法是按任务 ID 把任务放进不同的链表,每条链表用自己的 Mutex。PR 里贴的基准测试,spawn_tasks_parallel 从约 1.00µs 降到约 820ns;作者的估计是多线程 spawn 快 15% 到 20%,单线程 spawn 不受影响。
gen_sharded_list_size 的公式、closed 从锁里的 bool 改成单独的 AtomicBool、pre_shutdown 从随机分片开始清,都是这个 PR 一起加的。
挂上:bind
spawn 路径上的 OwnedTasks::bind 调 new_task 造出三个引用,然后进 bind_inner:
unsafe fn bind_inner(&self, task: Task<S>, notified: Notified<S>) -> Option<Notified<S>>
where
S: Schedule,
{
unsafe {
// safety: We just created the task, so we have exclusive access
// to the field.
task.header().set_owner_id(self.id);
}
let shard = self.list.lock_shard(&task);
// Check the closed flag in the lock for ensuring all that tasks
// will shut down after the OwnedTasks has been closed.
if self.closed.load(Ordering::Acquire) {
drop(shard);
task.shutdown();
return None;
}
shard.push(task);
Some(notified)
}
两件事:
- 记下
owner_id。每个OwnedTasks有一个全局唯一的 ID,写进任务的 Header。之后 worker 拿到Notified要 poll 之前,assert_owner会核对它(debug 构建下断言);remove时也会assert_eq!。这防的是把一个运行时的任务交给另一个运行时去 poll 或摘除,那会破坏两边的链表。 - 在分片锁里检查
closed。运行时已经开始关闭,新任务不挂上去,直接shutdown(drop future),返回None,调用方就不会把Notified放进队列。JoinHandle照样返回,.await它会得到JoinError::Cancelled。为什么要在锁里查,下面讲关闭时再说。
摘下:remove
任务完成时,complete 里的 release 调调度器的 Schedule::release,多线程运行时的实现就是 self.shared.owned.remove(task):
pub(crate) fn remove(&self, task: &Task<S>) -> Option<Task<S>> {
// If the task's owner ID is `None` then it is not part of any list and
// doesn't need removing.
let task_id = task.header().get_owner_id()?;
assert_eq!(task_id, self.id);
// safety: We just checked that the provided task is not in some other
// linked list.
unsafe { self.list.remove(task.header_ptr()) }
}
(list.rs 第 202 行)返回的 Task 就是链表那个引用,调用方 mem::forget 它,在 transition_to_terminal 里和 poll 的引用一起扣掉。任务已经被关闭流程弹出时,这里返回 None。
关闭:从 drop(Runtime) 开始
worker.rs 开头的模块文档把多线程运行时的关闭分成几步,对着代码走一遍。
1. 关门。 Runtime 的 Drop 调到 multi_thread::Handle::shutdown,再到 close:关闭注入队列,叫醒所有 worker。
pub(super) fn close(&self) {
if self.shared.inject.close() {
self.notify_all();
}
}
2. worker 发现关门。 worker 每隔一段时间跑一次 maintenance,看到注入队列关了,就把 core.is_shutdown 置为 true,主循环 while !core.is_shutdown 随之退出。
3. 并行取消。 每个 worker 退出主循环后调 pre_shutdown:
fn pre_shutdown(&mut self, worker: &Worker) {
// Start from a random inner list
let start = self
.rand
.fastrand_n(worker.handle.shared.owned.get_shard_size() as u32);
// Signal to all tasks to shut down.
worker
.handle
.shared
.owned
.close_and_shutdown_all(start as usize);
// ...
}
close_and_shutdown_all 先把 closed 置为 true,然后从 start 开始绕所有分片一圈,每个分片 pop_back 到空,对弹出的任务调 shutdown。每个 worker 从随机的分片开始,几个 worker 同时清,彼此不在同一把锁上排队。
Task::shutdown 进到 Harness::shutdown:
pub(super) fn shutdown(self) {
if !self.state().transition_to_shutdown() {
// The task is concurrently running. No further work needed.
self.drop_reference();
return;
}
// By transitioning the lifecycle to `Running`, we have permission to
// drop the future.
cancel_task(self.core());
self.complete();
}
任务空闲,就抢下 RUNNING,在当前 worker 线程上 drop future,存一个 JoinError::Cancelled,走完成流程。任务正被另一个 worker poll(那个 worker 还没走到 maintenance),只置 CANCELLED 就走,由 poll 的线程在这次 poll 返回后取消它。
4. 收尾。 每个 worker 调 shutdown_core 交出自己的 Core。最后一个交的 worker 负责收尾:断言链表已空,清空每个 Core 的本地队列,再清空注入队列。
fn shutdown_core(&self, core: Box<Core>) {
let mut cores = self.shared.shutdown_cores.lock();
cores.push(core);
if cores.len() != self.shared.remotes.len() {
return;
}
debug_assert!(self.shared.owned.is_empty());
for mut core in cores.drain(..) {
core.shutdown(self);
}
// Drain the injection queue
//
// We already shut down every task, so we can simply drop the tasks.
while let Some(task) = self.next_remote_task() {
drop(task);
}
}
此时所有任务都已完成,队列里剩下的只是作废的 Notified,drop 它们就是扣引用计数。
问: 第 3 步里,正被另一个 worker poll 的任务只置了 CANCELLED 就走,它还没完成。第 4 步的 debug_assert!(self.shared.owned.is_empty()) 凭什么成立?
答: 链表空不等于任务都完成了。close_and_shutdown_all 对每个任务都是先 pop_back 摘下来,再调 shutdown。不管能不能当场取消,任务都已经不在链表上,链表那个引用由 shutdown 负责扣掉(任务正在运行时就是那句 drop_reference)。所以第 3 步一结束链表就是空的。那个还在被 poll 的任务,由 poll 它的 worker 在这次 poll 返回后取消;完成时 remove 在链表里找不到它,返回 None,release 只扣 1。
「所有任务都已完成」靠的是另一件事:第 4 步要等所有 worker 都交出 Core 才往下走。正在 poll 的 worker 手里拿着自己的 Core,它得先跑完这次 poll、回到主循环、走完自己的 pre_shutdown,才会交出来。
5. 等线程退出。 Runtime 的最后一个字段是 blocking_pool,它的 Drop 调 shutdown(None),一直等到所有线程退出。worker 线程本身也是从阻塞池里起的,所以 drop(rt) 返回时,所有任务都已经被取消、析构函数都已经跑完。想设上限就用 Runtime::shutdown_timeout。
一个小实验:三个永远挂起的任务,各自持有一个带 Drop 的守卫。
struct Guard(u32);
impl Drop for Guard {
fn drop(&mut self) {
println!("guard {} dropped on {:?}", self.0, std::thread::current().name());
}
}
fn main() {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).build().unwrap();
for i in 0..3 {
rt.spawn(async move {
let _g = Guard(i);
std::future::pending::<()>().await;
});
}
rt.block_on(tokio::task::yield_now());
println!("alive before drop: {}", rt.metrics().num_alive_tasks());
drop(rt);
println!("runtime dropped");
}
一次运行的输出:
alive before drop: 3
guard 0 dropped on Some("tokio-rt-worker")
guard 2 dropped on Some("tokio-rt-worker")
guard 1 dropped on Some("tokio-rt-worker")
runtime dropped
析构在 worker 线程上执行,顺序不是 spawn 的顺序(分片加随机起点),drop(rt) 等它们全部跑完才返回。
为什么两个地方都要有「已关闭」标志
注入队列有 closed,OwnedTasks 也有 closed。模块文档专门解释了为什么缺一不可,场景是关闭进行中有人 spawn:
- 只有注入队列有标志:spawn 方可能在关闭全部做完之后,还把任务成功挂上链表。没有 worker 会再来清它,任务永远不会被取消。
- 只有
OwnedTasks有标志:spawn 方在第 3 步清空链表之前挂上了链表,接着把Notified推进注入队列,而这一推可能发生在第 4 步清空注入队列之后。这个Notified再也不会被取出,它持有的引用让任务永远不被释放,模块文档称之为引用环,结果是内存泄漏。
两个标志都有,spawn 只剩两种结局:看到 OwnedTasks 已关,就当场取消任务、不入队;看到它还开着,任务就是在它那个分片被第 3 步清空之前挂上的链表,第 3 步一定会把它取消。(模块文档说 OwnedTasks 在第 1 步就关了,但 1.54.1 的 close 只关注入队列,OwnedTasks 的 closed 要到第 3 步 close_and_shutdown_all 的第一行才置上。结论不受影响。)bind_inner 在分片锁里查 closed,close_and_shutdown_all 先置 closed 再逐个分片加锁清空,两者对同一把锁排队,就不会有「查的时候还开着、清的时候已经清过这个分片」的缝隙。
M2 Max、rustc 1.99.0。先 drop 多线程运行时,再用留下来的 Handle spawn 一个捕获了 Guard 的任务,最后借另一个运行时 .await 它的 JoinHandle:
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.build()
.unwrap();
let handle = rt.handle().clone();
drop(rt);
let g = Guard; // Drop 时打印当前线程名
let jh = handle.spawn(async move {
let _g = g;
println!("polled");
42
});
println!("spawned");
let rt2 = tokio::runtime::Builder::new_current_thread().build().unwrap();
println!("{:?}", rt2.block_on(jh));
会不会打印 polled?Guard 在哪个线程、在 spawned 之前还是之后析构?答案在本页末尾。
current_thread 运行时的关闭简单得多:只有一个线程,直接 close_and_shutdown_all(0)。spawn_blocking 的任务用的是 UnownedTask,不在 OwnedTasks 里,由阻塞池自己管理。
动脑筋答案
Guard 的定义:
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("guard dropped on {:?}", std::thread::current().name());
}
}
M2 Max、rustc 1.99.0、--release,连跑 4 次,每次都是:
guard dropped on Some("main")
spawned
Err(JoinError::Cancelled(Id(3)))
- 没有
polled:drop(rt)时各 worker 的pre_shutdown已经调过close_and_shutdown_all,closed是 true。handle.spawn走到bind_inner,在分片锁里看到它,直接task.shutdown(),返回None,任务从没进过队列。 Guard在 main 线程、spawned之前析构:async move块一创建,g就被移进了 future 的状态里,不必等第一次 poll。shutdown在调用spawn的线程上抢下RUNNING,cancel_task当场 drop future,Guard随之析构,这一切都发生在handle.spawn返回之前。Err(JoinError::Cancelled(..)):cancel_task存进去的就是取消错误。handle.spawn本身不 panic,照样返回JoinHandle,正文bind一节说的「.await它会得到JoinError::Cancelled」就是这一行。Id(3)是任务 ID,数字因程序而异。
小结
- 运行队列只有被唤醒的任务;
OwnedTasks挂着全部存活任务,关闭时才能找到那些正在等事件的任务。它持有每个任务的一个引用。 - 链表是侵入式的(指针在 Trailer 里,不分配),按任务 ID 分片(worker 数取 2 的幂乘 4,上限 65536),减少 spawn 和完成时的锁竞争。
bind写入owner_id并在分片锁里检查closed;remove在任务完成时摘下它,交出链表的那个引用。drop(Runtime):关注入队列 → worker 在maintenance发现 → 各自从随机分片开始close_and_shutdown_all→ 最后一个 worker 清空所有队列 → 阻塞池等线程退出。future 的析构在 worker 线程上执行。- 注入队列和
OwnedTasks各有一个关闭标志,缺一个就会在「关闭中 spawn」时漏掉任务或造成引用环。