协作式调度预算
主线位置:任务主线的「
Harness::poll」期间。调度器把线程交给一个任务,这一章讲它怎么把线程要回来。
sequenceDiagram
participant W as worker
participant T as 任务
participant L as 叶子 future(如 mpsc recv)
W->>W: coop::budget:预算设为 128
W->>T: poll
loop 每次 recv
T->>L: poll_recv
L->>L: poll_proceed:预算减 1
L-->>T: Ready(消息)
end
T->>L: 第 129 次 poll_recv
L->>L: 预算为 0:waker 交给 defer
L-->>T: Pending
T-->>W: Pending
W->>W: 跑别的任务、查驱动,然后唤醒 defer 里的任务
每次轮到你进园(worker poll 一个任务),检票员先给你 128 张票(coop::budget 把预算设成 128)。园里的设施(tokio 的叶子 future)玩一次收一张(poll_proceed 减 1)。走到一个今天没开的设施,票撕下来又还给你(RestoreOnPending)。票用完了,你被请到休息区(defer),工作人员先去门口收一趟快递(查驱动),回来才放休息区的人重新排队。
比方到这里为止:票不在你兜里,在检票口那台机器上(线程本地的 CONTEXT)。多线程调度器里,紧跟着你从 LIFO 槽进来的人用的是你剩下的那叠票。你自己带进来的玩具(第三方 future、纯计算)不收票,玩多久都没人管。
问题:一直就绪的 future
task/coop/mod.rs 的模块文档用了这样一个例子:
async fn drop_all<I: Stream + Unpin>(mut input: I) {
while let Some(_) = input.next().await {}
}
如果 input 总有数据,next().await 每次都立刻就绪,这个循环就永远不返回 Pending。对调度器来说,poll 一直不返回,worker 被这一个任务占住:
- 同一个 worker 本地队列里的任务全都等着,LIFO 槽里的任务也偷不走。
- worker 每 61 个 tick 才查一次驱动,而 tick 只在两个任务之间增加。这个 poll 不返回,IO 事件和定时器就都没人处理。
- 在 current_thread 上,整个运行时都停住。
Rust 的 future 没有抢占:运行时不能在 poll 中途打断它。tokio 的办法是在它自己的叶子 future(channel、socket、定时器……)里埋检查点,让任务用完一定的配额后自愿返回 Pending。
预算放在线程本地
预算的类型(第 97 行):
pub(crate) struct Budget(Option<u8>);
impl Budget {
// ...
const fn initial() -> Budget {
Budget(Some(128))
}
/// Returns an unconstrained budget. Operations will not be limited.
pub(crate) const fn unconstrained() -> Budget {
Budget(None)
}
fn has_remaining(self) -> bool {
self.0.is_none_or(|budget| budget > 0)
}
}
None 表示不限。它存在线程本地 CONTEXT(见线程本地上下文)的一个 Cell<Budget> 里(context.rs 第 140 行),初始值是不限。
调度器每次 poll 一个任务,都包在 coop::budget 里:
pub(crate) fn budget<R>(f: impl FnOnce() -> R) -> R {
with_budget(Budget::initial(), f)
}
fn with_budget<R>(budget: Budget, f: impl FnOnce() -> R) -> R {
struct ResetGuard {
prev: Budget,
}
impl Drop for ResetGuard {
fn drop(&mut self) {
let _ = context::budget(|cell| {
cell.set(self.prev);
});
}
}
#[allow(unused_variables)]
let maybe_guard = context::budget(|cell| {
let prev = cell.get();
cell.set(budget);
ResetGuard { prev }
});
// The function is called regardless even if the budget is not successfully
// set due to the thread-local being destroyed.
f()
}
把预算设成 128,执行 f,结束时由 ResetGuard 恢复原值(panic 时也会恢复)。调用它的地方:
| 位置 | 包住什么 |
|---|---|
多线程 run_task | 一个任务,加上紧接着跑的 LIFO 槽任务,共用一份 |
current_thread run_task | 一个任务 |
current_thread block_on | 主 future 的每次 poll |
多线程 block_on 用的 CachedParkThread::block_on | 主 future 的每次 poll |
poll_proceed:叶子 future 的检查点
叶子 future 在真正干活之前调 poll_proceed:
pub fn poll_proceed(cx: &mut Context<'_>) -> Poll<RestoreOnPending> {
context::budget(|cell| {
let mut budget = cell.get();
let decrement = budget.decrement();
if decrement.success {
let restore = RestoreOnPending::new(cell.get());
cell.set(budget);
// avoid double counting
if decrement.hit_zero {
inc_budget_forced_yield_count();
}
Poll::Ready(restore)
} else {
register_waker(cx);
Poll::Pending
}
}).unwrap_or(Poll::Ready(RestoreOnPending::new(Budget::unconstrained())))
}
- 还有预算:减 1,返回
Ready,附带一个RestoreOnPending,里面记着减之前的值。 - 预算为 0:调
register_waker,也就是context::defer(cx.waker()),然后返回Pending。 - 不在运行时里(线程本地取不到):不限。
RestoreOnPending 处理「扣了预算却没干成活」的情况。以 mpsc 的 recv 为例:
// Keep track of task budget
let coop = ready!(crate::task::coop::poll_proceed(cx));
// ...
match rx_fields.list.pop(&self.inner.tx) {
Some(Read::Value(value)) => {
self.inner.semaphore.add_permit();
coop.made_progress();
return Ready(Some(value));
}
// ...
}
取到消息才调 made_progress(),这次扣的预算才算数。如果 channel 是空的,recv 返回 Pending,coop 被 drop 时把预算恢复成扣之前的值。一个任务 select! 着十个空 channel,不会因为每个都看了一眼就把预算耗光。
预算用完之后
预算为 0 时返回的 Pending 一路往上传,任务的 poll 返回 Pending,线程回到调度器手里。这时任务没有被任何东西「等着」,它的 waker 在 defer 列表里。
defer 列表什么时候被唤醒?多线程调度器在 park_internal 里,park 返回之后调 self.defer.wake()。而 park 只在两种时候发生:每 61 个 tick 的维护(0 超时),以及本地没活可干。所以被预算打断的任务,要等 worker 至少查过一次驱动才会回到队列。主循环看到 defer 不为空时,即使没活干也只 park_yield,不会真的睡下。
tokio::task::yield_now() 用的是同一个机制,yield_now.rs 第 49 行 的注释讲了理由:
// Don't wake the task immediately, as that would push it right back
// onto the run queue and it could be polled again before other tasks
// or the IO/timer driver get a chance to run. Instead, hand the waker
// to the scheduler, which wakes deferred tasks only after it has run
// out of ready tasks and polled the driver. When polled from outside
// a Tokio runtime, the waker is woken immediately.
context::defer(cx.waker());
Defer::defer 还会去重:同一个任务连续 defer 多次,只记一次(比较 will_wake)。
1.44.0 以前,poll_proceed 在预算为 0 时调的是 cx.waker().wake_by_ref(),不经过 defer。#7185(2025 年 3 月,1.44.0)把它换成了 context::defer(cx.waker()),PR 说明:旧的写法不走 defer,运行时在两次让出之间就没有机会查驱动。
defer 列表本身是更早的 #5223(1.23.0)为 yield_now 加的,那个 PR 说的是同一个问题:yield_now 让出之后,调度器会在查驱动之前就再次 poll 这个任务,驱动可能被饿死。也就是说,从 1.23.0 到 1.44.0 之间,yield_now 会等驱动,预算用完却不会。
多线程调度器里有一个例外:如果当前线程没有 Core(正在 block_in_place),Context::defer 直接 wake_by_ref,因为这时的 defer 列表不一定有人来处理。
哪些 future 会扣预算
在 1.54.1 里调 poll_proceed 的地方:
| 类别 | future |
|---|---|
| 同步原语 | mpsc 的收发、oneshot、broadcast、watch;基于 batch_semaphore 的 Semaphore、Mutex、RwLock |
| IO | Registration::poll_ready,也就是 TcpStream、UdpSocket 等所有基于反应器的读写 |
| 定时器 | Sleep,因此 timeout、interval 也算 |
| 任务 | JoinHandle 的 poll(join.rs 第 332 行) |
| 其他 | 子进程;io::copy、io::copy_buf;io::empty、io::repeat、io::sink、io::duplex 这些在内存里读写的对象 |
timeout 曾经可能永远不超时issue #4291 的程序:timeout 1 秒,里面是一个不停读 io::empty() 的循环,程序一直不退出。修它分了两步,都随 1.15.0 发布:#4300 先让 io::empty 参与预算,循环会让出了;可 Sleep 也扣预算,内层 future 每次都把预算用光的话,轮到 Sleep 时它只会返回 Pending,超时还是检查不到。#4314 于是给 timeout 开了个例外:预算是被内层 future 用光的,就在 with_unconstrained 里 poll Sleep(time/timeout.rs 第 229 行)。改之前的 Timeout 长什么样,见 Sleep 的一生 里的 NaiveTimeout。
select! 比较特殊(宏展开见 select! 与 join!):它展开后的 poll_fn 第一行先调 poll_budget_available(select.rs 第 667 行),预算已经是 0 就直接返回 Pending,不去 poll 任何分支。注释的理由是:会看预算的分支反正要返回 Pending,不看预算的分支(第三方 future)则不会配合,不如整体让出。
select! 会卡死issue #7108 的程序:另一个任务不停往 mpsc::channel(2) 里发,主循环 select! 两个分支,一个是 receiver.recv(),一个是 std::future::ready(())。收到正好 128 条以后,再也收不到了。recv 用完预算后一直返回 Pending,ready(()) 却每次都就绪,select! 每轮都能完成,任务不让出,预算也就不会重置。
修复 #7164(1.44.0)就是上面这行 poll_budget_available。为了让宏能调到预算检查,同一版本的 #7116 先把 coop 模块公开了,PR 写明动机就是让 select! 感知预算。poll_proceed 和 cooperative 到 1.47.0 的 #7405 才公开,理由是只有 consume_budget 的时候,很难让自己实现的 Future 或 Stream 参与预算。
实验:每次 poll 正好 128 条
current_thread 运行时,往一个 unbounded channel 里先放 1000 条消息,然后 spawn 一个任务把它们全读出来。用一个包装 future 记录每次 poll 结束时读到了多少条:
// Records the message count after every poll of the inner future.
struct Probe<F> {
inner: Pin<Box<F>>,
count: Arc<AtomicUsize>,
log: Vec<usize>,
}
impl<F: Future<Output = ()>> Future for Probe<F> {
type Output = Vec<usize>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Vec<usize>> {
let r = self.inner.as_mut().poll(cx);
let n = self.count.load(Relaxed);
self.log.push(n);
match r {
Poll::Ready(()) => Poll::Ready(std::mem::take(&mut self.log)),
Poll::Pending => Poll::Pending,
}
}
}
async fn run(unconstrained: bool) {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
for i in 0..1000 {
tx.send(i).unwrap();
}
drop(tx);
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
let consume = async move {
while rx.recv().await.is_some() {
c.fetch_add(1, Relaxed);
}
};
let log = if unconstrained {
tokio::spawn(tokio::task::unconstrained(Probe { inner: Box::pin(consume), count, log: vec![] })).await
} else {
tokio::spawn(Probe { inner: Box::pin(consume), count, log: vec![] }).await
};
println!("unconstrained = {unconstrained}: count after each poll {:?}", log.unwrap());
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
run(false).await;
run(true).await;
}
unconstrained = false: count after each poll [128, 256, 384, 512, 640, 768, 896, 1000]
unconstrained = true: count after each poll [1000]
消息全都已经在 channel 里,recv 每次都立刻就绪。即便如此,任务每次被 poll 只读 128 条就返回 Pending,1000 条分 8 次读完。最后一次读剩下的 104 条,再收到一次 None,没有用完预算。包上 unconstrained 之后,一次 poll 读完全部 1000 条。
M2 Max、rustc 1.99.0、current_thread 运行时。还是上面的 Probe,任务换成下面这个循环。full 里事先放了 500 条消息;empty 一直是空的,发送端还活着:
while c.load(Relaxed) < 500 {
tokio::select! {
biased;
_ = empty.recv() => {}
Some(_) = full.recv() => { c.fetch_add(1, Relaxed); }
}
}
biased; 让每一轮都先 poll empty,再 poll full。每一轮调两次 recv,那么每次 poll 读到的是 64 条还是 128 条?答案在本页末尾。
关掉和手动扣:unconstrained 与 consume_budget
unconstrained 每次 poll 内层 future 时,用 with_budget(Budget::unconstrained(), ..) 把预算换成不限,poll 完再换回来。适合确实需要一口气处理完的场景,代价是这段时间里同一个 worker 上的其他任务只能等。
反过来,一个纯计算的循环不 await 任何 tokio future,永远碰不到检查点。可以在循环里 await consume_budget:
pub async fn consume_budget() {
let mut status = std::task::Poll::Pending;
std::future::poll_fn(move |cx| {
std::task::ready!(crate::trace::trace_leaf(cx));
if status.is_ready() {
return status;
}
status = crate::task::coop::poll_proceed(cx).map(|restore| {
restore.made_progress();
});
status
})
.await
}
扣 1 并马上确认,没预算就让出。和每次都 yield_now() 相比,它只在 128 次里让出一次。
第三方的 future 不知道 tokio 的预算。poll_proceed 和 cooperative 都是公开 API:前者可以在自己实现的 Stream 里用,后者直接把一个 future 包成会扣预算的版本。
阻塞的代码不受预算约束:spawn_blocking 的任务在调用闭包前调 coop::stop() 把预算设成不限(blocking/task.rs 第 40 行),block_in_place 也一样,退出时再恢复原来的预算。
预算不是抢占
预算只在任务 await tokio 自己的叶子 future 时起作用。下面这些情况它都管不到:
- 纯计算循环,中间没有
.await。 - 在 async 函数里调阻塞的同步代码:
std::thread::sleep、同步文件 IO、等std::sync::Mutex。 - await 的是不认识 tokio 预算的 future,而且它一直就绪。
这些代码会一直占着 worker。它们该去的地方是阻塞线程池。
动脑筋答案
完整程序在下面,Probe 和正文实验里的一样:
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (_keep, mut empty) = tokio::sync::mpsc::unbounded_channel::<u32>();
let (tx, mut full) = tokio::sync::mpsc::unbounded_channel();
for i in 0..500u32 {
tx.send(i).unwrap();
}
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
let a = async move {
while c.load(Relaxed) < 500 {
tokio::select! {
biased;
_ = empty.recv() => {}
Some(_) = full.recv() => { c.fetch_add(1, Relaxed); }
}
}
};
let log = tokio::spawn(Probe { inner: Box::pin(a), count, log: vec![] }).await.unwrap();
println!("count after each poll: {log:?}");
}
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:
count after each poll: [128, 256, 384, 500]
每次 poll 读 128 条,不是 64 条。
empty.recv()也调了poll_proceed,预算确实先减了 1。但 channel 是空的,recv没调made_progress()就返回Pending,RestoreOnPending被 drop 时把这 1 还了回去。full.recv()拿到消息,调了made_progress(),这 1 才真正扣掉。所以一轮循环净扣 1,128 轮把预算用完。- 第 129 轮,
select!开头的poll_budget_available看到预算是 0,两个分支都不 poll,直接返回Pending,任务让出。 - 500 = 3 × 128 + 116,前三次 poll 各读 128 条,第四次读完剩下的 116 条,循环条件不再成立,任务结束。
如果按 0.2.22 以前的规则推算,empty 那一次没拿到东西也要扣,每轮净扣 2,答案就是 64(这一条没有实际跑过)。
小结
- 每次 poll 任务前,调度器把线程本地的预算设成 128;多线程调度器里,一个任务和它后面连跑的 LIFO 任务共用这 128。
- tokio 的叶子 future 每次就绪前调
poll_proceed扣 1;扣不动就把 waker 交给defer,返回Pending。 - 没干成活(返回
Pending)的那次不算数,RestoreOnPending会把预算还回去。 - 被预算打断的任务,要等 worker 查过一次驱动后才重新进队列;
yield_now用的是同一个机制。 unconstrained关掉预算,consume_budget在计算循环里手动扣预算。预算不是抢占,不 await tokio future 的代码不受它约束。