Asynchronous Rust

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