Lesson 34 of 44
The Reactor, the Timer Wheel, and Task Layout
The scheduler from Runtime Builder and the Scheduler runs tasks that are ready. This lesson covers what makes them ready: the I/O driver (the reactor), which waits on the operating system for socket events, and the time driver, which fires timers. It ends with the memory layout of a task, which explains what a Waker and a JoinHandle point to. Constants are quoted from Tokio 1.53.1 and mio 1.2.3; paths are relative to each crate's src/ in ~/.cargo/registry/src/*/.
| Item | Purpose |
|---|---|
| mio, epoll, kqueue | the OS readiness APIs under the I/O driver |
ScheduledIo |
per-resource readiness bits and waiting wakers |
| edge-triggered readiness | the OS reports a change once; Tokio caches it until WouldBlock |
AsyncFd |
register any Unix file descriptor with the reactor |
| driver stack | time driver on top of the I/O driver; parking means waiting in epoll_wait |
| timer wheel | 6 levels × 64 slots, 1 ms resolution |
| task cell | header, core (future or output), trailer |
| state word | lifecycle bits, flags, and a reference count in one AtomicUsize |
size_of_val, Box::pin |
measure and shrink future size |
The I/O driver
Tokio's I/O driver is built on mio, a crate that wraps the OS readiness APIs: epoll on Linux, kqueue on macOS and BSD, and an IOCP-based emulation on Windows. A readiness API tells a program when a non-blocking socket can be read or written without blocking, rather than doing the read itself.
When a TcpStream is created, Tokio sets the socket to non-blocking and registers it with mio. The registration token is the address of a ScheduledIo struct owned by the driver (runtime/io/scheduled_io.rs). Two tokens are reserved in runtime/io/driver.rs: TOKEN_WAKEUP = Token(0) (used to interrupt a sleeping driver) and TOKEN_SIGNAL = Token(1) (Unix signals).
mio registers every source as edge-triggered: mio/src/sys/unix/selector/epoll.rs starts the interest mask with EPOLLET. Edge-triggered means the OS reports "readable" once when the state changes, not on every poll while data remains. Tokio therefore stores readiness itself, in one AtomicUsize per ScheduledIo:
| Bits | Field | Meaning |
|---|---|---|
| 16 low bits | readiness | readable, writable, read-closed, write-closed, priority, error |
| next 15 bits | driver tick | which driver turn set the readiness |
| 1 bit | shutdown | the driver is gone; all waiters get an error |
(READINESS, TICK, SHUTDOWN in runtime/io/scheduled_io.rs.)
How a read parks and resumes
TcpStream::poll_read calls Registration::poll_io (runtime/io/registration.rs), which loops:
poll_ready: spend one unit of coop budget, then check the cached readiness. If the resource is not readable, store the task's waker inScheduledIoand returnPending.- Readiness is set: call the non-blocking
readsyscall. Ok(n): return it.Err(WouldBlock): the cached readiness was stale, soclear_readinessremoves it and the loop returns to step 1, which now parks the task.
clear_readiness passes the tick at which the readiness was observed. If the driver set new readiness at a later tick in the meantime, the clear is ignored, so an event that arrived between the syscall and the clear is not lost.
On the other side, one worker at a time runs the driver's turn (runtime/io/driver.rs): it calls mio::Poll::poll with room for max_io_events_per_tick events (1024 by default), and for each event it ORs the readiness into the ScheduledIo and wakes the stored wakers. A wake from the driver schedules the task through the scheduler rules of the previous lesson.
Example 1: AsyncFd for your own file descriptor
tokio::io::unix::AsyncFd registers any type that implements AsRawFd with the reactor. It does no I/O itself. readable().await waits for the readiness bit, and guard.try_io runs your non-blocking call and clears readiness when it returns WouldBlock, exactly like steps 2 and 3 above.
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use tokio::io::unix::AsyncFd;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let (a, mut b) = UnixStream::pair()?;
a.set_nonblocking(true)?;
let a = AsyncFd::new(a)?;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
b.write_all(b"hello").unwrap();
});
let mut buf = [0u8; 16];
loop {
let mut guard = a.readable().await?;
match guard.try_io(|inner| inner.get_ref().read(&mut buf)) {
Ok(result) => {
let n = result?;
println!("read {n} bytes: {}", String::from_utf8_lossy(&buf[..n]));
break;
}
Err(_would_block) => continue,
}
}
Ok(())
}read 5 bytes: helloThe file descriptor must be non-blocking; otherwise read would block the worker instead of returning WouldBlock. AsyncFd deregisters the descriptor when dropped, before the inner value closes it. It is Unix-only and needs the net feature plus enable_io.
The driver stack and parking
A runtime builds its drivers as a stack (runtime/driver.rs):
| Layer | Present when | Role |
|---|---|---|
| time driver | enable_time |
owns the timer wheel; computes the next deadline |
| signal driver | enable_io on Unix |
turns TOKEN_SIGNAL events into signal notifications |
| I/O driver | enable_io |
owns the mio Poll |
ParkThread |
I/O disabled | a condition variable used instead of epoll_wait |
enable_all() turns on both enable_io and enable_time. When a worker has no tasks, it parks through the stack: the time driver finds the nearest timer deadline and parks the I/O driver with that timeout, so the thread sleeps inside epoll_wait until either a socket event or the next timer. To wake a parked driver from another thread (a task was spawned from main, for example), Tokio triggers the waker registered under TOKEN_WAKEUP.
The timer wheel
The time driver stores every active timer in a hierarchical timing wheel: an array of slots where each slot holds the timers that expire in one time range (runtime/time/wheel/).
| Constant | Value | Source |
|---|---|---|
NUM_LEVELS |
6 | runtime/time/wheel/mod.rs |
LEVEL_MULT (slots per level) |
64 | runtime/time/wheel/level.rs |
| resolution | 1 ms (level 0 slot) | runtime/time/wheel/mod.rs doc comment |
MAX_DURATION |
(1 << 36) - 1 ms, about 2.2 years |
runtime/time/wheel/mod.rs |
| deadline rounding | up to the next millisecond (+ 999_999 ns) |
runtime/time/source.rs (deadline_to_tick) |
Level n has 64 slots of 64ⁿ ms each: 1 ms, 64 ms, about 4 s, 4 min, 4.6 h, and 12 days. Each level covers one slot of the level above. A timer is placed in the level chosen by the highest bit in which its deadline differs from the current time (level_for). Each level keeps a 64-bit occupied mask, so finding the next non-empty slot is a bit scan, not a loop. When time reaches a slot above level 0, its timers are moved down to finer levels; at level 0 they fire and wake their tasks. Insert and cancel are O(1).
A Sleep does not touch the wheel when it is created. Its first poll spends one unit of coop budget, creates the timer entry, and inserts it; dropping the Sleep removes it. reset moves an entry to a new deadline.
Example 2: Timer placement
The function below repeats the arithmetic of level_for in runtime/time/wheel/mod.rs for a wheel whose elapsed time is 0. The paused clock (from Time: sleep, timeout, and interval) confirms that each sleep ends exactly at its deadline.
use std::time::Duration;
use tokio::time::{Instant, sleep};
fn level_for(elapsed: u64, when: u64) -> usize {
const SLOT_MASK: u64 = (1 << 6) - 1;
const MAX_DURATION: u64 = (1 << 36) - 1;
let mut masked = elapsed ^ when | SLOT_MASK;
if masked >= MAX_DURATION {
masked = MAX_DURATION - 1;
}
let significant = 63 - masked.leading_zeros() as usize;
significant / 6
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let durations_ms = [10u64, 100, 5_000, 600_000, 259_200_000, 2_592_000_000];
for ms in durations_ms {
println!("{ms:>13} ms -> level {}", level_for(0, ms));
}
let start = Instant::now();
sleep(Duration::from_millis(600_000)).await;
println!("elapsed: {:?}", start.elapsed());
} 10 ms -> level 0
100 ms -> level 1
5000 ms -> level 2
600000 ms -> level 3
259200000 ms -> level 4
2592000000 ms -> level 5
elapsed: 600sTen minutes lands on level 3 (slots of about 4 minutes). As the clock approaches the deadline, the entry cascades through levels 2, 1, and 0 before it fires. Because deadlines round up to the next whole millisecond, a sleep never ends before its deadline and can end up to about 1 ms after it.
Anatomy of a task
tokio::spawn allocates one heap block per task, a Cell (runtime/task/core.rs) with three parts:
| Part | Contents |
|---|---|
Header |
state word, run-queue link, vtable pointer, owner id |
Core |
the scheduler handle, the task id, and a Stage: Running(future), Finished(output), or Consumed |
Trailer |
the owned-tasks list link and the JoinHandle's waker |
The future and its output share the same Stage memory. The cell is aligned to 128 bytes on x86_64 and aarch64 (#[repr(align(128))]) so two tasks never share a cache-line pair.
The state word (runtime/task/state.rs) is one AtomicUsize:
| Bit | Name | Meaning |
|---|---|---|
0b000001 |
RUNNING |
a thread is polling the future |
0b000010 |
COMPLETE |
the future finished; never unset |
0b000100 |
NOTIFIED |
the task is already in a run queue |
0b001000 |
JOIN_INTEREST |
a JoinHandle still exists |
0b010000 |
JOIN_WAKER |
the JoinHandle stored a waker in the trailer |
0b100000 |
CANCELLED |
abort or shutdown requested cancellation |
| bits 6 and up | reference count | one per Notified, Waker, JoinHandle, and the owned list |
A new task starts with three references (the owned-tasks list, the first Notified sent to the scheduler, and the JoinHandle) and with NOTIFIED | JOIN_INTEREST set (INITIAL_STATE). The memory is freed when the count reaches zero, whichever holder is last.
The vtable (runtime/task/raw.rs) holds functions generated for the concrete future and scheduler types: poll, schedule, dealloc, try_read_output, drop_join_handle_slow, drop_abort_handle, and shutdown, plus field offsets. Every other type (RawTask, JoinHandle, Waker) is a pointer to the Header, so they are all one word and need no generics.
The waker (runtime/task/waker.rs) is a RawWaker whose data pointer is the Header and whose vtable is WAKER_VTABLE (clone_waker, wake_by_val, wake_by_ref, drop_waker). Cloning increments the reference count. Waking runs transition_to_notified_*:
- task idle and not notified: set
NOTIFIED, add a reference, call the scheduler'sschedule; - task already notified, or complete: do nothing;
- task running: set
NOTIFIEDonly; the polling thread reschedules it whenpollreturnsPending.
Example 3: Many wakes, one poll
Because of the NOTIFIED bit, waking a task five times before it runs puts it in the run queue once.
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
struct Watched {
polls: Arc<AtomicUsize>,
waker: Arc<Mutex<Option<Waker>>>,
done: Arc<AtomicBool>,
}
impl Future for Watched {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
self.polls.fetch_add(1, Ordering::SeqCst);
if self.done.load(Ordering::SeqCst) {
return Poll::Ready(());
}
*self.waker.lock().unwrap() = Some(cx.waker().clone());
Poll::Pending
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let polls = Arc::new(AtomicUsize::new(0));
let waker = Arc::new(Mutex::new(None::<Waker>));
let done = Arc::new(AtomicBool::new(false));
let target = tokio::spawn(Watched { polls: polls.clone(), waker: waker.clone(), done: done.clone() });
tokio::spawn(async move {
let w = waker.lock().unwrap().take().unwrap();
for _ in 0..5 {
w.wake_by_ref();
}
done.store(true, Ordering::SeqCst);
});
target.await.unwrap();
println!("wakes: 5, polls: {}", polls.load(Ordering::SeqCst));
}wakes: 5, polls: 2On the current-thread runtime the target runs first (FIFO), stores its waker, and returns Pending. The second task wakes it five times: the first wake sets NOTIFIED and queues it, the other four see NOTIFIED and do nothing. The target is polled a second time and completes.
Future size and boxing
A task's Core stores the future inline, so the task allocation is at least the size of the future. The size of an async block is the size of its largest set of variables that are alive at the same .await, plus the state tag (Futures and async/await). A large array held across an .await makes every task of that type large.
spawn, block_on, and spawn_blocking move the future or closure by value several times before it reaches the heap. To avoid a stack overflow, Tokio boxes it first when it is larger than BOX_FUTURE_THRESHOLD: 2048 bytes in debug builds and 16384 bytes in release builds (runtime/mod.rs; checked in task/spawn.rs, runtime/runtime.rs, task/local.rs, runtime/blocking/pool.rs).
Example 4: Measuring and shrinking a future
use std::mem::size_of_val;
#[tokio::main]
async fn main() {
let small = async {
tokio::task::yield_now().await;
1
};
let big = async {
let buf = [0u8; 4096];
tokio::task::yield_now().await;
buf.len()
};
println!("small under 64 bytes: {}", size_of_val(&small) < 64);
println!("big at least 4096 bytes: {}", size_of_val(&big) >= 4096);
let boxed = Box::pin(big);
println!("boxed: {} bytes", size_of_val(&boxed));
println!("result: {}", tokio::spawn(boxed).await.unwrap());
}small under 64 bytes: true
big at least 4096 bytes: true
boxed: 8 bytes
result: 4096buf is alive across the .await, so it is part of the state machine. Box::pin moves it to the heap; the future that spawn receives is now one pointer (8 bytes on a 64-bit target). The alternatives are to allocate the buffer inside the block (vec![0u8; 4096], 24 bytes inline) or to end its scope before the .await. Exact sizes depend on the compiler version, so tests compare against bounds, not fixed numbers.
The multi-thread runtime requires Send futures because a stolen task is polled on another thread, and everything alive across an .await lives inside the task cell that moves. Spawning costs one allocation of the cell, an insert into the runtime's owned-tasks list (a set of mutex-protected lists), and a queue push.
Interview points
- The I/O driver waits on mio (epoll/kqueue) and registers sources edge-triggered (
EPOLLET). Tokio caches readiness perScheduledIoand clears it only when a syscall returnsWouldBlock. - A read: check readiness, try the syscall, on
WouldBlockclear readiness and park with the waker stored inScheduledIo. A driver tick counter prevents clearing newer readiness. AsyncFdregisters any non-blocking Unix fd;readable().awaitthenguard.try_io(...).- Parking a worker means waiting in
epoll_waitwith a timeout equal to the next timer deadline. - Timer wheel: 6 levels × 64 slots, 1 ms resolution, max about 2.2 years; deadlines round up;
Sleepregisters on first poll. - A task is one allocation: header (state, vtable), core (future or output), trailer (join waker). Wakers and
JoinHandles are pointers to the header plus a reference count. NOTIFIEDcoalesces repeated wakes into one queue entry.- Futures larger than 2048 bytes (debug) or 16384 bytes (release) are boxed by
spawn;Box::pinlarge futures yourself to keep tasks small.
Summary
- mio provides readiness; Tokio stores it in
ScheduledIoas 16 readiness bits, a 15-bit tick, and a shutdown bit. WouldBlockclears readiness and parks the task; a driver event sets readiness and wakes it.- The time driver sits on top of the I/O driver;
enable_allenables both. - Timers live in a 6-level, 64-slot wheel with 1 ms resolution and are inserted on first poll.
- The task state word holds
RUNNING,COMPLETE,NOTIFIED,JOIN_INTEREST,JOIN_WAKER,CANCELLED, and the reference count. - A new task has three references; the last holder frees the cell.
size_of_valmeasures a future;Box::pinreduces it to one pointer.
