Tokio's cooperative scheduler: budgets, yields, and starvation
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.
1async 2345
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.
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.