任务窃取与空闲
主线位置:任务主线之外,worker 没有任务时。这一章回答两个问题:什么时候去偷、偷谁;什么时候睡、谁来叫醒。
stateDiagram-v2 [*] --> Running Running --> Searching: 本地没活,transition_to_searching 成功 Running --> Parked: 本地没活,名额已满,不去偷 Searching --> Running: 偷到任务,transition_from_searching Searching --> Parked: 偷不到,transition_to_parked Parked --> Searching: 被 notify 叫醒 Parked --> Running: 驱动把任务放进了自己的队列
站点有几名骑手(worker),每人车上有自己的一摞单(本地队列),门口还有一块公共白板(注入队列)。手上没单的骑手有两条路:出去转一圈,看同事车上有没有多的单,有就拿走一半(searching,steal_work);或者回休息室睡觉,名字写在门口的名单上(sleepers)。
出去转的人不超过一半(transition_worker_to_searching)。调度员接到新单时(worker_to_notify),只要还有人在外面转,就不去休息室叫人,转悠的人迟早会看到。最后一个在外面转的人拿到了单,自己要去送了,走之前去休息室叫醒一个人接班(transition_worker_from_searching)。如果他什么也没找到,回休息室之前要再看一眼所有人的车和白板(notify_if_work_pending)。
比方到这里为止:休息室里有一个人是守着电话睡的(抢到驱动、睡在 epoll_wait 上的 worker)。电话一响他就醒,醒来时新单可能已经在他自己车上了,他不用出去转,也不算「在外面转的人」(transition_from_parked 的第一种情况)。
Idle:一个原子里的两个计数
空闲 worker 的信息在 multi_thread/idle.rs:
pub(super) struct Idle {
/// Tracks both the number of searching workers and the number of unparked
/// workers.
///
/// Used as a fast-path to avoid acquiring the lock when needed.
state: AtomicUsize,
/// Total number of workers.
num_workers: usize,
}
/// Data synchronized by the scheduler mutex
pub(super) struct Synced {
/// Sleeping workers
sleepers: Vec<usize>,
}
const UNPARK_SHIFT: usize = 16;
const UNPARK_MASK: usize = !SEARCH_MASK;
const SEARCH_MASK: usize = (1 << UNPARK_SHIFT) - 1;
state 的低 16 位是正在找活(searching)的 worker 数,高位是醒着(unparked)的 worker 数。和任务状态一样,放进同一个原子变量,是为了能一次读到两个数的一致快照。sleepers 是睡着的 worker 编号列表,放在 Shared::synced 这把锁里。
去偷之前:最多一半在找
transition_worker_to_searching:
pub(super) fn transition_worker_to_searching(&self) -> bool {
let state = State::load(&self.state, SeqCst);
if 2 * state.num_searching() >= self.num_workers {
return false;
}
// It is possible for this routine to allow more than 50% of the workers
// to search. That is OK. Limiting searchers is only an optimization to
// prevent too much contention.
State::inc_num_searching(&self.state, SeqCst);
true
}
已经有一半 worker 在找活,就不再加入。找活的过程是挨个读别人队列的 head 并 CAS,人越多竞争越激烈,而且活就那么多,多派人去找收益很小。读和加不是一次原子操作,几个 worker 同时检查可能都通过,所以实际可能超过一半。注释说这无所谓:这个限制只是优化。
名额满了,steal_work 直接返回 None,worker 接着走 park。
问: 名额满了的 worker 直接去睡,而睡前的最后一次检查只有「最后一个 searcher」才做(见后面的 transition_to_parked)。它睡下的那一刻要是来了新任务,不就漏了吗?
答: 不会。它没能去找活,说明这时 searching 计数不为 0。只要有人在找活,worker_to_notify 就不叫人,新任务由这些 searcher 负责:每个 searcher 最后要么偷到活,要么去睡。偷到活的如果是最后一个 searcher,会叫醒一个 worker 接班;去睡的如果是最后一个 searcher,会把所有队列再查一遍。所以 searching 计数从 1 变成 0 的那一刻,总有人回头看一眼。没去找活的 worker 不承担这份责任,直接睡是安全的。
steal_work
fn steal_work(&mut self, worker: &Worker) -> Option<Notified> {
if !self.transition_to_searching(worker) {
return None;
}
let num = worker.handle.shared.remotes.len();
// Start from a random worker
let start = self.rand.fastrand_n(num as u32) as usize;
for i in 0..num {
let i = (start + i) % num;
// Don't steal from ourself! We know we don't have work.
if i == worker.index {
continue;
}
let target = &worker.handle.shared.remotes[i];
if let Some(task) = target
.steal
.steal_into(&mut self.run_queue, &mut self.stats)
{
return Some(task);
}
}
// Fallback on checking the global queue
worker.handle.next_remote_task()
}
从一个随机的 worker 开始,挨个试 steal_into,偷到就返回。随机起点让几个同时找活的 worker 分散到不同的目标上,不会一起挤向 0 号。所有 worker 都偷不到,最后看一眼注入队列。
steal_into 一次偷走对方本地队列的一半(向上取整),细节见本地队列。偷不到 LIFO 槽里的任务。
偷到之后:最后一个 searcher 要叫人
偷到任务就进 run_task,第一件事是 transition_from_searching,最终到 Handle::transition_worker_from_searching:
/// Returns `true` if another parked worker was notified, `false` otherwise.
fn transition_worker_from_searching(&self) -> bool {
if self.shared.idle.transition_worker_from_searching() {
// We are the final searching worker. Because work was found, we
// need to notify another worker.
self.notify_parked_local()
} else {
false
}
}
searching 计数减 1,如果减之前是 1(自己是最后一个在找活的),就叫醒一个睡着的 worker。
道理是:这个 worker 刚偷到了活,说明系统里很可能还有更多活。它自己马上要去跑任务,没人接着找了,所以叫醒一个来接班。新醒来的 worker 处在 searching 状态,偷到活以后如果也是最后一个 searcher,再叫醒下一个。worker 就这样一个接一个地醒来,直到有人偷不到活、回去睡觉为止。
这样做是为了避免惊群:来了一批任务,不是一下子叫醒所有睡着的 worker(大部分醒来发现没活,又去睡,白白浪费了上下文切换),而是每次只叫醒一个,醒来的 worker 确认还有活,再叫下一个。
叫醒谁:worker_to_notify
不管是 spawn 时的 notify_parked_remote、schedule_local 里的 notify_parked_local,还是上面的接班,都通过 Idle::worker_to_notify 选人:
pub(super) fn worker_to_notify(&self, shared: &Shared) -> Option<usize> {
// If at least one worker is spinning, work being notified will
// eventually be found. A searching thread will find **some** work and
// notify another worker, eventually leading to our work being found.
//
// For this to happen, this load must happen before the thread
// transitioning `num_searching` to zero. Acquire / Release does not
// provide sufficient guarantees, so this load is done with `SeqCst` and
// will pair with the `fetch_sub(1)` when transitioning out of
// searching.
if !self.notify_should_wakeup() {
return None;
}
// Acquire the lock
let mut lock = shared.synced.lock();
// Check again, now that the lock is acquired
if !self.notify_should_wakeup() {
return None;
}
// A worker should be woken up, atomically increment the number of
// searching workers as well as the number of unparked workers.
State::unpark_one(&self.state, 1);
// Get the worker to unpark
let ret = lock.idle.sleepers.pop();
debug_assert!(ret.is_some());
ret
}
fn notify_should_wakeup(&self) -> bool {
// This must be a `SeqCst` RMW rather than a load: it is what makes
// the caller's preceding inject-queue push visible to a parking
// worker's subsequent unlocked queue-emptiness check.
let state = State(self.state.fetch_add(0, SeqCst));
state.num_searching() == 0 && state.num_unparked() < self.num_workers
}
- 有人在找活,就不叫。 正在找活的 worker 迟早会找到这个新任务;就算它找到的是别的任务,按上一节的接班规则,它也会叫醒下一个。
- 没人睡着,也不叫。 所有 worker 都醒着,新任务总会被某个 worker 的主循环取到。
- 先无锁检查,再加锁确认。 大部分时候有人在找活或者没人睡着,无锁检查就能返回,不用碰锁。
- 被叫醒的 worker 直接算作 searching。
unpark_one(&self.state, 1)同时把 unparked 和 searching 各加 1。这一步在锁里、在真正叫醒之前做,所以紧接着来的下一次通知会看到「有人在找活」,不会再叫醒第二个。 fetch_add(0, SeqCst)是一次「什么也不改」的读-改-写。注释说明这里必须是 RMW 而不是普通的 load:它和 worker 睡前的最后一次检查配对,保证不会出现「通知方觉得有人醒着、worker 觉得队列是空的」两边都错过的情况。下一节就是这个检查。
fetch_add(0) 原来是一次普通的 load2019 年 11 月,0.2.0 还没发布,issue #1768 报告了一个会卡住的程序:只开 1 个线程的线程池运行时,反复 block_on 一个 spawn 了小任务的 future,跑一阵子之后就不再有输出。报告者判断是丢了一次唤醒:调试器里看到一个线程停在 thread::park,其余线程在等 IO 完成端口(Windows)。
修复是 #1788(随 0.2.0 发布),只改了一行:notify_should_wakeup 里的 State::load(&self.state, SeqCst) 换成 State(self.state.fetch_add(0, SeqCst))。PR 说明:SeqCst 的 load 不足以保证刚放进队列的任务对正在找活的 worker 可见,fetch_add(0) 能建立所需的同步。PR 还写明,要写出能抓住这个 bug 的单元测试得先修 loom,所以当时是手工验证的。
睡之前:最后一次检查
fn transition_to_parked(&mut self, worker: &Worker) -> bool {
// Workers should not park if they have work to do or are about to be traced
if self.has_tasks() || self.is_traced {
return false;
}
// When the final worker transitions **out** of searching to parked, it
// must check all the queues one last time in case work materialized
// between the last work scan and transitioning out of searching.
let is_last_searcher = worker.handle.shared.idle.transition_worker_to_parked(
&worker.handle.shared,
worker.index,
self.is_searching,
);
// The worker is no longer searching. Setting this is the local cache
// only.
self.is_searching = false;
if is_last_searcher {
worker.handle.notify_if_work_pending();
}
true
}
transition_worker_to_parked 在锁里把自己加进 sleepers,unparked 减 1,原来在 searching 的话 searching 也减 1。
最后一个 searcher 去睡之前,要再检查一遍所有队列(notify_if_work_pending):任何一个 worker 的本地队列或注入队列里有任务,就叫醒一个 worker。
没有这一步会丢任务。设想下面的顺序:
| 时刻 | 最后一个 searcher | 另一个线程 |
|---|---|---|
| 1 | 扫了一遍所有队列,都是空的 | |
| 2 | 往注入队列放了一个任务 | |
| 3 | worker_to_notify:看到 searching 为 1,认为有人会找到它,不叫人 | |
| 4 | searching 减到 0,去睡 |
这时任务在注入队列里,所有 worker 都睡着,没人会醒来。最后一次检查发生在时刻 4 之后,一定能看到时刻 2 放进去的任务。SeqCst 保证两边的顺序:要么通知方看到 searching 已经是 0,自己去叫人;要么最后一个 searcher 的检查看到了这个任务。
醒来之后
Context::park 在一个循环里睡,每次醒来调 transition_from_parked 判断是不是真的该醒:
fn transition_from_parked(&mut self, worker: &Worker) -> bool {
// If a task is in the lifo slot/run queue, then we must unpark regardless of
// being notified. Same when woken to be traced: a dump unparks worker
// threads without going through `Idle`, so the worker does it here.
if self.has_tasks() || self.is_traced {
// When a worker wakes, it should only transition to the "searching"
// state when the wake originates from another worker *or* a new task
// is pushed. We do *not* want the worker to transition to "searching"
// when it wakes when the I/O driver receives new events.
self.is_searching = !worker
.handle
.shared
.idle
.unpark_worker_by_id(&worker.handle.shared, worker.index);
return true;
}
if worker
.handle
.shared
.idle
.is_parked(&worker.handle.shared, worker.index)
{
return false;
}
// When unparked, the worker is in the searching state.
self.is_searching = true;
true
}
三种情况:
- 自己队列里有任务了。 这个 worker 睡在驱动上,驱动收到 IO 事件,唤醒的任务进了它自己的队列。它自己把自己从
sleepers里删掉(unpark_worker_by_id),不进 searching:它有活干,不用去偷。 - 还在
sleepers里。 没人叫它,是假唤醒或者定时器超时之类,接着睡。 - 不在
sleepers里了。 被worker_to_notify选中、从列表里弹出来了,按约定进入 searching 状态,回到主循环去找活。
1.16.0 以前,第一个分支写的是先 unpark_worker_by_id,再无条件 self.is_searching = true。#4383(2022 年 1 月,1.16.0)指出这是个 bug:调度器负载不满、一个 worker 就够用时,IO 驱动收到事件叫醒这个 worker,它被错标为 searching,找到 IO 事件带来的活,又作为「最后一个 searcher」退出 searching,于是叫醒另一个线程,那个线程醒来没活,又睡回去。修复后 is_searching 取 unpark_worker_by_id 返回值的反面:自己把自己从 sleepers 里删掉的,不算 searching。PR 说这让空唤醒最多减少了一半。同一个 PR 也解释了「最后一个 searcher 找到活就叫醒下一个」的来由:它是用来补一个竞争的。最后一个 searcher 正要退出 searching,另一个线程要叫人,看到还有 searcher 就跳过了,searcher 退出时又没看到这个新任务。多叫醒一个 worker,竞争真发生时它能找到那个任务,没发生它就回去睡。
条件里的 || self.is_traced 是 2026 年 9 月的 #8372(1.54.0)加的。请求任务转储时,notify_all 绕过 Idle 直接叫醒所有 worker,被叫醒的 worker 在 Idle 看来还在睡。原来的代码在没任务时走第二个分支,发现自己还在 sleepers 里,于是不做逻辑上的 unpark。转储完它再去睡,transition_worker_to_parked 把它第二次放进 sleepers,num_unparked 又减了一次,减到下溢。从此 notify_should_wakeup 永远是 false,从运行时外面提交的任务再也没人去跑。
Parker:谁去等驱动
worker 怎么「睡」,由 multi_thread/park.rs 决定。每个 worker 有自己的 Parker,状态是四个值之一:
const EMPTY: usize = 0;
const PARKED_CONDVAR: usize = 1;
const PARKED_DRIVER: usize = 2;
const NOTIFIED: usize = 3;
驱动只有一份,所有 Parker 共享,放在 TryLock 里。Inner::park:
fn park(&self, handle: &driver::Handle) -> HadDriver {
// If we were previously notified then we consume this notification and
// return quickly.
if self
.state
.compare_exchange(NOTIFIED, EMPTY, SeqCst, SeqCst)
.is_ok()
{
return HadDriver::No;
}
if let Some(mut driver) = self.shared.driver.try_lock() {
self.park_driver(&mut driver, handle, None)
} else {
self.park_condvar(None);
HadDriver::No
}
}
抢到驱动的 worker 睡在 epoll_wait(或 kqueue、IOCP)上,状态是 PARKED_DRIVER;抢不到的睡在自己的条件变量上,状态是 PARKED_CONDVAR。叫醒时(Inner::unpark)把状态换成 NOTIFIED,再按旧状态分别处理:
match self.state.swap(NOTIFIED, SeqCst) {
EMPTY => {} // no one was waiting
NOTIFIED => {} // already unparked
PARKED_CONDVAR => self.unpark_condvar(),
PARKED_DRIVER => driver.unpark(),
actual => panic!("inconsistent state in unpark; actual = {actual}"),
}
叫醒一个睡在驱动上的 worker,用的是驱动自己的唤醒机制:开了 IO 时,IO 驱动注册过一个 mio Waker,触发它,epoll_wait 就会返回;没开 IO 时驱动退化成 ParkThread,直接唤醒线程。如果对方还没睡(EMPTY),状态留在 NOTIFIED,它下次 park 时第一步就会看到,直接返回,不会错过这次通知。
实验:64 个任务落到 4 个线程
4 个 worker,在一个任务里 spawn 64 个任务,每个忙等 5 毫秒,统计每个线程跑了几个:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let seen = Arc::new(Mutex::new(HashMap::new()));
let s = seen.clone();
tokio::spawn(async move {
let mut hs = Vec::new();
for _ in 0..64 {
let s = s.clone();
hs.push(tokio::spawn(async move {
let t = Instant::now();
while t.elapsed() < Duration::from_millis(5) {}
let name = format!("{:?}", std::thread::current().id());
*s.lock().unwrap().entry(name).or_insert(0) += 1;
}));
}
for h in hs {
h.await.unwrap();
}
})
.await
.unwrap();
let mut v: Vec<_> = seen.lock().unwrap().clone().into_iter().collect();
v.sort();
println!("{} threads ran the 64 tasks: {:?}", v.len(), v);
}
4 threads ran the 64 tasks: [("ThreadId(2)", 16), ("ThreadId(3)", 16), ("ThreadId(4)", 16), ("ThreadId(5)", 16)]
64 个任务都是在同一个 worker 上 spawn 的,一开始全在它的 LIFO 槽和本地队列里。第二个任务被挤进本地队列时,schedule_local 叫醒了一个睡着的 worker;它偷到一半,作为最后一个 searcher 又叫醒下一个;如此接力,4 个线程很快都有活干。每个任务耗时相同,4 个线程同时在跑,所以正好各 16 个,连续跑了 5 次都是这个结果。任务耗时不均时,分布不会这么整齐,但不会有线程闲着而别的线程排着队。
小结
- 空闲状态是一个原子变量里的两个计数:searching 和 unparked;睡着的 worker 编号放在锁里的
sleepers列表。 - 同时找活的 worker 不超过一半(不严格);找活从随机的 worker 开始,每次偷一半,都偷不到再看注入队列。
- 有人在找活就不叫醒别人;找到活的最后一个 searcher 负责叫醒下一个。worker 一个接一个地醒,避免惊群。
- 最后一个 searcher 睡前要再检查一遍所有队列,配合
SeqCst,保证新任务不会在所有 worker 都睡着时被漏掉。 - 一个 worker 抢到驱动睡在
epoll_wait上,其他的睡在各自的条件变量上;unpark按对方睡在哪里分别叫醒。