rust · async · tokio

Tokio's cooperative scheduler: budgets, yields, and starvation

2026-06-272 min readrust

Tokio feels like magic until one task runs a 200ms CPU loop on a worker thread and every other future on that worker stalls. Async Rust is cooperative — tasks must yield explicitly or at await points.

Workers and the run queue

Each Tokio worker thread pulls tasks from a local queue (and occasionally steals from siblings). There's no preemption: a future that never awaits monopolizes its worker.

bad-task.rs
1async fn poison() {2    loop {3        heavy_cpu(); // no .await — blocks the worker4    }5}
Gotcha

block_in_place and spawn_blocking exist for a reason — don't call blocking IO or long CPU work inside async fn bodies without them.

Task budgets (Tokio 1.x+)

Tokio can stop polling a task after a budget expires, forcing other tasks on the same worker to run. This mitigates starvation but doesn't replace good hygiene.

🦀
Ferris' hot tip

If you're CPU-bound, use rayon or a dedicated thread pool. Async is for waiting, not for saturating cores — that's what threads are for.

Checklist

  • Await at natural boundaries; chunk long loops with yield_now().await.
  • Move blocking work to spawn_blocking.
  • Watch p99 latency when load mixes fast handlers with heavy ones — starvation shows up as tail latency, not average CPU.

Pinning and self-referential futures interact with this model too — see Pin in practice.

Comments
Loading comments…