在 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
.awaitpoints, so code that spends a long time without reaching an.awaitwill prevent other tasks from running.
协作式预算也帮不上忙:预算只在叶子 future 被 poll 时检查,不 .await 的代码根本碰不到它(见协作式调度预算)。
问: 一次 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,几微秒就被偷走执行。
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_thread | current_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。