A minimal async runtime built from first principles to understand how Tokio works.
I got asked in an interview: "Why do we need async, and how does it actually work?" I gave the usual answer about not blocking the thread and the runtime switching tasks. It was not wrong, but it was only the shape of the idea. I could not describe the exact handoff from a task yielding to the scheduler resuming it.
So I tried to build the smallest version I could using only the Rust standard library. This repo is the result.
This is a single-threaded async runtime. It has:
- a
Delayfuture that completes after a fixed time - manual
Wakers built fromRawWakerVTable - an
Executorwith a ready queue spawnthat returns aJoinHandleblock_onthat drives the root future and spawned tasks and returns the root future's outputthread::park/unparkfor sleeping when idle
It is a learning model, not a production runtime.
cargo runExpected output:
root: starting
spawned task: done
root: got 42
all done, result = 84
lib.rs is the runtime. main.rs is the demo.
Delay is a future that waits for a duration. On the first poll it spawns a background thread, sleeps, and then calls its waker. RefCell<Option<thread::JoinHandle>> makes sure the thread is only spawned once.
The Executor owns an mpsc ready queue. spawn wraps the user future in an async block that sends its result through a oneshot channel, then stores that wrapped future in an Arc<Task> and pushes it onto the queue. The JoinHandle is the receiver side of that oneshot channel.
block_on is the main loop. It drains the ready queue, polls every task, then polls the root future. When there is nothing to do it calls thread::park(). A task waker re-queues the task and calls thread::unpark() to wake the executor.
main.rs spawns a task that returns 42 and then block_ons a root future that waits 500ms, awaits the handle, and returns value * 2.
main
│
▼
Executor::new()
│
▼
ex.spawn(task_A) ──► creates JoinHandle<i32> with oneshot channel
│ sends Arc<Task_A> into ready queue
▼
ex.block_on(root_future)
│
├─► loop:
│ │
│ ├─ try_recv() ──► poll Task_A
│ │ Task_A: Delay(1s) not ready → Pending
│ │ Delay thread owns Task_A waker
│ │
│ ├─ poll root_future
│ │ prints "root: starting"
│ │ Delay(500ms) not ready → Pending
│ │ Delay root thread owns root waker
│ │
│ └─ thread::park() ← executor sleeps
│
│ [0.5s later]
│ root Delay thread → root.wake() → unpark executor
│
├─► loop:
│ │
│ ├─ try_recv() empty
│ │
│ ├─ poll root_future
│ │ Delay(500ms) ready
│ │ handle.await → JoinHandle polls OneshotReceiver
│ │ value not ready → stores root waker in OneshotShared
│ │ returns Pending
│ │
│ └─ thread::park()
│
│ [0.5s later]
│ Task_A Delay thread → Task_A.wake() → send to queue + unpark executor
│
├─► loop:
│ │
│ ├─ try_recv() → poll Task_A
│ │ Delay(1s) ready
│ │ prints "spawned task: done"
│ │ tx.send(42)
│ │ OneshotShared stores value and calls root waker.wake()
│ │ Task_A returns Ready(())
│ │ tasks_pending = 0
│ │
│ ├─ poll root_future
│ │ handle.await → OneshotReceiver value is Some(42)
│ │ returns Ready(42)
│ │ prints "root: got 42"
│ │ returns Ready(84)
│ │
│ └─ root_output.is_some() && tasks_pending == 0
│ → return 84
│
▼
println!("all done, result = 84")
| This runtime | Tokio equivalent |
|---|---|
Delay |
tokio::time::sleep |
Executor |
Tokio scheduler |
Task + Waker |
Tokio task + Waker |
JoinHandle |
tokio::task::JoinHandle |
block_on |
Runtime::block_on |
thread::park / unpark |
Tokio worker thread parking |
Tokio replaces the one-thread-per-sleep Delay with a timer wheel, and it uses multi-threaded work-stealing queues instead of a single mpsc channel. The ideas are the same.
- a timer wheel
- I/O polling
- multi-threading and work-stealing
- cancellation /
abort - panic propagation
spawn_blockingor task-local storage
Each of those is a separate deep topic. This project is only the core scheduler loop.
.await is a state machine that calls poll, gets Pending, stores a waker, and tries again later.
tokio::spawn puts a task in a queue and gives it a waker that puts it back in the queue.
block_on is a loop that polls things until nothing is left to do.