Skip to content

ProgMastermind/custom_async_runtime

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 

Repository files navigation

custom-async-runtime

A minimal async runtime built from first principles to understand how Tokio works.

Why this exists

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.

What it is

This is a single-threaded async runtime. It has:

  • a Delay future that completes after a fixed time
  • manual Wakers built from RawWakerVTable
  • an Executor with a ready queue
  • spawn that returns a JoinHandle
  • block_on that drives the root future and spawned tasks and returns the root future's output
  • thread::park / unpark for sleeping when idle

It is a learning model, not a production runtime.

How to run

cargo run

Expected output:

root: starting
spawned task: done
root: got 42
all done, result = 84

What the code does

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.

Complete execution flow

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")

How this maps to Tokio

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.

What is intentionally not included

  • a timer wheel
  • I/O polling
  • multi-threading and work-stealing
  • cancellation / abort
  • panic propagation
  • spawn_blocking or task-local storage

Each of those is a separate deep topic. This project is only the core scheduler loop.

Core idea

.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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages