一、先说说这个陷阱是怎么冒出来的
很多写异步 Rust 的朋友,都喜欢把 Tokio 当成一个“自动帮你搞定一切并发”的黑盒子。确实,Tokio 的多线程调度器默认会开跟 CPU 核心数一样的 worker 线程,然后每个 worker 手里拿着一个本地队列,再通过工作窃取(work stealing)来平衡负载。听着很完美对吧?但这里面藏着一个特别容易踩的坑:如果你在异步任务里写了一个长时间运行的循环,而且这个循环里没有 await 点,那它就会像一个霸道的房客,死死占住当前 worker 线程不放,导致排在这个 worker 本地队列里的其他任务全都被饿死。
你可能觉得,“我写异步代码,当然不会干这种傻事”。但现实是,很多业务逻辑里都有“处理大量数据”“轮询某个状态”“计算哈希”之类的重活,一不小心就会写成同步循环。而且 Tokio 的调度器并不会主动把你的循环任务“踢”出去,它只会傻傻地等这个任务自己让出 CPU。所以问题就来了:你以为自己在用异步,实际上某个 worker 线程已经被一个循环给独占了几秒钟甚至几分钟,而其他小任务只能干瞪眼。
二、工作窃取到底是怎么工作的
要理解这个坑,我们得先简单看看 Tokio 多线程调度器的日常运转。你可以把它想象成一个热闹的厨房,多个厨师(worker 线程)各自有个台面(本地队列)。每来一道菜(异步任务),就放到某个厨师的台面上。厨师从自己台面上拿起一道菜开始做(执行 Future)。如果自己台面空了,就会去别的厨师台面上“偷”菜来帮忙。
这个设计本来是为了让负载均衡。但是注意,厨师一旦开始做某道菜,他会一口气做到底,除非这道菜自己说“我累了,先歇一下”(即碰到 .await 主动让出)。如果一道菜是那种“无限循环炖汤”,厨师就会一直盯着锅,哪怕自己台面上另外堆着几十道菜,他也完全没空去碰。工作窃取此时也帮不上忙,因为别的厨师跑过来一看,发现这个厨师正忙得满头大汗,但台面上确实有菜,可那能怎么办呢?总不可能把正在手里做的菜抢走吧?Tokio 的窃取机制只在任务被 poll 返回 Pending 之后才发生,你的循环任务如果不让出,那它永远不会回到队列里,别人自然也没机会偷到它。
2.1 一个肉眼可见的饿死现场
下面我写一个最简单的例子,让你直接看到这个问题。技术栈是 Rust + Tokio。
// 使用 tokio 多线程运行时,依赖 tokio = { version = "1", features = ["full"] }
use std::time::{Duration, Instant};
use tokio::time::sleep;
#[tokio::main(worker_threads = 2)] // 开两个 worker,方便观察饿死
async fn main() {
// 这个任务会疯狂循环,循环里没有任何 await
let heavy_task = tokio::spawn(async {
println!("[长任务] 开始跑死循环啦,你猜我要跑多久?");
let start = Instant::now();
let mut x: u64 = 0;
// 故意跑一个约 2 秒的同步循环,模拟 CPU 密集型操作
while start.elapsed().as_secs_f32() < 2.0 {
x = x.wrapping_add(1);
}
println!("[长任务] 总算结束了,x={}", x);
});
// 同时我们派发 10 个小任务,每个只要睡 10 毫秒就打印一句话
let small_tasks: Vec<_> = (0..10)
.map(|i| {
tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
println!("[小任务 {}] 我醒啦!", i);
})
})
.collect();
// 等等长任务和所有小任务完成
let _ = heavy_task.await;
for t in small_tasks {
let _ = t.await;
}
}
你运行这段代码,大概率会看到这样的输出顺序(不一定完全一样,但核心现象一致):
[长任务] 开始跑死循环啦,你猜我要跑多久?
[长任务] 总算结束了,x=...
[小任务 0] 我醒啦!
[小任务 1] 我醒啦!
...
看到了吗?小任务不是先醒,而是等长任务跑完之后才陆续醒来。但实际上小任务只需要 10 毫秒就能完成,却硬生生等了 2 秒。如果你的长循环持续更久,小任务就等更久。这就是“饿死”的典型症状。
三、为什么说这是公平性陷阱
你可能会想:“这不就是优先级问题嘛,让我把长任务放在其他任务之后不就行了?”但问题没那么简单。Tokio 的调度器本身在设计上并没有“优先级”的概念,它只关心“哪个任务需要被 poll”。工作窃取的目标是提升吞吐量,而不是公平性。也就是说,它只保证“每个 worker 都在忙活”,不保证“每个任务都能及时被推进”。所以当有一个不主动让出的任务时,同一 worker 上的其他任务就会遭遇“不是并发,而是排队”。
最关键的是,这个饿死现象是“隐藏”的。为什么说隐藏?因为如果你的小任务分派到了另一个 worker 线程上,那它可能不受影响,饿死的只是同队列的任务。你很难一眼看出到底是哪个任务霸占了哪个 worker。尤其是在真实业务里,任务数量多、运行时间波动大,你可能会看到某些请求特别慢,但查不到原因。
3.1 真实场景模拟
想象一下,你有一个 Web 服务,Tokio 作为异步运行时。每个进来的请求是一个异步任务。有一个后台任务在不停地分析日志,这个分析过程是一个同步循环,每循环一次处理一条日志。如果这个后台任务和某个请求任务被放到同一个 worker 的队列里,那么那个请求可能就会超时,而其他 worker 上的请求却很正常。超时的请求一多,用户就会抱怨“系统不稳定”。但你以为是自己数据库慢了,其实是调度器被一个不讲武德的任务给卡住了。
四、怎么解决?主动让出 + 执行预算
要解决这个问题,核心思路就是:让长时间运行的循环任务定期“喘口气”,把 CPU 让给别的任务。同时,最好还能控制它一次运行的总预算,免得它让出之后又立刻被调度,结果还是霸占了绝大多数时间。
4.1 主动让出:yield_now
最简单的做法是在循环里定期调用 tokio::task::yield_now()。它的作用就是让当前任务主动回到队列末尾,告诉调度器“我暂时不干了,让别人先来”。注意,yield_now 是一个异步函数,所以循环里必须有一个 await。我们把刚才的长任务改一下:
// 技术栈:Rust + Tokio
use std::time::{Duration, Instant};
use tokio::time::sleep;
#[tokio::main(worker_threads = 2)]
async fn main() {
let heavy_task = tokio::spawn(async {
println!("[长任务] 开始循环,每 1000 次迭代让出一次");
let start = Instant::now();
let mut x: u64 = 0;
// 循环 2 秒
while start.elapsed().as_secs_f32() < 2.0 {
x = x.wrapping_add(1);
// 每 1000 次循环,主动让出当前 worker
if x % 1000 == 0 {
tokio::task::yield_now().await;
}
}
println!("[长任务] 结束,x={}", x);
});
let small_tasks: Vec<_> = (0..10)
.map(|i| {
tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
println!("[小任务 {}] 我醒啦!", i);
})
})
.collect();
let _ = heavy_task.await;
for t in small_tasks {
let _ = t.await;
}
}
现在你再运行,小任务基本不会等满了。因为每次让出后,调度器会检查队列里的其他任务,小任务就有机会被 poll 了。这个方法很简单,但有个问题:如果小任务特别多,那么让出会非常频繁,导致长任务的整体执行时间拉长,因为大部分时间都花在切换上了。
4.2 执行预算:限制单次运行时间
“执行预算”的意思就是,给每一次让出前的工作量设定一个上限。比如最多执行 10 毫秒的 CPU 密集计算,然后必须让出。这样既能保证其他任务不会被饿死,又能让长任务保持一个相对高的吞吐,因为切换次数是可控的。
实现方式可以自己维护一个开始时间和预算,到了时间就让出。下面是一个通用的“带预算的循环”模式:
// 技术栈:Rust + Tokio
use std::time::{Duration, Instant};
use tokio::time::sleep;
#[tokio::main(worker_threads = 2)]
async fn main() {
let heavy_task = tokio::spawn(async {
println!("[长任务] 使用执行预算,每 10 毫秒让出一次");
let total_start = Instant::now();
// 设置每段预算为 10 毫秒
let budget = Duration::from_millis(10);
// 当前这一段的开始时间
let mut segment_start = Instant::now();
let mut x: u64 = 0;
// 整体跑 2 秒
while total_start.elapsed().as_secs_f32() < 2.0 {
// 做一些 CPU 操作
x = x.wrapping_add(1);
// 如果这一段跑满了预算,就让出
if segment_start.elapsed() >= budget {
segment_start = Instant::now(); // 重置段计时
tokio::task::yield_now().await; // 主动让出
}
}
println!("[长任务] 结束,x={}", x);
});
let small_tasks: Vec<_> = (0..10)
.map(|i| {
tokio::spawn(async move {
sleep(Duration::from_millis(10)).await;
println!("[小任务 {}] 我醒啦!", i);
})
})
.collect();
let _ = heavy_task.await;
for t in small_tasks {
let _ = t.await;
}
}
这样做的优点是:每次最多占用 10 毫秒 CPU,然后其他任务就有机会插进来。10 毫秒对用户来说几乎无感,但对其他任务来说,等待时间被缩短到了可接受的范围。你可以根据业务场景调整预算,比如 1 毫秒、5 毫秒、20 毫秒,越短公平性越好,但切换开销也越大。
4.3 更优雅的写法:把 CPU 密集部分放进 spawn_blocking
如果你的循环完全是 CPU 密集型的,没有异步 I/O 需求,那更推荐的做法是用 tokio::task::spawn_blocking 把它丢到专门的阻塞线程池里去。Tokio 的阻塞线程池不参与异步调度,因此不会饿死任何异步任务。但注意,spawn_blocking 的线程数量有限,如果并发太多,它们之间也会互相排队。不过至少,你的异步任务是安全的。
// 技术栈:Rust + Tokio
use std::time::Duration;
use tokio::task;
#[tokio::main(worker_threads = 2)]
async fn main() {
// 阻塞线程池里跑 CPU 密集任务
let blocking_task = task::spawn_blocking(|| {
let start = std::time::Instant::now();
let mut x: u64 = 0;
while start.elapsed().as_secs_f32() < 2.0 {
x = x.wrapping_add(1);
}
x
});
// 与此同时,普通异步任务完全不受影响
let async_task = task::spawn(async {
for i in 0..5 {
tokio::time::sleep(Duration::from_millis(100)).await;
println!("异步任务正常推进:{}", i);
}
});
let result = blocking_task.await.unwrap();
let _ = async_task.await;
println!("阻塞任务结果:{}", result);
}
运行后你会看到异步任务每 100 毫秒打印一次,不会等 2 秒。这就是 spawn_blocking 的威力。不过要注意,spawn_blocking 里的代码不能使用异步 API(不过可以配合 block_in_place 等方法,但那是另一个话题)。如果循环中需要做异步操作,比如查数据库,那就不适合放进去,除非你用 block_on 之类的,但那又会导致线程阻塞。所以最安全的方式还是回到 yield_now + 预算的方案。
五、具体应用场景分析
这个坑最常出现在下面几类场景里:
- 批处理任务:比如后台跑一个分析脚本,要把一个超大集合里的元素全部处理一遍。这个处理过程如果只是算算数字、拼拼字符串,很容易写成同步循环。
- 轮询循环:有些系统需要周期性地去检查某个状态,比如“每 5 秒检查一次有没有新文件”。如果检查逻辑本身很快,没问题;但如果你在检查过程中做了很多计算,或者检查的频率极高(比如 while true 里没有 sleep),那就危险了。
- 数据序列化/反序列化:解析大 JSON、大 XML,或者图片处理、压缩解压,这些操作是 CPU 密集的,而且经常出现在异步代码里。如果处理一个大对象需要几百毫秒,那这个任务很可能就“独占”了一个 worker 一两百毫秒,虽然不像几秒那么严重,但也足够让同 worker 上的小任务感受到明显延迟。
六、这个方案的优缺点
6.1 主动让出 + 执行预算的优点
- 实现简单:只需要在循环里加个 if 和 yield_now,不需要改动架构。
- 可控性强:预算大小可以调整,在公平性和执行效率之间取平衡。
- 兼容性好:适用于所有异步上下文,不需要改调用方。
6.2 缺点
- 切换开销:频繁 yield 会增加上下文切换成本,尤其是预算设置太小时,性能会明显下降。
- 调整玄学:预算设置多大才合适?这取决于任务量和业务容忍度,没有万能值,可能需要压测。
- 仍然可能被饿死:如果所有任务都自带预算但都特别“贪心”,每个任务让出后立刻又被调度,那还是会挤占小任务的时间。此时需要依赖 Tokio 的公平调度机制,但它的公平性是“尽力而为”,不是强保证。
七、注意事项
- 在溢出的循环里,不要只调 yield_now 不检查预算,因为如果循环里每次迭代很快(比如就加个数字),yield_now 会调用得非常频繁,导致长任务性能急剧下降。较好的办法是组合使用:每 N 次迭代检查一次时间,或者每 N 次迭代让出一次。
- 不要把 sleep 当成让出工具。有些人会在循环里加 tokio::time::sleep(Duration::from_millis(0)).await,这虽然也能让出,但睡眠是定时器,开销比 yield_now 大,而且语义不清晰。yield_now 就是专门用来“主动让出”的。
- 如果你的循环里本来就包含 .await 调用(比如一次网络请求),那么循环会自动让出,通常不需要额外处理。只有那些完全同步、没有 await 的循环才是罪魁祸首。
- 在测试环境最好模拟高并发场景,专门验证一下你的长循环任务是否会饿死其他任务。别只跑单任务,那样永远看不出问题。
- 使用多线程调度器时,任务在哪个 worker 上是不确定的,所以饿死事件本身就是概率性的。有时候你重启一次服务,现象就消失了,这就更有迷惑性。排查时建议在日志里打印线程 id 或任务开始时的排队时间,才能定位。
八、总结
说到底,Tokio 的工作窃取调度器是一个高性能但“只保证忙、不保证公平”的调度器。它默认假设所有任务都会定期让出 CPU,因为异步任务在遇到真正的 I/O 等待时会自动让出。但 CPU 密集的同步循环破坏了这一假设,于是同队列的任务就会被饿死。解决的核心不是去修改 Tokio 的调度策略(也没办法改),而是从代码层面约束自己的任务行为。主动让出是最直接的兜底方案,配合执行预算可以做到“既让出又不浪费”。再激进一点,直接把纯 CPU 计算丢到 spawn_blocking 的阻塞线程池里,人间清净。
写到这里,我想起一句话:异步编程里最怕的其实不是“慢”,而是“藏”。一个看似无害的循环,可能正在角落里悄悄堵死了整条路。希望这篇博客能帮你提前识破这个陷阱,写出更健壮的异步代码。记住,在 Tokio 的世界里,永远别忘了给你的大任务“留个门缝”,让其他任务也有机会喘口气。
评论
围绕“Tokio工作窃取调度器隐藏的公平性陷阱:长时间运行的循环任务会饿死同队列任务,代码层面要从主动让出与执行预算的配合上规避。”参与讨论