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 槽 → 本地队列 |
| 其他 tick | LIFO 槽 → 本地队列 → 注入队列取一批 |
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 紧接着在同一个线程上运行,比把它排到队尾、等几百个任务之后再跑(那时缓存早就冷了)要快得多。
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();
// ...
}
})
按顺序看:
- 离开 searching 状态。 如果这个 worker 是偷到任务才进来的,它现在不再找活了。如果它是最后一个在找活的 worker,要叫醒另一个接着找(见任务窃取与空闲)。
- 把
Core放进Context,poll 任务。 任务里 spawn、唤醒,都通过Context里的Core进 LIFO 槽或本地队列。 - poll 完,先确认
Core还在。 不在就是被block_in_place拿走了,返回Break。 - LIFO 槽里有任务就接着跑,不回主循环。 这是 LIFO 槽真正生效的地方:刚被唤醒的任务紧跟在唤醒者后面运行,中间不经过 tick、不检查注入队列。
- 整批共用一份预算。
coop::budget包住的是整个循环,不是每个任务。第一个任务用掉的预算,后面的 LIFO 任务接着用;预算用完了,LIFO 槽里的任务挪到本地队列尾部。 - 连续跑满 3 个,关掉 LIFO 槽。
MAX_LIFO_POLLS_PER_TICK是 3。关掉以后,这批任务里新产生的任务直接进本地队列尾部(schedule_local看lifo_enabled)。循环结束时reset_lifo_enabled再打开。
第 6 点防的是注释里说的「乒乓」:A 唤醒 B,B 又唤醒 A,两个任务轮流进 LIFO 槽,可以一直跑下去,本地队列里的任务全都饿着。常量的注释承认 3 是拍脑袋定的:「跑几次 LIFO 槽似乎就足以享受局部性的好处,超过 3 次大概就过头了」。
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 的任务也可能先跑。
还是 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),等槽能被偷了再考虑要不要保留。
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
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_interval | 61,固定 | 查一次驱动,检查关闭信号 |
问: 「每 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 槽;spawnc1,B被挤到本地队列,c1进槽。然后打印parent done,返回。 run_task的循环接着跑槽里的任务:c1(lifo_polls= 1)spawn 的c2进槽;c2(2)spawn 的c3进槽;轮到c3时lifo_polls到了 3,先把lifo_enabled设成假,再跑c3。c3spawn 的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_taskpoll 完一个任务后,紧接着跑 LIFO 槽,整批共用一份预算;连续 3 次后本轮关掉 LIFO 槽,防止两个任务互相唤醒把别人饿死。- 每 61 个 tick 维护一次:用 0 超时查一次驱动,检查关闭信号。
- park 期间
Core留在Context里,驱动唤醒的任务直接进本地队列;醒来后先唤醒defer里的任务,任务多就叫醒别的 worker 来偷。