Rust in Everything
基于 tokio 1.54.1

协作式调度预算

主线位置:任务主线的「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 里的任务
💡TIP
打个比方:游乐园的一叠票

每次轮到你进园(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,不会因为每个都看了一眼就把预算耗光。

📝NOTE
冷知识:最早,没干成活也要扣预算

预算是 2020 年 3 月的 #2160(0.2.14)引入的。那时检查点只要被调用就扣 1,不管后面有没有拿到东西。两个月后的 #2549(0.2.22)改成只有前进了才算数:调用方没有确认 made progress,预算就恢复成原值。

PR 给的理由:一个任务 poll 了 128 个没法前进的资源,第 129 个能前进的资源就轮不到被 poll;如果前 128 个都在等第 129 个,这就成了死锁。PR 也写明了代价:返回 Pending 的资源从此不占预算,但它们确实占着执行器的时间,一个反复 poll 大量空资源的任务,会比以前跑得更久才让出。

预算用完之后

预算为 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)。

📝NOTE
冷知识:预算用完,原来是立刻叫醒自己

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
IORegistration::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 这些在内存里读写的对象
📝NOTE
冷知识: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)则不会配合,不如整体让出。

📝NOTE
冷知识:1.44.0 以前,这样写的 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 条。

❗IMPORTANT
动脑筋:一个空 channel 加一个满 channel,每次 poll 读到几条?

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 的代码不受它约束。

💬 关联讨论

发起讨论
加载中...