Cooperative Scheduling
Async Rust uses cooperative scheduling: once an executor calls Future::poll on a task, that task runs uninterrupted on the underlying OS worker thread until the task voluntarily yields control at an .await point that returns Poll::Pending (or finishes with Poll::Ready). Unlike OS threads, which the kernel preempts automatically via hardware timer interrupts every few milliseconds, an async task can never be preempted mid-computation between .await points.
Preemptive OS Scheduling vs. Cooperative Async Scheduling
Preemptive Scheduling (OS Threads):
Kernel Timer Interrupt forces context switch even inside a tight `while` loop!
Thread 1: [ compute... ] --(IRQ)--> [ Thread 2 runs ] --(IRQ)--> [ compute... ]
Cooperative Scheduling (`async` Tasks on 1 Executor Worker Thread):
Task 1 can ONLY yield at an `.await` point that returns `Poll::Pending`!
Task 1: [ compute .......... .await (Pending) ] ---> [ Task 2 runs ... .await ]
Task Starvation and Tail-Latency Spikes
If a single async task executes a long CPU-bound loop (such as hashing a 100 MiB file, parsing a huge JSON blob, or running 50,000 loop iterations without yielding) between .await points, it monopolizes the executor worker thread—starving other tasks on that thread and delaying heartbeats, timers, and network I/O!
Operation Duration Between .await Points |
Effect on Async Executor | Recommended Action |
|---|---|---|
< 10–100 µs |
Normal; negligible impact on sibling tasks | Run inline inside the async fn |
100 µs – 1 ms |
Noticeable tail-latency jitter for concurrent tasks | Periodically yield back to the executor (yield_now().await) |
> 1 ms or blocking syscall |
Severe starvation of worker thread | Offload to a dedicated blocking or rayon thread pool (spawn_blocking) |
Implementing a Cooperative yield_now() Future
A yield_now() future returns Poll::Pending on its first poll after immediately waking its own Waker (cx.waker().wake_by_ref()), and returns Poll::Ready(()) on its second poll. Inserting yield_now().await every $K$ iterations of a long computation hands control back to the executor's ready queue so sibling tasks can run:
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
struct YieldNow {
yielded: bool,
}
impl Future for YieldNow {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.yielded {
Poll::Ready(())
} else {
self.yielded = true;
// Re-schedule this task at the back of the executor queue:
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
fn yield_now() -> YieldNow {
YieldNow { yielded: false }
}
async fn cooperative_worker(name: &'static str, steps: usize) {
for step in 1..=steps {
println!("[{name}] completed step {step}/{steps}");
// Cooperatively yield after each step so other tasks get a turn:
yield_now().await;
}
}
fn main() {
// Round-robin single-threaded task queue demonstrating cooperative interleaving:
let mut ready_queue: VecDeque<Pin<Box<dyn Future<Output = ()>>>> = VecDeque::from([
Box::pin(cooperative_worker("Task A", 2)) as Pin<Box<dyn Future<Output = ()>>>,
Box::pin(cooperative_worker("Task B", 2)),
]);
let mut cx = Context::from_waker(Waker::noop());
while let Some(mut task) = ready_queue.pop_front() {
if task.as_mut().poll(&mut cx).is_pending() {
ready_queue.push_back(task);
}
}
}
[Task A] completed step 1/2
[Task B] completed step 1/2
[Task A] completed step 2/2
[Task B] completed step 2/2