Rust in Everything
基于 tokio 1.54.1

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>),这也是状态机一章里说「被遗忘的任务不会被释放,要等运行时关闭」的原因。

💡TIP
打个比方:酒店的住客登记簿

运行队列像前台排的队:只有按了铃(被唤醒)的客人才在里面。大多数客人在房间里睡觉(等 IO),前台的队伍里看不到他们。OwnedTasks 是住客登记簿,入住时登记(bind),退房时划掉(remove)。打烊清场(运行时关闭)时,员工照着登记簿挨个敲门(close_and_shutdown_all),而不是只看前台的队伍。

登记簿按房号分成好几本(分片),每本有自己的锁(每个分片一个 Mutex),房号(任务 ID)决定登记在哪一本。打烊时先在前台挂上「停止入住」的牌子(closed),前台登记新客人时,拿着那一本登记簿再看一眼牌子。

比方到这里为止:真酒店清场是请客人自己离开,这里是员工直接替客人把房间清空(在 worker 线程上 drop future),客人没有机会再做一件事。

侵入式、分片的链表

链表的节点指针放在任务自己的 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 代替取模。注释也解释了为什么设上限:分片越多,链表节点在内存里越分散,构建时间也越长。

📝NOTE
冷知识:1.35.0 之前只有一把锁

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。为什么要在锁里查,下面讲关闭时再说。
📝NOTE
冷知识:OwnedTasks 和「spawn 时就挂上」都是 1.9.0 才有的

2021 年 7 月的 #3909 引入了 OwnedTasks,PR 说明是:它装着运行时上的全部任务,摘除任务时不再需要传消息。同月的 #3955 改了绑定的时机:任务在 spawn 时就绑到运行时上,之前是第一次 poll 时才延迟绑定。同一个 PR 让 OwnedTasks 可以关闭,运行时关闭时不再接受新任务绑定,也就是上面 bind_inner 里那段检查;Notified 的含义也从这时起变成「只是一个通知」,持有它的人不负责清理任务。两个 PR 都随 1.9.0 发布。

摘下: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 它们就是扣引用计数。

📝NOTE
没有蠢问题

问: 第 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 再逐个分片加锁清空,两者对同一把锁排队,就不会有「查的时候还开着、清的时候已经清过这个分片」的缝隙。

❗IMPORTANT
动脑筋:运行时 drop 之后再 spawn,会打印什么?

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」时漏掉任务或造成引用环。

💬 关联讨论

发起讨论
加载中...