Rust in Everything
基于 tokio 1.54.1

在 async 里阻塞:一个任务卡住整个 worker

主线位置:任务主线的「worker 取出任务 → Harness::poll」。poll 不返回,worker 循环就停在这一步,后面的任务都轮不到。

这个问题没有报错。表现是:别的任务延迟几百毫秒、定时器不准、心跳超时、刚 spawn 的任务迟迟不开始。

flowchart TB
  w[worker 循环] --> p[poll 任务 A]
  p --> blk["A 里 thread::sleep 或长计算"]
  blk -->|不返回| stuck[worker 停在这里]
  stuck --> q1["本地队列:可以被偷"]
  stuck --> q2["LIFO 槽:偷不走"]
  stuck --> q3["current_thread:没人可偷"]

触发它的代码

一个「心跳」任务每 10ms 醒一次,记下两次醒来之间的最大间隔。50ms 后另 spawn 一个任务,里面 std::thread::sleep(300ms),代表任何阻塞调用:同步文件 IO、阻塞的数据库驱动、reqwest::blocking、长时间的计算。

use std::time::{Duration, Instant};
use tokio::runtime::{Builder, Runtime};

// A task that wants to wake every 10ms; returns the largest gap it saw.
async fn ticker(total: Duration) -> Duration {
  let start = Instant::now();
  let mut last = Instant::now();
  let mut worst = Duration::ZERO;
  while start.elapsed() < total {
    tokio::time::sleep(Duration::from_millis(10)).await;
    worst = worst.max(last.elapsed());
    last = Instant::now();
  }
  worst
}

fn blocker() {
  std::thread::sleep(Duration::from_millis(300)); // stands for any blocking call
}

fn independent(rt: Runtime, label: &str) {
  let worst = rt.block_on(async {
    let t = tokio::spawn(ticker(Duration::from_millis(500)));
    tokio::time::sleep(Duration::from_millis(50)).await;
    tokio::spawn(async { blocker() });
    t.await.unwrap()
  });
  println!("{label:<40} worst ticker gap = {worst:?}");
}

fn main() {
  independent(Builder::new_current_thread().enable_all().build().unwrap(), "current_thread");
  for n in [1, 2] {
    let rt = Builder::new_multi_thread().worker_threads(n).enable_all().build().unwrap();
    independent(rt, &format!("multi_thread, {n} worker(s)"));
  }

  // The LIFO trap: the blocker spawns a task, then blocks.
  let rt = Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
  rt.block_on(async {
    tokio::spawn(async {
      let spawned = Instant::now();
      let child = tokio::spawn(async move { spawned.elapsed() });
      blocker();
      println!("{:<40} child first polled after {:?}", "multi_thread, 2 workers, spawn then block", child.await.unwrap());
    })
    .await
    .unwrap();
  });
}

连跑三次,每次结果一致,这里是其中一次:

current_thread                           worst ticker gap = 309.941916ms
multi_thread, 1 worker(s)                worst ticker gap = 301.353ms
multi_thread, 2 worker(s)                worst ticker gap = 13.279167ms
multi_thread, 2 workers, spawn then block child first polled after 310.077375ms

13ms 左右是这台机器上这个测量的底数:10ms 的 sleep,加上定时器精度和唤醒的开销(定时器的取整见 Sleep 的一生)。

  • current_thread、multi_thread 1 个 worker:只有一个线程跑任务,它被占住 300ms,心跳也停 300ms。
  • multi_thread 2 个 worker:心跳在另一个 worker 上照常运行。看起来没事,但只是因为两个任务恰好不在一起;worker 数等于同时阻塞的任务数时,就退化成上面一行。
  • 2 个 worker,先 spawn 再阻塞:另一个 worker 完全空闲,新任务还是等了 310ms。

源码里在哪

没有哪一行代码「产生」这个问题,它来自协作式调度本身。tokio 在 lib.rs 的文档里写明:

Tokio is able to concurrently run many tasks on a few threads by repeatedly swapping the currently running task on each thread. However, this kind of swapping can only happen at .await points, so code that spends a long time without reaching an .await will prevent other tasks from running.

协作式预算也帮不上忙:预算只在叶子 future 被 poll 时检查,不 .await 的代码根本碰不到它(见协作式调度预算)。

📝NOTE
没有蠢问题

问: 一次 poll 卡了 300ms,tokio 为什么连个警告都不给?

答: worker 调用 poll 以后,要等它返回才拿得回这个线程;Rust 没有办法从外面打断一个正在执行的函数。1.54.1 里也没有专门盯着 poll 时长、超时就报警的机制,所以它不报错。能做的是测量,两类工具都要 --cfg tokio_unstable:Builder 的 on_before_task_poll 和 on_after_task_poll 在每次 poll 前后调你的函数,自己记时间,超过阈值就打日志;运行时指标里有 worker_mean_poll_time,Builder::enable_metrics_poll_time_histogram() 还能打开每个 worker 的 poll 耗时直方图。

最后一行实验是 LIFO 槽的副作用。worker 上的任务 spawn 新任务时走 schedule_local:

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 槽时 should_notify 是 false,不叫醒别的 worker;即使别的 worker 醒着,LIFO 槽也不在可窃取的队列里(见 worker 主循环的「LIFO 槽的代价」)。设计上假设当前任务很快就会返回,然后由同一个 worker 接着跑槽里的任务。当前任务阻塞了,这个假设就不成立。

用 tokio_unstable 的 Builder::disable_lifo_slot() 对照一下(编译时加 RUSTFLAGS="--cfg tokio_unstable"):

for disable in [false, true] {
  let mut b = tokio::runtime::Builder::new_multi_thread();
  b.worker_threads(2).enable_all();
  if disable {
    b.disable_lifo_slot();
  }
  let rt = b.build().unwrap();
  rt.block_on(async move {
    tokio::spawn(async move {
      let spawned = Instant::now();
      let child = tokio::spawn(async move { spawned.elapsed() });
      std::thread::sleep(Duration::from_millis(300));
      println!("disable_lifo_slot = {disable:<5} child first polled after {:?}", child.await.unwrap());
    })
    .await
    .unwrap();
  });
}
disable_lifo_slot = false child first polled after 310.028167ms
disable_lifo_slot = true  child first polled after 6.834µs

关掉 LIFO 槽,新任务进本地队列并叫醒另一个 worker,几微秒就被偷走执行。

📝NOTE
冷知识:「让 LIFO 槽可以被偷」的 issue 从 2022 年开到现在

disable_lifo_slot 是 #4936(1.21.0,2022 年 8 月)加的。说明里写:LIFO 槽能改善一些用法,尤其是任务之间传消息,「但这个 LIFO 槽目前不能被偷」;「最终 LIFO 槽会变得可偷。在那之前,作为权宜之计,这个 unstable 选项让用户在关掉它能改善整体性能时把它关掉」。对应的 issue #4941「make the LIFO slot in the multi-threaded scheduler stealable」到 2026 年 10 月仍是 open。1.54.1 里 disable_lifo_slot 的文档还是这么写:这个选项是 LIFO 槽不可偷的变通办法,槽变得可偷以后再看还要不要它。

怎么修

还是心跳实验,这次阻塞的一方换成约 300ms 的纯 CPU 计算 cpu_job()(30 万步,每步 1000 次乘加),在 current_thread 上比较几种改法;最后一行是多线程 2 个 worker 上「先 spawn 再阻塞」的情况改用 block_in_place。ticker 同上(这次持续 600ms),每种改法都是「spawn 心跳,50ms 后 spawn 计算任务,等心跳结束」,这里只列计算任务的写法:

// ~300ms of pure CPU work, split into steps so we can yield between them
fn step(x: u64) -> u64 {
  let mut h = x;
  for _ in 0..1_000 {
    h = h.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
  }
  h
}

const STEPS: u64 = 300_000;

fn cpu_job() -> u64 {
  (0..STEPS).fold(0, |acc, i| acc ^ step(i))
}

// 0. inline: the loop runs on the worker
tokio::spawn(async { cpu_job() });

// 1. yield_now every 1000 steps
tokio::spawn(async {
  let mut acc = 0;
  for i in 0..STEPS {
    acc ^= step(i);
    if i % 1000 == 0 {
      tokio::task::yield_now().await;
    }
  }
  acc
});

// 2. spawn_blocking
tokio::spawn(async { tokio::task::spawn_blocking(cpu_job).await.unwrap() });

// 3. rayon + oneshot
tokio::spawn(async {
  let (tx, rx) = tokio::sync::oneshot::channel();
  rayon::spawn(move || {
    let _ = tx.send(cpu_job());
  });
  rx.await.unwrap()
});

// 4. block_in_place (multi_thread, 2 workers, child sits in the LIFO slot)
let child = tokio::spawn(async move { spawned.elapsed() });
tokio::task::block_in_place(cpu_job);
cpu_job alone takes 336.336583ms (result 7c4c40d855acad00)
current_thread, inline loop          worst ticker gap = 352.78625ms
current_thread, yield_now / 1000     worst ticker gap = 13.028125ms
current_thread, spawn_blocking       worst ticker gap = 13.686166ms
current_thread, rayon + oneshot      worst ticker gap = 13.237416ms
multi_thread 2w, block_in_place      child first polled after 60.417µs

四种改法都把心跳的最大间隔压回了底数。block_in_place 交出 worker 前会把 LIFO 槽里的任务挪进本地队列(阻塞线程池讲过这一步),所以子任务 60µs 就开始了。

改法适合代价
spawn_blocking阻塞 IO、调用同步库、偶尔一次的计算闭包要 Send + 'static;阻塞池默认最多 512 个线程,大量 CPU 任务会互相抢核
block_in_place阻塞代码要借用局部变量;运行时是 multi_threadcurrent_thread 上会 panic;同一任务里 join! 的其他分支在这期间也停住
rayon + oneshot大量 CPU 密集计算,希望线程数固定为核数多一个依赖;rayon 的线程不在 tokio 运行时里,不能直接用 tokio 的 IO 和定时器
yield_now自己写的长循环,能找到切分点计算还在 worker 上,只是让出了间隙,总吞吐不变;每隔多少步让一次要自己定

tokio 的文档对 CPU 密集任务的建议也是用 rayon 这类专用线程池,结果用 oneshot 送回来;另建一个专门跑 CPU 任务的 tokio 运行时也可以,前提是那个运行时上不跑 IO 任务。

几个容易忽略的阻塞点:

  • std::sync::Mutex::lock 在竞争严重、临界区很长时也会阻塞 worker。临界区短的话没问题,见 tokio::sync::Mutex 还是 std::sync::Mutex。
  • std::fs、std::net、同步的 DNS 解析(ToSocketAddrs)。tokio 的 tokio::fs 和 lookup_host 内部就是 spawn_blocking。
  • 日志写到慢的同步输出、大 JSON 的序列化和反序列化、压缩、密码哈希。

小结

  • 任务只在 .await 处让出线程。不 .await 的代码占着 worker,同一 worker 上的任务全都等它,协作式预算管不到。
  • current_thread 或只有一个 worker 时,一次 300ms 的阻塞让所有任务延迟 300ms。worker 多时问题被掩盖,但没有消失。
  • 阻塞前刚 spawn 的任务在 LIFO 槽里,不会叫醒别的 worker,也不能被偷:即使有空闲 worker,它也要等阻塞结束。
  • 阻塞 IO 用 spawn_blocking;要借用时在 multi_thread 上用 block_in_place;大量 CPU 计算交给 rayon,用 oneshot 取结果;自己的长循环可以定期 yield_now。

💬 关联讨论

发起讨论
加载中...