Rust in Everything
基于 tokio 1.54.1

任务窃取与空闲

主线位置:任务主线之外,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: 驱动把任务放进了自己的队列
💡TIP
打个比方:外卖站点的骑手

站点有几名骑手(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。

📝NOTE
没有蠢问题

问: 名额满了的 worker 直接去睡,而睡前的最后一次检查只有「最后一个 searcher」才做(见后面的 transition_to_parked)。它睡下的那一刻要是来了新任务,不就漏了吗?

答: 不会。它没能去找活,说明这时 searching 计数不为 0。只要有人在找活,worker_to_notify 就不叫人,新任务由这些 searcher 负责:每个 searcher 最后要么偷到活,要么去睡。偷到活的如果是最后一个 searcher,会叫醒一个 worker 接班;去睡的如果是最后一个 searcher,会把所有队列再查一遍。所以 searching 计数从 1 变成 0 的那一刻,总有人回头看一眼。没去找活的 worker 不承担这份责任,直接睡是安全的。

steal_work

Core::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 觉得队列是空的」两边都错过的情况。下一节就是这个检查。
📝NOTE
冷知识:fetch_add(0) 原来是一次普通的 load

2019 年 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,所以当时是手工验证的。

睡之前:最后一次检查

Core::transition_to_parked:

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往注入队列放了一个任务
3worker_to_notify:看到 searching 为 1,认为有人会找到它,不叫人
4searching 减到 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
}

三种情况:

  1. 自己队列里有任务了。 这个 worker 睡在驱动上,驱动收到 IO 事件,唤醒的任务进了它自己的队列。它自己把自己从 sleepers 里删掉(unpark_worker_by_id),不进 searching:它有活干,不用去偷。
  2. 还在 sleepers 里。 没人叫它,是假唤醒或者定时器超时之类,接着睡。
  3. 不在 sleepers 里了。 被 worker_to_notify 选中、从列表里弹出来了,按约定进入 searching 状态,回到主循环去找活。
📝NOTE
冷知识:第一种情况修过两次

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 按对方睡在哪里分别叫醒。

💬 关联讨论

发起讨论
加载中...