You have built a two-part async runtime out of std: an executor with a ready queue and per-task wakers, and a reactor with a timer heap. It is a toy in scale and not in shape. This article maps every piece of it onto what a production runtime does with the same piece, so that “I built a toy” turns into “I understand the real thing”.
Everything below is a description of a design as it stands in 2026. Runtime internals change between releases; treat the shapes as durable and the specifics as dated.
Your ready queue becomes a work-stealing scheduler
Yours: one Arc<Mutex<VecDeque<usize>>>, one thread, pop from the front.
The multi-threaded version cannot do that, because a single global queue behind a single mutex is a contention point that gets worse with every core you add. The standard design instead gives each worker thread its own local queue, plus a global overflow queue:
- a worker pushes woken tasks onto its own local queue, with no synchronisation in the common case;
- when a worker’s local queue is empty, it steals a batch from another worker’s queue;
- when a local queue overflows, the excess goes to the global queue, which idle workers also check.
The LIFO-slot idea from item 16.20 shows up here too: a worker typically keeps the task it just woke in a special slot so it runs immediately with hot caches, with a cap on consecutive hits so it cannot starve the fair queue. You wrote both disciplines and watched the difference in the output.
The extra bound that appears at this point is the reason tokio::spawn requires Send + 'static: a task may be stolen by another thread, so everything it holds must be safe to move across threads and must not borrow anything with a shorter lifetime. Your single-threaded executor could accept Rc and RefCell freely, which is exactly why tokio::task::spawn_local and LocalSet exist for the cases where you do not want that bound.
Your logical clock becomes an OS event loop plus a timer wheel
Yours: a BinaryHeap<Reverse<(deadline, task_id)>> and a now that advances only when the ready queue is empty.
Production: the same two-phase loop with two things swapped in.
The wait primitive is the OS. Instead of “advance the clock to the next deadline”, a real reactor blocks in epoll_wait (Linux), kevent (BSD/macOS) or an IOCP completion port (Windows) with a timeout equal to the next timer deadline. tokio uses the mio crate for this; smol uses polling. When the call returns it has both the sockets that became ready and, possibly, the expiry of the timeout — and it wakes the corresponding tasks.
The ordering rule you were made to get right is the same rule: drain everything runnable, and only then go to sleep in the syscall. A runtime that blocked in epoll_wait while tasks were still runnable would be adding latency for no reason.
Timers use a hierarchical wheel, not a heap. A binary heap is O(log n) per insert and removal. Timers are inserted and cancelled constantly — every timeout on every request — so runtimes use a timer wheel: buckets of a fixed resolution, with coarser wheels for longer deadlines, giving O(1) insert and cancel at the cost of some rounding. Your heap is the textbook version of the same idea, and it is what you would reach for below a few thousand timers.
Because a real reactor waits on wall-clock time, tests that depend on timing become flaky — which is why tokio ships tokio::time::pause() and advance(). That is your logical clock, offered as a testing feature. The technique generalises far beyond async: making time an injected value rather than a global is how you make any time-dependent system deterministically testable.
Your TaskWaker becomes a refcounted task header
Yours: Arc::new(TaskWaker { id, queue }), freshly allocated on every poll.
Production: one allocation per task, not per poll. A task is laid out as a header — refcount, state bitfield, scheduler pointer, Waker vtable — immediately followed by the future itself and a slot for its output. The Waker handed to poll is a pointer to that header, so it costs nothing to create and cloning it is an atomic increment.
That is the payoff of the RawWaker representation from item 16.8. A Waker is two words precisely so a runtime can make it a pointer into a structure it already owns. Your version’s per-poll Arc allocation is the honest simplification.
The state bitfield in the header is doing more than it looks: it tracks running / scheduled / complete / cancelled so that a wake arriving while the task is mid-poll re-schedules it exactly once rather than twice, and so that a task cannot be enqueued on two workers at the same time.
Your JoinHandle is already the real one
Yours: Rc<RefCell<Shared<T>>> with a value slot and a waker slot; the task writes the value, takes the waker and calls it.
Production: the same handshake against the task header, with atomics instead of a RefCell and the output stored in the task allocation. The clause you discovered the hard way in item 16.18 — store the most recent waker — is exactly the clause that matters here, because with work stealing the awaiting task genuinely does migrate between threads.
One behavioural difference worth knowing: in tokio, dropping a JoinHandle does not cancel the task. The task was queued on spawn and keeps running; you get abort() if you want cancellation. That is the opposite of a plain future, which does nothing until awaited and dies when dropped. Both models exist in the same program, and confusing them is a common source of “why is this still running”.
The ecosystem, factually, in 2026
- tokio is the production default. Largest ecosystem by a wide margin (hyper, axum, tonic, sqlx and most of the service stack are built on it), multi-threaded work-stealing scheduler, its own I/O and sync types, and per-method cancel-safety documentation.
-
smol is the lightweight alternative — a small set of composable crates (
async-executor,async-io,polling,futures-lite) that you can assemble rather than adopt whole. Attractive when you want to understand or control what you are running. - async-std was officially discontinued in August 2025 (RUSTSEC-2025-0052), with migration to smol recommended. It shipped a std-shaped API over its own runtime; it is deprecated and should not be used in new code.
-
embassy serves embedded
no_std: a static executor with no allocator, interrupt-driven wakers, and its own HAL integration. Closest in spirit to what you built, because it cannot afford anything more.
Resist treating any of that as doctrine. The real tradeoff is ecosystem gravity — with tokio, everything you need already exists and is tested together — against surface area and control. Both are legitimate, and the choice usually gets made for you by the crates you need.
💡Your library needs to make an HTTP request. Should it depend on tokio? click to reveal
Prefer not to, and if you must, be honest about it in your docs.
The problem is that runtimes do not compose. Two runtimes in one binary means two thread pools, two reactors, and futures that are silently registered with the wrong one. The characteristic symptom is a task that never completes, or the runtime panicking with “there is no reactor running” because an I/O resource was created inside a different runtime’s context. block_on inside an async context is the other classic: on most runtimes it deadlocks or panics outright.
Three positions a library can take, in decreasing order of politeness:
-
Runtime-agnostic: take the I/O as a generic parameter, or express your logic over
AsyncRead/AsyncWrite-shaped traits, and let the caller supply the runtime. Most work, most reuse. -
Feature-gated: an optional
tokiofeature (and perhapssmol) so the dependency is the caller’s choice. This is what a lot of middle-layer crates do. - Runtime-specific, and say so in the first line of the README. Sometimes correct — a service framework has to pick — but a utility crate that hard-depends on a runtime has made the decision for everyone downstream.
There is a fourth option people forget: if the operation is not on a hot path and does not need thousands of concurrent instances, do it with a blocking client on a thread. Not every function needs to be async, and the colouring problem in the next article is largely about the cost of pretending it does.
Read the source
That is the real recommendation. tokio/src/runtime/scheduler/multi_thread/ and smol‘s async-executor are both readable now in a way they were not before you wrote item 16.19. You know what a ready queue is for, what a waker has to guarantee, why the task is one allocation, and why the reactor only runs when the executor is idle.
The thing you built is the same shape. The difference is scale, hardware integration, and a decade of tuning.