全局注入队列
主线位置:任务主线的「任务进入队列」,任务来自 worker 之外的线程,或者是本地队列放不下的。
flowchart TB outside[非 worker 线程上 spawn 或 wake] -->|push,加锁| inject[注入队列:Mutex 加链表] overflow[本地队列满了] -->|push_batch,129 个一次挂上| inject inject -->|每 global_queue_interval 个 tick,先取 1 个| worker[worker] inject -->|本地队列空了,取一批| worker inject -->|偷不到时最后取 1 个| worker
谁往里放
| 来源 | 入口 | 一次放几个 |
|---|---|---|
在非 worker 线程上 spawn,比如 main 函数体里 | schedule_task → push_remote_task | 1 |
在非 worker 线程上唤醒,比如普通线程里 tx.send() | 同上 | 1 |
在 worker 线程上,但这个线程此刻没有 Core(正在 block_in_place) | 同上 | 1 |
| 本地队列满了 | push_overflow → push_batch | 129 |
| 本地队列满了,而且正有人在偷 | Overflow::push | 1 |
前三种之后,schedule_task 会调 notify_parked_remote 叫醒一个睡着的 worker,详见任务窃取与空闲。
一把锁,一条链表
多线程调度器用的是 InjectQueue,它目前只有一个变体 Locked,包着和 current_thread 共用的 Inject:
pub(crate) struct Inject<T: 'static> {
shared: Shared<T>,
synced: Mutex<Synced>,
}
1.28 的 Inject 自带一把 Mutex。1.29.0 的 #5748 和 #5754 把它拆成两半:不加锁也能读的放 Shared,必须持锁才能碰的放 Synced。多线程调度器把 Synced 放进自己的大锁里,和空闲 worker 的状态共用一把。#5748 写的目的是把锁从 Inject 挪到调度器里,减少加锁次数。
1.54.0 的 #8382 又给注入队列单独配了一把锁。PR 的理由是:当初共用一把锁是为了合并一些临界区,而那些合并的临界区已经随着另一套实验性的多线程调度器在 #7275 里被删掉了。它说这是走向 #7973 的第一步,#7973 提议把注入队列切成最多 8 个分片,每片一把锁。同一版本的 #8443 加了只有一个 Locked 变体的 InjectQueue,给分片的实现留出对称的位置。#7973 本身已经关闭,作者说会拆成更小的改动重新提。
锁里面是一条单向链表(synced.rs):
pub(crate) struct Synced {
/// True if the queue is closed.
pub(super) is_closed: bool,
/// Linked-list head.
pub(super) head: Option<task::RawTask>,
/// Linked-list tail.
pub(super) tail: Option<task::RawTask>,
}
链表节点就是任务本身。任务的内存布局一章讲过,Header 里有一个 queue_next 字段,专门给这条链表用。这是侵入式链表:入队不需要额外分配节点,把 Notified 拆成 RawTask 指针挂上去就行。一个任务同一时刻最多只有一个 Notified,所以一个 queue_next 字段够用。
入队(shared.rs 第 68 行):
pub(crate) unsafe fn push(&self, synced: &mut Synced, task: task::Notified<T>) {
if synced.is_closed {
return;
}
// safety: only mutated with the lock held
let len = unsafe { self.len.unsync_load() };
let task = task.into_raw();
// The next pointer should already be null
debug_assert!(unsafe { task.get_queue_next().is_none() });
if let Some(tail) = synced.tail {
// safety: Holding the Notified for a task guarantees exclusive
// access to the `queue_next` field.
unsafe { tail.set_queue_next(Some(task)) };
} else {
synced.head = Some(task);
}
synced.tail = Some(task);
self.len.store(len + 1, Release);
}
两个细节:
- 长度放在锁外面。
Shared::len是一个AtomicUsize,只在持锁时修改(所以修改时可以用unsync_load读),但任何时候都能不加锁地读。Inject::pop先看一眼is_empty(),空的就直接返回,不去抢锁。注入队列大部分时候是空的,worker 又频繁地检查它,这个快路径省掉了绝大多数加锁。 - 关闭以后静默丢弃。
is_closed为真时push直接返回,Notified在函数结束时被 drop。批量版本push_batch_inner在这种情况下会先释放锁再逐个 drop,注释给了原因:drop 一个任务可能执行用户的Drop代码,而这段代码可能又去调度一个任务、再次获取这把锁。为什么注入队列需要「已关闭」标志,见 OwnedTasks 与关闭。
一次取一批
worker 本地没有任务时,在 Core::next_task 里从注入队列取一批:
let maybe_task = self.next_local_task();
if maybe_task.is_some() {
return maybe_task;
}
if worker.inject().is_empty() {
return None;
}
let cap = usize::min(
// Other threads can only **remove** tasks from the current
// worker's `run_queue`. So, we can be confident that by the
// time we call `run_queue.push_back` below, there will be *at
// least* `cap` available slots in the queue.
// ...
self.run_queue.remaining_slots(),
// We want to make sure that all of the tasks we take end up in
// the first half of the local queue. This ensures that the
// tasks do not get pushed to the inject queue again if overflow
// occurs, as overflow only affects tasks in the second half of
// the local queue.
// ...
self.run_queue.max_capacity() / 2,
);
// The worker is currently idle, pull a batch of work from the
// injection queue. We don't want to pull *all* the work so other
// workers can also get some.
let n = usize::min(
worker.inject().len() / worker.handle.shared.remotes.len() + 1,
cap,
);
// Take at least one task since the first task is returned directly
// and not pushed onto the local queue.
let n = usize::max(1, n);
worker.inject().pop_n(n, |mut tasks| {
// Pop the first task to return immediately
let ret = tasks.next();
// Push the rest of the on the run queue
self.run_queue.push_back(tasks);
ret
})
取多少个,由三个数取最小值决定:
- 平分:注入队列长度除以 worker 数,再加 1。留一些给别的 worker。
- 放得下:本地队列的剩余容量。别的线程只会从这个队列里取走任务,所以现在算出来的空位,等到真正
push_back的时候只会更多。 - 不超过 128:保证取来的任务全部落在本地队列的前一半。溢出时只搬后一半(见本地队列),所以刚从注入队列取来的任务不会马上又被搬回去。
#5705(1.29.0)之前,本地队列空了的 worker 只从注入队列取 1 个任务,然后接着去别处找活。PR 说,运行时外部调度大量任务时,注入队列会因此越积越多。改成一次取一批;但也不一次取完,否则活会分得不均,这就是上面「平分」那一条。PR 的基准里只有 spawn_many_remote_busy 有变化,每轮从 10.14ms 降到 8.14ms。
pop_n 在锁里把链表截下 n 个,返回一个迭代器 Pop。迭代器的第一个任务直接返回去运行,其余的批量放进本地队列。Pop 的 Drop 会把没取完的任务全部取出并 drop,所以长度在 pop_n 一开始就减掉 n 也是对的。
除了这里,注入队列还在另外两处被读:每隔 global_queue_interval 个 tick 先取一个(下一节),以及 steal_work 从别的 worker 那里偷不到时最后取一个(见任务窃取与空闲)。
M2 Max、rustc 1.99.0、tokio 1.54.1,多线程运行时只有 1 个 worker。先用一个任务把 worker 卡住(它阻塞在一个 std 的 channel 上),趁这段时间从 main 线程 rt.spawn n 个任务,它们全进注入队列,然后放开 worker。i == 0 的任务排在注入队列最前面,它被 poll 时读一次注入队列的深度:
for i in 0..n {
let out_tx = out_tx.clone();
rt.spawn(async move {
if i == 0 {
let m = tokio::runtime::Handle::current().metrics();
out_tx.send(m.global_queue_depth()).unwrap();
}
});
}
go_tx.send(()).unwrap();
n 为 300 和 100 时,读到的深度各是多少?答案在本页末尾。
多久看一次注入队列
next_task 的另一个分支:
if self.tick % self.global_queue_interval == 0 {
// Update the global queue interval, if needed
self.tune_global_queue_interval(worker);
worker
.handle
.next_remote_task()
.or_else(|| self.next_local_task())
}
和 current_thread 一样,这是为了公平:如果本地队列一直有任务(两个任务互相唤醒就能做到),注入队列里的任务会永远排不上。区别在于 current_thread 的间隔固定为 31,多线程调度器的间隔会自动调节。
调节的依据在 stats.rs:
/// How to weigh each individual poll time, value is plucked from thin air.
const TASK_POLL_TIME_EWMA_ALPHA: f64 = 0.1;
/// Ideally, we wouldn't go above this, value is plucked from thin air.
const TARGET_GLOBAL_QUEUE_INTERVAL: f64 = Duration::from_micros(200).as_nanos() as f64;
/// Max value for the global queue interval. This is 2x the previous default
const MAX_TASKS_POLLED_PER_GLOBAL_QUEUE_INTERVAL: u32 = 127;
/// This is the previous default
const TARGET_TASKS_POLLED_PER_GLOBAL_QUEUE_INTERVAL: u32 = 61;
#5720(1.29.0)之前,多线程调度器的间隔是固定的任务个数,默认 61,可以手动配置。PR 说,worker 满载而任务 poll 得慢时,注入队列会被饿着,所以改成用指数加权移动平均跟踪任务的平均 poll 耗时,再据此选间隔。PR 还提到,worker 主循环一章里 #5712 用的那个基准(模拟运行时满载时注入任务),有了这个改动以后从约 2 秒降到约 100 毫秒。PR 也说 current_thread 同样能受益,但要等两个调度器的逻辑统一之后再做;到 1.54.1,current_thread 的间隔仍然固定是 31。
目标是:两次查看注入队列之间,大约隔 200 微秒。worker 统计每个任务的平均 poll 耗时,用 200 微秒除以它,得到间隔应该是多少个任务(第 56 行):
pub(crate) fn tuned_global_queue_interval(&self, config: &Config) -> u32 {
// If an interval is explicitly set, don't tune.
if let Some(configured) = config.global_queue_interval {
return configured;
}
// As of Rust 1.45, casts from f64 -> u32 are saturating, which is fine here.
let tasks_per_interval = (TARGET_GLOBAL_QUEUE_INTERVAL / self.task_poll_time_ewma) as u32;
// If we are using self-tuning, we don't want to return less than 2 as that would result in the
// global queue always getting checked first.
tasks_per_interval.clamp(2, MAX_TASKS_POLLED_PER_GLOBAL_QUEUE_INTERVAL)
}
平均耗时是一个指数加权移动平均(EWMA),每处理完一批任务更新一次(end_processing_scheduled_tasks):
// Calculate the mean poll duration for a single task in the batch
let mean_poll_duration = elapsed / num_polls;
// Compute the alpha weighted by the number of tasks polled this batch.
let weighted_alpha = 1.0 - (1.0 - TASK_POLL_TIME_EWMA_ALPHA).powf(num_polls);
// Now compute the new weighted average task poll time.
self.task_poll_time_ewma = weighted_alpha * mean_poll_duration
+ (1.0 - weighted_alpha) * self.task_poll_time_ewma;
weighted_alpha 按这一批的任务数加权:一批 poll 了 k 个任务,效果相当于把单次 α = 0.1 的更新做了 k 次。一批 10 个,新数据占 65%;一批 61 个,新数据占 99.8%。
「一批」指的是从开始处理任务到停下来(本地没任务去偷、做维护、睡觉)之间 poll 的所有任务,耗时用墙上时间算,所以也包括了两次 poll 之间调度器自己的开销。
初始值是 200 微秒 / 61,所以刚启动时间隔就是旧版本的固定值 61。之后按平均耗时变化:
| 任务平均 poll 耗时 | 200µs 除以它 | 实际间隔 |
|---|---|---|
| 1µs | 200 | 127(上限) |
| 200µs / 61,约 3.28µs(初始值) | 61 | 61 |
| 10µs | 20 | 20 |
| 50µs | 4 | 4 |
| 100µs 或更长 | 2 或更小 | 2(下限) |
任务越慢,越要频繁地看注入队列,不然注入队列里的任务要等很久。下限是 2 而不是 1:间隔为 1 时每次都先看注入队列,本地队列反而变成了次要的。
间隔不是每次都改。tune_global_queue_interval 只在新旧值相差超过 2 时才更新,避免来回抖动。调节只在需要看注入队列的那个 tick 做,平时不花这个开销。
如果用 Builder::global_queue_interval(n) 显式设了间隔,就不再自动调节。
动脑筋答案
完整程序(M2 Max、rustc 1.99.0、--release):
use std::sync::mpsc;
fn main() {
for n in [300, 100] {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.build()
.unwrap();
// Occupy the only worker while main fills the inject queue.
let (go_tx, go_rx) = mpsc::channel::<()>();
let (busy_tx, busy_rx) = mpsc::channel::<()>();
rt.spawn(async move {
busy_tx.send(()).unwrap();
go_rx.recv().unwrap();
});
busy_rx.recv().unwrap();
let (out_tx, out_rx) = mpsc::channel();
for i in 0..n {
let out_tx = out_tx.clone();
rt.spawn(async move {
if i == 0 {
let m = tokio::runtime::Handle::current().metrics();
out_tx.send(m.global_queue_depth()).unwrap();
}
});
}
go_tx.send(()).unwrap();
let depth = out_rx.recv().unwrap();
println!("n = {n}: first task sees global queue depth {depth}");
}
}
连跑 3 次,输出都是:
n = 300: first task sees global queue depth 172
n = 100: first task sees global queue depth 0
放开 worker 后,卡住它的任务返回,主循环进入下一个 tick。LIFO 槽和本地队列都是空的,next_task 走到「从注入队列取一批」,三个数取最小:
| n | 平分:n / 1 + 1 | 本地队列空位 | 上限 | 实际取走 | 注入队列剩下 |
|---|---|---|---|---|---|
| 300 | 301 | 256 | 128 | 128 | 172 |
| 100 | 101 | 256 | 128 | 100 | 0 |
- 300:最小的是 128。
pop_n一开始就把长度减掉 128,第一个任务(i == 0)直接运行,其余 127 个进本地队列。注入队列剩 300 − 128 = 172。 - 100:最小的是 101,比队列里的任务还多。
Shared::pop_n里还有一次min(n, len),实际取走 100 个,注入队列空了。
这里只有 1 个 worker,平分那一项等于整个队列再加 1,几乎不起作用;worker 多了,份额会变小。如果这一 tick 正好是 global_queue_interval 的倍数,worker 会先单独取 1 个,n = 300 时读到的就会是 299。3 次运行都没有碰上。
小结
- 注入队列是一把
Mutex加一条侵入式链表,节点就是任务本身,用Header::queue_next串起来,入队不分配。 - 长度是锁外的原子变量,队列为空时取任务不加锁。
- 本地队列空了,worker 一次取一批:注入队列的平均份额加 1,不超过本地队列的空位,也不超过 128,保证这些任务都落在本地队列前一半。
- 每
global_queue_interval个 tick 先看一次注入队列。这个间隔等于 200 微秒除以任务平均 poll 耗时,限制在 2 到 127 之间,初始是 61。