Skip to content
← Back to explainers

Rust explainer

How tokio and smol build on these primitives

About 16 min to read. Allow extra time to work through examples.

How the parts of this track's small runtime compare with tokio and smol: per-worker queues with work stealing, an operating-system wait combined with timers, one allocation per task that also backs its wakers, and join handles with different cancellation rules.

This article uses Rust. You can make it your practice mode to browse related content across the site.

The earlier items in this track build a small async runtime using only the standard library. It has two parts:

  • an executor, which polls tasks that are ready to make progress. In item 16.17 it keeps a queue of ready task IDs and creates a new waker for each poll.
  • a reactor, which waits for events and wakes the tasks that were waiting for them. In item 16.19 the only events are timers, stored in a heap, and time is a simulated integer clock.

tokio and smol are two general-purpose async runtimes for Rust. This article takes each part of the small runtime and describes what those two runtimes do in the same place, and why.

The details describe tokio 1.53 and smol 2.0 (with its component crates async-executor 1.14, async-io 2.6 and async-task 4.7). Queue sizes, polling intervals and internal type layouts are implementation details, not public API, and can change in any release. The overall structure is the durable part; the specific numbers are examples.

The ready queue becomes per-worker queues with work stealing

In the small runtime: one Arc<Mutex<VecDeque<usize>>>, read by one thread, taking task IDs from the front.

A multi-threaded executor polls tasks on several worker threads. If every worker took tasks from one queue behind one mutex, every worker would compete for that lock, and the competition tends to grow with the number of workers. Both tokio's multi-threaded scheduler and smol's async-executor instead give each worker its own local queue, plus a shared global queue:

  • A worker takes tasks from its local queue first. The local queue is designed so that its owner rarely waits for other threads. It still uses atomic operations, because other workers may take tasks from it.
  • Where a woken task goes differs. In tokio, a task woken by code running on a worker usually goes onto that worker's local queue, and a wake from a thread outside the runtime goes to the global queue. In async-executor 1.14, every wake goes to the global queue, and workers move tasks from the global queue into their local queues in batches.
  • A worker whose local queue is empty checks the global queue and tries to steal a batch of tasks from another worker's local queue. Stealing is what spreads work across idle threads.
  • In tokio, when a full local queue (256 tasks in tokio 1.53) receives another task, half of its tasks move to the global queue.
  • Workers also check the global queue periodically while they still have local work (every 64 tasks in async-executor 1.14; tokio's multi-threaded scheduler tunes its interval at run time unless one is configured), so tasks there are not left waiting indefinitely.

Item 16.20 compares a FIFO queue with a pure LIFO policy, where every woken task goes to the front, and shows that pure LIFO lets one task keep the executor to itself. tokio's multi-threaded scheduler uses a narrower form of LIFO: each worker has a single LIFO slot. When the task being polled wakes another task on the same worker, the woken task goes into the slot and runs next. This helps when two tasks pass data back and forth, because the data is likely still in the CPU cache. Whatever was previously in the slot moves to the back of the local queue. A task that yields goes to the back of the queue, not to the slot. To stop two tasks from waking each other forever and starving the rest, tokio limits how many slot tasks it runs in a row (three in tokio 1.53) before it switches the slot off and returns to the queue.

Why tokio::spawn requires Send + 'static

tokio::spawn requires the future, and the value it produces, to be Send + 'static. Both bounds follow from the scheduler design above:

  • Send allows a value to be moved to another thread. Because another worker may steal the task, the future can be polled on a different thread from the one that spawned it.
  • 'static means the future contains no borrows that could expire, not that it lives forever. spawn returns immediately, and the function that called it may return before the task finishes, so the task must not borrow that function's local variables.

A future that holds an Rc fails to compile with tokio::spawn; the error reads "future cannot be sent between threads safely". A future that borrows a local variable fails too. For an async block that uses a local variable without move, the error is E0373 ("async block may outlive the current function, but it borrows v"), and adding move fixes it by moving the value into the future. For a future that holds a reference created outside it, such as spawn(read_len(&v)), the error is E0597 ("v does not live long enough"). smol::spawn has the same two bounds.

The small runtime polls every task on one thread, so its tasks could share an Rc<RefCell<_>>. Both runtimes have a single-thread form for futures that are not Send:

  • tokio's tokio::task::spawn_local runs the task on the current thread. It must be called inside a tokio::task::LocalSet or a tokio::runtime::LocalRuntime; otherwise it panics.
  • smol's LocalExecutor accepts futures that are not Send and polls them on the thread that runs it.

The simulated clock becomes an operating-system wait plus timers

In the small runtime: a BinaryHeap<Reverse<(deadline, task_id)>>, and a driver that moves the clock to the next deadline once no task is ready.

A real reactor waits for two kinds of events: I/O readiness (for example, a socket has data to read) and timer deadlines. It asks the operating system to report I/O readiness through an event interface: epoll on Linux, kqueue on macOS and the BSDs, and I/O completion ports on Windows. tokio uses the mio crate for this, and smol uses the polling crate (through async-io). When there is nothing to run, the reactor blocks in that call with a timeout equal to the time remaining until the earliest timer. When the call returns, the reactor wakes the tasks whose I/O became ready and the tasks whose timers expired.

One rule does not carry over. The small runtime looks at timers only when the ready queue is empty, which works because its simulation has no I/O. A production runtime cannot wait for the ready queue to empty, or a steady supply of ready tasks could delay I/O events indefinitely. tokio checks for I/O and timer events without blocking every 61 scheduler ticks by default (a tick is roughly one task poll; runtime::Builder::event_interval changes the number). It blocks with a timeout only when the worker has nothing to run.

Timer storage differs between the two runtimes. tokio stores timers in a hierarchical timing wheel: six levels of 64 slots, where each slot on one level covers 64 times the time span of a slot on the level below, and the finest level has millisecond resolution. Inserting or cancelling a timer touches one slot's list, so its cost does not depend on how many timers exist. The price is rounding: tokio documents that its sleep operates at millisecond granularity. smol's async-io instead keeps timers in a BTreeMap keyed by deadline and an ID, which costs O(log n) per insert or removal, like the heap in item 16.19. Both are reasonable designs. A wheel helps most when a program creates and cancels many timers, such as a timeout around every request.

Because a real reactor waits on the real clock, tests involving timeouts can be slow, or can pass or fail depending on timing. tokio's test-util feature adds tokio::time::pause(), which freezes tokio's clock, and tokio::time::advance(), which moves it forward by a chosen amount. While the clock is paused and the runtime has no other work, it jumps straight to the next pending timer, which is the same rule as the driver in item 16.19. pause() works only on tokio's current-thread runtime; on the multi-threaded runtime it panics. More generally, passing the current time into code as a value, instead of having the code read a global clock, is a common way to make time-dependent code testable.

The per-poll TaskWaker becomes a reference-counted task allocation

In the small runtime: every poll allocates a new Arc<TaskWaker { id, queue }> and converts it into a Waker.

tokio, and smol through async-task, allocate once per spawned task instead. That allocation starts with a header and also holds the future itself; when the future finishes, its output is stored in the same place. In tokio 1.53 the header contains an atomic state word, which packs status flags together with a reference count, and a pointer to a table of functions for that task type. smol's header is similar.

As item 16.8 shows, a RawWaker is a data pointer plus a pointer to a RawWakerVTable. That representation lets a runtime use the task allocation itself as the data pointer. Producing a waker for a poll needs no new allocation, and cloning a waker increments the task's reference count with one atomic operation. The small runtime's per-poll Arc is simpler and still meets the Waker contract; it costs an allocation on every poll.

The status flags matter for correctness as well as speed. tokio's include RUNNING, NOTIFIED, COMPLETE and CANCELLED. If a wake arrives while the task is being polled, the waker sets NOTIFIED instead of putting a second copy of the task in a queue; when the poll returns Pending, the worker sees the flag and schedules the task once. The same flags keep a task from sitting in two queues at once or being polled by two workers at the same time.

JoinHandle uses the same handshake

In item 16.18: the task and its handle share an Rc<RefCell<Shared<T>>> with a slot for the value and a slot for a waker. The task stores its value, takes the waker and wakes it; each poll of the handle stores the waker it was given, replacing any older one.

tokio follows the same handshake, but through the task allocation: the output is stored there, the handle's waker is stored there, and atomic state flags replace the RefCell. tokio skips the swap when the new waker would wake the same task, which has the same effect as always keeping the most recent one. Keeping the most recent waker matters even more with work stealing, because the task awaiting the handle may be polled by different workers, with a different waker, each time.

The runtimes differ on what dropping a handle means:

Handle Dropping it To cancel To let it run unobserved
tokio::task::JoinHandle detaches; the task keeps running call abort() drop the handle
smol::Task (from async-task) cancels the task drop it, or cancel().await to wait for it to stop call detach()

A future that was never spawned behaves like neither: it does nothing until it is polled, and dropping it simply drops its state. So the same drop(handle) line stops work under smol and leaves it running under tokio. Check which kind of handle you hold before relying on either behavior.

The runtimes as of 2026

  • tokio has a multi-threaded work-stealing scheduler and a current-thread scheduler, together with its own I/O, timer and synchronization types. Many of its async methods document whether they are cancel safe, meaning whether dropping the call partway through can lose data. axum's default tokio feature, which provides axum::serve, and tonic's default transport feature, which provides its built-in server and client, both run on tokio. hyper 1.x defines its own runtime traits, and the companion crate hyper-util implements them for tokio.
  • smol is a small crate that re-exports a set of separately published crates, including async-executor, async-io, async-task (through the executor's Task type), blocking and futures-lite. Each can be used on its own, so a program can take only the pieces it needs.
  • async-std is discontinued. Advisory RUSTSEC-2025-0052 (August 2025) marks it unmaintained and names smol as an alternative. Existing code still builds, but the crate receives no fixes, so new projects should use a maintained runtime.
  • embassy targets embedded no_std systems. Its executor can keep task storage in memory reserved at compile time, so it does not need a heap allocator, and its hardware drivers wake tasks from interrupt handlers.

The choice is usually driven by dependencies: a library built on tokio's I/O types needs a tokio runtime to be running. Beyond that, the tradeoff is between a single integrated runtime and a set of smaller crates you assemble yourself.

Check your understanding Your library needs to make an HTTP request. Should it depend on tokio? Show answer

Only if requiring tokio is acceptable for everyone who will use the library. If it does, say so at the top of its documentation.

A library that depends on one runtime pushes that choice onto its users. tokio's I/O and timer types need a tokio runtime context: called from code running under smol::block_on with no tokio runtime, tokio::time::sleep and tokio::net::TcpListener::bind panic with "there is no reactor running, must be called from the context of a Tokio 1.x runtime". A program can run both runtimes, but then it has two sets of threads and two reactors, and it must make sure each runtime's types are used inside that runtime.

A related mistake is calling a blocking block_on from inside async code. tokio's Runtime::block_on called on a thread that is already running a tokio runtime panics with "Cannot start a runtime from within a runtime". A general-purpose block_on, such as smol::block_on, does not panic, but it blocks the worker thread. On tokio's current-thread runtime, if the future it waits for needs that same thread, it never finishes.

Four positions a library can take:

  1. Runtime independent. Accept a connection or I/O object from the caller, or write the logic against async I/O traits, so the caller's runtime does the work. This takes the most effort and is the most reusable. Note that tokio's AsyncRead/AsyncWrite and the futures-io traits used by smol are different traits; adapters such as the compat module in tokio-util convert between them.
  2. Optional runtime features. Offer Cargo features such as tokio and smol so the caller chooses. Cargo features are additive, so the crate must also decide what happens when both or neither are enabled.
  3. Runtime specific, stated plainly. Appropriate for a framework that has to pick one; less appropriate for a small utility crate, because it decides for every downstream user.
  4. Blocking. If the request is not latency sensitive and the program will not have many of them in flight at once, a blocking HTTP client is simpler. Async callers can run it with tokio::task::spawn_blocking or smol::unblock so it does not occupy a worker thread. The next article, Choosing between blocking and async code, discusses this tradeoff.

Reading the source

The small runtime gives names to the main parts of both codebases, which makes them easier to read:

  • tokio: src/runtime/scheduler/multi_thread/ (workers, local queues, the LIFO slot), src/runtime/task/ (the task allocation and its state flags) and src/runtime/time/wheel/ (the timing wheel).
  • smol: async-executor (global and local queues and stealing), async-task (the task allocation and its header) and async-io (the reactor and its timer map).

The structure is the same as the small runtime: a queue of ready tasks, wakers that put tasks back on it, a reactor that waits for I/O and timers, and a handshake between a task and its handle. The production versions add multiple threads, operating-system I/O and the scheduling rules needed to keep those fair.

step 24 of 25

Async From First Principles

Up next Choosing between blocking and async code