tech-sharing / 2026-04-15

What Is the Rust Async Runtime
Actually Scheduling?

From Future::poll to tokio's work-stealing, explained in one go.

@lewisplatform infra · 45 min + Q&A

agenda.toml

Today's roadmap

01Context: why we need async~5min
02Deep dive 1: Future & Waker~12min
03Deep dive 2: Tokio scheduler~15min
04Code: build a mini-runtime~8min
05Takeaways + Q&A~5min

// context

The problem: one thread per connection
can't handle 100k concurrent users.

Thread-per-conn

One OS thread per connection, 2–8MB stack each. 100k connections = hundreds of GB of RAM.

❌ Unrealistic

Event loop (C)

epoll/kqueue plus callback hell. Fast, but painful to write and bug-prone.

😩 callback hell

Async / await

Reads like synchronous code, compiles to a state machine. One thread runs thousands of tasks.

✅ Rust's choice

deep-dive · 1 / 2

A Future has just one method.

The compiler turns an async fn into an anonymous state machine that implements the Future trait. The runtime does one thing: poll it repeatedly until it returns Ready.

Pending Ready(T) Waker.wake()
future.rs
pub trait Future {
    type Output;
    fn poll(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Self::Output>;
}

// Poll::Pending   → suspended, waits for the waker
// Poll::Ready(v)  → done, yields v

deep-dive · 2 / 2

Tokio is a small worker that steals tasks.

Multi-thread runtime = N workers, each with its own local queue. An idle worker "steals" tasks from other queues.

✦ local queue · 256 slots
✦ global injection queue
✦ work-stealing @ 50% steal ratio
✦ LIFO slot for cache locality

scheduler tick loop

1. pop from LIFO slot
2. else pop from local queue
3. else drain global queue (every 61 ticks)
4. else steal from random victim
5. else park the thread

mini-runtime.rs · ~40 LOC

Build a minimal runtime.

src/main.rs
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};

struct Task(Mutex<Pin<Box<dyn Future<Output = ()> + Send>>>);

impl Wake for Task {
    fn wake(self: Arc<Self>) { QUEUE.lock().unwrap().push_back(self); }
}

fn block_on<F: Future<Output = ()> + Send + 'static>(fut: F) {
    spawn(fut);
    while let Some(task) = QUEUE.lock().unwrap().pop_front() {
        let waker = Waker::from(task.clone());
        let mut cx = Context::from_waker(&waker);
        let mut fut = task.0.lock().unwrap();
        let _ = fut.as_mut().poll(&mut cx); // this is the one line
    }
}

// takeaways

Three takeaways.

1 · async is a zero-cost abstraction

Compiled to a state machine: no runtime vtable, no GC.

2 · The waker is the pulse

A Future does nothing on its own; the runtime relies on the waker to decide "when to poll again".

3 · Never block inside async

A single std::fs::read can stall an entire worker. Use spawn_blocking.

Further reading: tokio.rs/blog/2019-10-scheduler · rust-lang.github.io/async-book

?

Questions?

github.com/lewis · @lewis on slack

slides: git.co/rt-deck code: git.co/mini-rt