Rust in Everything
基于 tokio 1.54.1

worker 主循环

主线位置:任务主线的「worker 取出任务 → Harness::poll」。前面几章讲了队列,这一章看 worker 按什么顺序用它们。

flowchart TB
  top[tick 加 1] --> maint{tick 是 61 的倍数?}
  maint -->|是| doit[maintenance:不睡,查一次驱动;看是否已关闭]
  maint -->|否| next
  doit --> next[next_task]
  next -->|取到| run[run_task:poll 任务,再连跑 LIFO 槽]
  run --> top
  next -->|取不到| steal[steal_work]
  steal -->|偷到| run
  steal -->|偷不到| park[park:睡到被叫醒]
  park --> top

Context::run

Context::run,去掉统计和 taskdump 相关的几行:

fn run(&self, mut core: Box<Core>) {
    // Reset `lifo_enabled` here in case the core was previously stolen from
    // a task that had the LIFO slot disabled.
    self.reset_lifo_enabled(&mut core);

    // ...

    while !core.is_shutdown {
        // ...

        // Increment the tick
        core.tick();

        // Run maintenance, if needed
        core = self.maintenance(core);

        // First, check work available to the current worker.
        if let Some(task) = core.next_task(&self.worker) {
            core = match self.run_task(task, core) {
                ControlFlow::Continue(core) => core,
                ControlFlow::Break(()) => return,
            };
            continue;
        }

        // ...

        // There is no more **local** work to process, try to steal work
        // from other workers.
        if let Some(task) = core.steal_work(&self.worker) {
            // Found work, switch back to processing
            core.stats.start_processing_scheduled_tasks();
            core = match self.run_task(task, core) {
                ControlFlow::Continue(core) => core,
                ControlFlow::Break(()) => return,
            };
        } else {
            // Wait for work
            core = if !self.defer.is_empty() {
                self.park_yield(core)
            } else {
                self.park(core)
            };
            core.stats.start_processing_scheduled_tasks();
        }
    }

    // ...

    core.pre_shutdown(&self.worker);
    // Signal shutdown
    self.worker.handle.shutdown_core(core);
}

core 是按值传的 Box<Core>,每一步都把它交出去再接回来。run_task 返回 Break 表示 Core 在 poll 期间被拿走了(block_in_place),这个线程不再是 worker,直接返回。

循环条件是 !core.is_shutdown。运行时关闭时,maintenance 发现注入队列已关闭,设置 is_shutdown,循环结束后进入关闭流程(见 OwnedTasks 与关闭)。

next_task:取任务的顺序

next_task 在前两章已经分段读过,合起来是:

条件顺序
tick % global_queue_interval == 0注入队列取 1 个 → LIFO 槽 → 本地队列
其他 tickLIFO 槽 → 本地队列 → 注入队列取一批

next_local_task 就一行:

fn next_local_task(&mut self) -> Option<Notified> {
    self.lifo_slot.take().or_else(|| self.run_queue.pop())
}

LIFO 槽总是排在本地队列前面。但后面会看到,LIFO 槽里的任务多数等不到 next_task,在 run_task 里就被跑掉了。

新任务进 LIFO 槽:schedule_local

在 worker 线程上 spawn 或唤醒一个任务,走 schedule_local:

fn schedule_local(&self, core: &mut Core, task: Notified, is_yield: bool) {
    core.stats.inc_local_schedule_count();

    // Spawning from the worker thread. If scheduling a "yield" then the
    // task must always be pushed to the back of the queue, enabling other
    // tasks to be executed. If **not** a yield, then there is more
    // flexibility and the task may go to the front of the queue.
    let should_notify = if is_yield || !core.lifo_enabled {
        core.run_queue
            .push_back_or_overflow(task, self, &mut core.stats);
        true
    } else {
        // Push to the LIFO slot
        let prev = core.lifo_slot.take();
        let ret = prev.is_some();

        if let Some(prev) = prev {
            core.run_queue
                .push_back_or_overflow(prev, self, &mut core.stats);
        }

        core.lifo_slot = Some(task);

        ret
    };

    // Only notify if not currently parked. If `park` is `None`, then the
    // scheduling is from a resource driver. As notifications often come in
    // batches, the notification is delayed until the park is complete.
    if should_notify && core.park.is_some() {
        self.notify_parked_local();
    }
}
  • 新任务放进 LIFO 槽,槽里原来的任务挪到本地队列尾部。
  • 只有当本地队列里多了任务时才去叫醒别的 worker(should_notify)。LIFO 槽里的任务不能被偷,为它叫醒别人没用。
  • core.park 为 None 说明这个 worker 正在驱动上处理 IO 事件(park_internal 把 Parker 取出来了),这时被唤醒的任务往往一来一大批,先不通知,等 park 结束后统一处理。

为什么要有 LIFO 槽?典型场景是消息传递:任务 A 往 channel 里发一条消息,唤醒了等在另一头的任务 B。B 要读的数据刚被 A 写过,还在这个核的缓存里。让 B 紧接着在同一个线程上运行,比把它排到队尾、等几百个任务之后再跑(那时缓存早就冷了)要快得多。

💡TIP
打个比方:手边只夹得下一张的加急单

worker 是一个办事员。普通待办放在篮子里(本地队列),先来先办。他手边还有一个只夹得下一张单子的夹子(LIFO 槽):办事时新冒出来的活直接夹上去,夹子上原来那张挪到篮子最后(push_back_or_overflow(prev))。手上这件办完,先办夹子上的,再看篮子。

同事闲下来会从篮子里拿走一半(窃取),但夹子在他手边,同事够不着。所以只有篮子里多了单子,他才喊同事来帮忙(should_notify)。

比方到这里为止:真办事员会一直先办夹子上的。tokio 不会,连着办了 3 张夹子上的单子,这一轮就不再用夹子,新冒出来的活直接进篮子(下一节的 lifo_enabled = false)。这里的「一轮」也不是一段时间,而是 run_task 里的一次循环。

run_task:poll,然后连跑 LIFO 槽

run_task 的主体:

let task = self.worker.handle.shared.owned.assert_owner(task);

// Make sure the worker is not in the **searching** state. This enables
// another idle worker to try to steal work.
let notified_parked_worker = core.transition_from_searching(&self.worker);

// ...

// Make the core available to the runtime context
*self.core.borrow_mut() = Some(core);

// Run the task
coop::budget(|| {
    // ...
    task.run();
    // ...

    let mut lifo_polls = 0;

    // As long as there is budget remaining and a task exists in the
    // `lifo_slot`, then keep running.
    loop {
        // Check if we still have the core. If not, the core was stolen
        // by another worker.
        let mut core = match self.core.borrow_mut().take() {
            Some(core) => core,
            None => {
                // ...
                return ControlFlow::Break(());
            }
        };

        // Check for a task in the LIFO slot
        let task = match core.lifo_slot.take() {
            Some(task) => task,
            None => {
                self.reset_lifo_enabled(&mut core);
                core.stats.end_poll();
                return ControlFlow::Continue(core);
            }
        };

        if !coop::has_budget_remaining() {
            core.stats.end_poll();

            // Not enough budget left to run the LIFO task, push it to
            // the back of the queue and return.
            core.run_queue.push_back_or_overflow(
                task,
                &*self.worker.handle,
                &mut core.stats,
            );
            // ...
            return ControlFlow::Continue(core);
        }

        // Track that we are about to run a task from the LIFO slot.
        lifo_polls += 1;
        // ...

        // Disable the LIFO slot if we reach our limit
        //
        // In ping-ping style workloads where task A notifies task B,
        // which notifies task A again, continuously prioritizing the
        // LIFO slot can cause starvation as these two tasks will
        // repeatedly schedule the other. To mitigate this, we limit the
        // number of times the LIFO slot is prioritized.
        if lifo_polls >= MAX_LIFO_POLLS_PER_TICK {
            core.lifo_enabled = false;
            // ...
        }

        let task = self.worker.handle.shared.owned.assert_owner(task);

        // ...

        *self.core.borrow_mut() = Some(core);

        // ...
        task.run();
        // ...
    }
})

按顺序看:

  1. 离开 searching 状态。 如果这个 worker 是偷到任务才进来的,它现在不再找活了。如果它是最后一个在找活的 worker,要叫醒另一个接着找(见任务窃取与空闲)。
  2. 把 Core 放进 Context,poll 任务。 任务里 spawn、唤醒,都通过 Context 里的 Core 进 LIFO 槽或本地队列。
  3. poll 完,先确认 Core 还在。 不在就是被 block_in_place 拿走了,返回 Break。
  4. LIFO 槽里有任务就接着跑,不回主循环。 这是 LIFO 槽真正生效的地方:刚被唤醒的任务紧跟在唤醒者后面运行,中间不经过 tick、不检查注入队列。
  5. 整批共用一份预算。 coop::budget 包住的是整个循环,不是每个任务。第一个任务用掉的预算,后面的 LIFO 任务接着用;预算用完了,LIFO 槽里的任务挪到本地队列尾部。
  6. 连续跑满 3 个,关掉 LIFO 槽。 MAX_LIFO_POLLS_PER_TICK 是 3。关掉以后,这批任务里新产生的任务直接进本地队列尾部(schedule_local 看 lifo_enabled)。循环结束时 reset_lifo_enabled 再打开。

第 6 点防的是注释里说的「乒乓」:A 唤醒 B,B 又唤醒 A,两个任务轮流进 LIFO 槽,可以一直跑下去,本地队列里的任务全都饿着。常量的注释承认 3 是拍脑袋定的:「跑几次 LIFO 槽似乎就足以享受局部性的好处,超过 3 次大概就过头了」。

📝NOTE
冷知识:上限 3 之前,tokio 先试过用预算来管

1.29.0 以前,这个循环只看预算还剩没剩。poll 一个任务不一定消耗预算,没用到 tokio 的叶子 future 就不消耗,所以两个这样的任务互相唤醒,可以一直占着 LIFO 槽。这个问题在 1.29.0 里改了两次。先是 #5686:每跑一次 LIFO 槽,额外扣 1 个单位预算。一周多以后的 #5712 说这还不够:每批任务有 128 个单位预算,对那些没什么异步操作、但每次 poll 要 5 到 10 微秒的任务来说太多了。#5712 删掉了额外扣预算那一行,换成现在的 MAX_LIFO_POLLS_PER_TICK,并且说理想的做法是让调度器按负载自适应,这个上限只是权宜之计。PR 里一个模拟运行时满载时注入任务的基准,从 68 秒降到 2 秒。

实验:C、A、B

一个 worker,在任务里依次 spawn A、B、C:

use std::time::Duration;

fn main() {
  for disable in [false, true] {
    let mut b = tokio::runtime::Builder::new_multi_thread();
    b.worker_threads(1).enable_all();
    #[cfg(tokio_unstable)]
    if disable {
      b.disable_lifo_slot();
    }
    #[cfg(not(tokio_unstable))]
    if disable {
      continue;
    }
    let rt = b.build().unwrap();
    println!("disable_lifo_slot = {disable}");
    rt.block_on(async {
      tokio::spawn(async {
        for name in ["A", "B", "C"] {
          tokio::spawn(async move { println!("  {name}") });
        }
        println!("  parent done");
      });
      tokio::time::sleep(Duration::from_millis(100)).await;
    });
  }
}

disable_lifo_slot 是不稳定 API,用 RUSTFLAGS="--cfg tokio_unstable" 构建,输出:

disable_lifo_slot = false
  parent done
  C
  A
  B
disable_lifo_slot = true
  parent done
  A
  B
  C

默认情况下:spawn A 时 A 进 LIFO 槽;spawn B 时 A 被挤到本地队列,B 进槽;spawn C 时 B 被挤到队列,C 进槽。父任务结束后,run_task 的循环先跑 LIFO 槽里的 C,然后回到主循环,按先进先出跑队列里的 A、B。关掉 LIFO 槽后,三个任务都按顺序进本地队列。

所以不要依赖 spawn 的顺序。在多线程运行时里,就算只有一个 worker,后 spawn 的任务也可能先跑。

❗IMPORTANT
动脑筋:B 排在第几个?

还是 1 个 worker,不用 tokio_unstable。父任务先 spawn 一个打印 B 的任务,再 spawn 一条链:c1 运行时 spawn c2,c2 spawn c3,一直到 c8。

fn chain(i: u32, max: u32) {
  println!("  c{i}");
  if i < max {
    tokio::spawn(async move { chain(i + 1, max) });
  }
}

// 父任务里:
tokio::spawn(async { println!("  B") });
tokio::spawn(async { chain(1, 8) });
println!("  parent done");

parent done 之后,B 打印在 c 几的后面?答案在本页末尾。

LIFO 槽的代价

LIFO 槽不在本地队列里,Steal 句柄看不到它,其他 worker 偷不走。如果槽里的任务恰好要跑很久,或者当前任务一直不返回,槽里的任务只能等。disable_lifo_slot 的文档说得很直白:这个选项是 LIFO 槽不能被偷的权宜之计(issue #4941),等槽能被偷了再考虑要不要保留。

📝NOTE
冷知识:LIFO 槽在 1.51.0 被做成可偷的,一个月后又撤了

issue #4941 是 2022 年 8 月开的,disable_lifo_slot 也是那时作为临时办法加的(#4936,1.21.0)。2026 年 4 月合并的 #7431(1.51.0)把槽做成了可偷的:Core 里的 Option<Notified> 换成可以原子交换的 AtomicNotified,挪进本地队列的 Inner,窃取者偷完一批以后把槽里的任务也拿走。PR 说的场景正是本节这个:一个任务唤醒另一个任务后自己长时间占着 CPU 不让出,被唤醒的任务就一直跑不了。PR 还指出,disable_lifo_slot 是不稳定选项,用户多半是在生产环境里撞上延迟尖刺之后才知道有它。

issue #8065 报告了副作用:一个处理时间只有微秒级的高 QPS 服务,从 1.50 升到 1.52.1 后整体 CPU 多了约 8.5%,worker park 次数变成 2.6 倍,近一半被叫醒的 worker 什么活也没找到又睡回去;打开 disable_lifo_slot 也不管用。原因就在上面 schedule_local 的 should_notify:后来重新提交这个改动的 #8120 解释说,以前任务放进空的 LIFO 槽不会叫醒睡着的 worker,#7431 让槽可偷以后,这种情况也开始叫人,运行时开销因此变大。#8100 把 #7431 整个撤掉,进了 1.51.2 和 1.52.2。

所以 1.54.1 里还是本页这段代码:槽在 Core 里,偷不走;放进空槽时 should_notify 为假,不叫人。#4941 重新打开了,#8120 截至 2026 年 10 月还没合并,它打算把「放进槽里时叫不叫人」做成可选的。

block_in_place 交出 Core 之前,也会先把 LIFO 槽里的任务挪进本地队列,免得它跟着一个暂时没人跑的 Core 一起被搁置(见阻塞线程池)。

maintenance:每 61 个 tick

Context::maintenance:

fn maintenance(&self, mut core: Box<Core>) -> Box<Core> {
    if core.tick % self.worker.handle.shared.config.event_interval == 0 {
        super::counters::inc_num_maintenance();

        core.stats.end_processing_scheduled_tasks();

        // Call `park` with a 0 timeout. This enables the I/O driver, timer, ...
        // to run without actually putting the thread to sleep.
        core = self.park_yield(core);

        // Run regularly scheduled maintenance
        core.maintenance(&self.worker);

        core.stats.start_processing_scheduled_tasks();
    }

    core
}

event_interval 默认是 61(Builder::new_multi_thread)。一个一直有活干的 worker 永远不会去 park,所以要定期用 0 超时 park 一次:抢到驱动就收一次 IO 事件、处理到期的定时器,抢不到就立刻返回(park_timeout 对 0 超时不会去等条件变量)。

Core::maintenance 做三件小事:提交统计数据;检查注入队列是否已关闭,关闭了就设置 is_shutdown;检查是否有人请求 taskdump。

所以 tick 上挂着两个周期:

周期默认做什么
global_queue_interval自动调节,初始 61,范围 2 到 127先看一次注入队列
event_interval61,固定查一次驱动,检查关闭信号
📝NOTE
没有蠢问题

问: 「每 61 个 tick」是不是就是「每 poll 61 个任务」?

答: 不是。tick 只在 Context::run 的主循环里加(第 588 行),一圈加 1。一圈里 run_task 先 poll 取到的任务,再连跑最多 3 个 LIFO 槽里的任务,这些都不加 tick。所以一圈最多 poll 4 个任务,61 个 tick 最多对应 244 次 poll。反过来,取不到任务、最后去睡的那一圈一个任务也没 poll,tick 照样加 1。上面动脑筋的程序里,父任务和 c1、c2、c3 就是在同一个 tick 里跑完的。

park_internal:睡前和醒后

不管是维护时的 park_yield 还是没活干时的 park,最后都进 park_internal:

fn park_internal(&self, mut core: Box<Core>, duration: Option<Duration>) -> Box<Core> {
    // ...

    // Take the parker out of core
    let mut park = core.park.take().expect("park missing");
    // Store `core` in context
    *self.core.borrow_mut() = Some(core);

    // ...

    // Park thread
    let had_driver = if let Some(timeout) = duration {
        park.park_timeout(&self.worker.handle.driver, timeout)
    } else {
        park.park(&self.worker.handle.driver)
    };

    self.defer.wake();

    // ...

    // Remove `core` from context
    core = self.core.borrow_mut().take().expect("core missing");

    // Place `park` back in `core`
    core.park = Some(park);
    core.had_driver = had_driver;

    if core.should_notify_others() {
        self.worker.handle.notify_parked_local();
    }
    core
}
  • 睡的时候 Core 也放在 Context 里。 驱动收到 IO 事件后会在当前线程上调 waker,任务经 schedule_task 进入这个 worker 的 LIFO 槽和本地队列。这时 core.park 是 None,schedule_local 不急着通知别人。
  • 醒来后唤醒 defer 里的任务。 yield_now 和预算耗尽的任务就是在这里被唤醒的:它们等到 worker 查过一次驱动之后才回到队列(见协作式调度预算)。
  • 醒来后看要不要叫人帮忙。 should_notify_others:自己不在 searching 状态,并且 LIFO 槽加本地队列里多于 1 个任务,就叫醒一个睡着的 worker 来偷。一次 IO 事件可能唤醒几十个任务,全落在这一个 worker 上,靠窃取把它们分出去。

动脑筋答案

完整程序(M2 Max、rustc 1.99.0、--release):

fn chain(i: u32, max: u32) {
  println!("  c{i}");
  if i < max {
    tokio::spawn(async move { chain(i + 1, max) });
  }
}

fn main() {
  let rt = tokio::runtime::Builder::new_multi_thread()
    .worker_threads(1)
    .enable_all()
    .build()
    .unwrap();
  rt.block_on(async {
    tokio::spawn(async {
      tokio::spawn(async { println!("  B") });
      tokio::spawn(async { chain(1, 8) });
      println!("  parent done");
    })
    .await
    .unwrap();
    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  });
}

连跑 5 次,输出都是:

  parent done
  c1
  c2
  c3
  B
  c4
  c5
  c6
  c7
  c8

B 在 c3 后面。一步一步看:

  • 父任务是在 block_on 所在的线程上 spawn 的,进注入队列,worker 取出它,在 run_task 里 poll。
  • 父任务 spawn B,B 进 LIFO 槽;spawn c1,B 被挤到本地队列,c1 进槽。然后打印 parent done,返回。
  • run_task 的循环接着跑槽里的任务:c1(lifo_polls = 1)spawn 的 c2 进槽;c2(2)spawn 的 c3 进槽;轮到 c3 时 lifo_polls 到了 3,先把 lifo_enabled 设成假,再跑 c3。c3 spawn 的 c4 只能进本地队列,排在 B 后面。
  • 槽空了,reset_lifo_enabled,回到主循环。本地队列先进先出:先 B,再 c4。
  • c4 由主循环取出,它的 run_task 重新计数:c5、c6、c7 从槽里连跑,c8 进本地队列,下一圈再跑。

最后两组(c4 到 c7、c8)在输出里看不出边界,但第一组看得出来:B 早在 c1 之前就进了本地队列,却要等 c1、c2、c3 三个 LIFO 任务跑完才轮到它。

小结

  • 主循环:tick 加 1;必要时维护;按 LIFO 槽、本地队列、注入队列的顺序取任务;取不到就偷,偷不到就睡。
  • 在 worker 上 spawn 或唤醒的任务进 LIFO 槽,槽里原来的任务挪到本地队列。只有本地队列多了任务才通知别的 worker,因为 LIFO 槽不能被偷。
  • run_task poll 完一个任务后,紧接着跑 LIFO 槽,整批共用一份预算;连续 3 次后本轮关掉 LIFO 槽,防止两个任务互相唤醒把别人饿死。
  • 每 61 个 tick 维护一次:用 0 超时查一次驱动,检查关闭信号。
  • park 期间 Core 留在 Context 里,驱动唤醒的任务直接进本地队列;醒来后先唤醒 defer 里的任务,任务多就叫醒别的 worker 来偷。

💬 关联讨论

发起讨论
加载中...