sanskar.
← writing

The wakeup race

I built an async runtime in Rust around one promise: a task that says Pending must be polled again when it is ready, never concurrently, and never after its memory is gone. The queues, wakers, reactors, timers, io_uring, and consoles are what it took to keep that promise.

Poll::Pending is not sleep. It is a promise: do not poll me again until something changes, and I will arrange the notification that tells you when it does. The Rust standard library gives a future a way to make that promise. It does not give you the machine that keeps it.

That machine is an executor, a queue, a waker, a timer, an I/O driver, and a memory-lifetime story that still works when cancellation arrives at the worst possible instant. I built eddy, an open-source async runtime library in Rust, to find the smallest set of invariants that makes that machine trustworthy. The project now has current- and multi-thread schedulers, readiness I/O, timers, channels, a Linux io_uring backend, and a TUI and web console. The interesting part is not the inventory. It is the same invariant surviving each expansion.

WHAT RUST STOPS AT Future::poll Ready(value) · Pending Context · Waker · Pin the gap eddy fills in the missing machine executor · queue · reactor · timer spawn · park · wake · complete event socket · timer · channel waker record NOTIFIED run queue one notification worker poll Ready or Pending Pending registers the next event; Ready releases the join handle
Fig 1 The runtime is a loop, not a pile of APIs. Rust supplies the poll contract; eddy supplies the path from an external event back to the next poll.

Pending is a contract with three clauses

An async function compiles into a state machine. Calling poll advances that state machine until it either returns Ready(value) or reaches an operation it cannot finish yet and returns Pending. The Context carries a Waker; the future registers that waker with whatever it is waiting on. A socket, timer, semaphore, or channel eventually calls it.

That description hides three obligations for the runtime:

  1. A task must never be polled concurrently with itself. The future may contain self-references or !Send state, and Pin only helps if the executor respects the one-poll-at-a-time rule.
  2. A wake that arrives during a poll must not disappear between Pending and the task becoming idle.
  3. The task allocation must outlive every queue entry, join handle, and cloned waker that can still reach it.

The first implementation of eddy was deliberately boring: one current-thread queue, one task at a time, and a thread parker for the root future. That gave me a place to prove task ownership before adding work stealing. The project is published as a library source tree, not a promise that it is a Tokio replacement. Its value is that the machinery is small enough to inspect: crates/eddy contains the runtime, eddy-macros provides #[eddy::main] and combinators, and the console crates consume the same event stream used by the tests.

The task is one allocation with a state word in the middle

The central object is Cell<F, S>. It allocates once and stores a header, the future and scheduler state, and a trailer for the join result. The header is type-erased behind a RawTask, so a queue can hold tasks with unrelated future types without allocating a wrapper for each push.

ONE ALLOCATION · CELL<F, S> refcount · 48 bits flags · 16 bits RUNNING COMPLETE NOTIFIED CANCELLED Header state · vtable owner · queue link Core<F, S> Running(future) Finished(output) Trailer join waker output handoff queue reference · join handle · waker clones all point at the same cell
Fig 2 A task allocation is shared by three kinds of owners. The packed state word prevents concurrent polls; the reference count keeps the cell alive while a queue, join handle, or waker still points at it.

The state is one AtomicUsize: 48 bits of reference count and 16 bits of flags. The important flags are RUNNING, COMPLETE, NOTIFIED, and CANCELLED. Every transition is one compare-and-swap loop rather than a sequence of independent loads and stores. The shape is less about clever packing than about making the transition atomic:

  • RUNNING claims the right to poll.
  • NOTIFIED records a wake that happened while the task was running.
  • COMPLETE makes later wakes harmless.
  • CANCELLED lets the scheduler drop a task that has not started without polling it.

The refcount is not a GC. It represents concrete ownership: the join handle, a queued notification, and each cloned Waker. The final decrement deallocates the whole cell. That is why queue_next is intrusive and why the scheduler does not copy a task into a second allocation every time it wakes.

The wakeup race is the runtime

Here is the failure that gave the project its shape. A worker marks a task running and calls poll. The reactor fires in the middle of that poll and calls wake_by_ref. The future returns Pending. If the worker now clears RUNNING and checks only a queue flag that was already observed, the wake is gone. The future is parked forever and no test that only checks the happy path will tell you why.

THE SAME INTERLEAVING · TWO OUTCOMES Naive flags wake disappears poll starts wake Pending lost RUNNING = 1 wake ignored RUNNING = 0 Eddy's packed state wake is replayed poll starts wake Pending requeue RUNNING | 0 NOTIFIED = 1 OkNotified the wake is not a second queue entry; it is one bit that survives the poll
Fig 3 The same interleaving produces either a permanent hang or a requeue. In eddy, the wake is a bit in the same atomic state machine as RUNNING, so the transition out of poll can see it and return `OkNotified`.

This is the part I did not want to explain with a placeholder code block. The real waker vtable is four functions in waker.rs:

unsafe fn clone_waker(ptr: *const ()) -> RawWaker {
    let header = NonNull::new_unchecked(ptr as *mut Header);
    header.as_ref().state.ref_inc(); // +1
    RawWaker::new(ptr, &WAKER_VTABLE)
}

unsafe fn wake_by_ref_waker(ptr: *const ()) {
    let header = NonNull::new_unchecked(ptr as *mut Header);
    RawTask::from_raw(header).wake_by_ref();
}

The dangerous detail is the ownership difference. wake consumes the RawWaker reference; wake_by_ref borrows it and takes a fresh reference only if the state transition actually schedules the task. clone adds one. drop removes one. The comments in the implementation say +1 and -1 because this is not a place where a reader should have to reconstruct the accounting from a clever abstraction.

The test that earns this design is wake_during_poll_requeues_after_pending_not_lost. The future wakes itself immediately before returning Pending; the scheduler must observe one queued task after poll returns. A second test wakes twice before the next poll and asserts one queue entry, not two. The same transitions run under loom, where the scheduler explores the interleavings rather than hoping the machine chooses the interesting one.

Locality is not fairness

Once one task can be polled correctly, the scheduler can grow. The current-thread executor remains useful because it gives !Send futures a home and keeps the state machine easy to exercise. The multi-thread executor adds a Chase–Lev deque per worker, a shared intrusive injector, a LIFO slot, and random stealing.

The LIFO slot keeps a task that just woke close to the worker that was already talking to it. The local deque gives the owner cheap push/pop operations. An idle worker steals half of a random victim’s older work. When a deque is full, eddy moves the older half to the injector and keeps the new task local. Moving the new task would cause it to ping-pong between the injector and a worker just as it becomes hot.

LOCALITY · FAIRNESS · PROGRESS worker 0 · owner LIFO slot · 1 task · max 3 polls local deque · 256 tasks new work enters at the tail owner pops; thieves steal older work full? move older half injector shared intrusive list global fairness check every 61 ticks worker 1 idle → random victim steal half no convoy: each thief starts at a different victim The same task can be cache-hot without letting an always-ready local queue starve injected work.
Fig 4 The scheduler makes two opposing promises: keep recently communicating tasks cache-hot, and do not let a self-replenishing local queue starve outside work. The LIFO cap and the 61-tick injector check are the compromise.

The fairness rule is easy to miss because it is not in the deque algorithm. Every 61 ticks the worker checks the injector before its local queue. The cooperative budget is the other half of the defense: an always-ready channel loop consumes one unit per operation and, at exhaustion, wakes itself and returns Pending.

Some(0) => {
    cx.waker().wake_by_ref();
    emit(|| RuntimeEvent::BudgetExhausted { task: TaskId::current() });
    Poll::Pending
}

That tiny branch is what prevents loop { rx.recv().await } from owning a worker forever. unconstrained exists too, but it is an explicit escape hatch: the caller is choosing possible starvation rather than receiving it accidentally.

The kernel is another waker

The portable I/O API does not need to know whether a readiness notification came from epoll, kqueue, or IOCP. It needs a registration, a token, a readiness bit, and a task waker. On Linux, the token is a slab index rather than the raw file descriptor, so a stale event for a closed descriptor cannot wake a newly opened descriptor that reused the same number. A generation counter closes the remaining reuse window.

ONE TASK CONTRACT · THREE KERNEL DIALECTS Linux · epoll level-triggered readiness fd → token → Ready eventfd wakes the driver BSD/macOS · kqueue read/write filters merged fd → token → Ready EVFILT_USER wakes driver Windows · IOCP completion packets packet → token → Ready posted packet wakes driver ScheduledIo → Waker → queue
Fig 5 Three kernel interfaces, one task contract. The platform driver translates each event into ScheduledIo readiness; the task sees only a waker and a queue entry.

The operation loop is intentionally repetitive: await interest, attempt the nonblocking syscall, and clear readiness when the syscall says WouldBlock. Forgetting that last step is how edge-triggered reads turn into a hot loop. It is also why the I/O tests talk about drain-to-WouldBlock, not just “the socket read returned the right bytes.”

Timers take the same route. Eddy uses a six-level hierarchical wheel with 64 slots per level. Insertion and cancellation are intrusive operations; the occupied bitmap finds the next non-empty slot without scanning every bucket. The driver’s next park timeout is the wheel’s next expiration. A timer is therefore not a separate scheduler: it is another source of the same wake that the task state machine already knows how to record.

Cancellation is where ownership crosses the kernel boundary

Readiness lets a future retry a syscall. io_uring lets the kernel retain an operation after submission, and that changes the question. If a future owns a Vec<u8>, submits its pointer, and is then dropped, Rust can prove nothing about the kernel’s future write. The borrow checker cannot see a completion queue.

Eddy’s completion backend is Linux-only and feature-gated. An operation owns its buffer in an operation slab until its CQE arrives. If the future is cancelled first, the operation moves to an orphan slab and sends a best-effort async cancel. The buffer is still retained until the kernel answers. It is a slower-looking design than “free it on drop,” but it is the one that does not turn cancellation into a use-after-free.

IO_URING · THE BORROW CHECKER CANNOT SEE THE KERNEL submit poll drop CQE Futureowns Vec<u8> SQEkernel owns bytes orphan slabbuffer retained CQEreturn or release cancel is a state transition, not permission to free the buffer
Fig 6 Cancellation changes who is responsible for the buffer; it does not make the kernel forget the pointer. The orphan slab is the waiting room between a dropped future and a safe release.

Multishot accept and receive make the same ownership problem repeat. One SQE can produce many CQEs, so the operation remains live while IORING_CQE_F_MORE is set. Provided buffers are copied out before returning to the kernel’s buffer ring. If the stream is dropped, cleanup retains the state until the final completion instead of pretending a single-CQE lifecycle applies.

This is also where the honest limits belong. The backend is not the default path, it is Linux-only, and shared CI kernels are not sufficient evidence for every io_uring behavior. The verification document calls for a dedicated Linux kernel. That is less exciting than saying “supports io_uring,” but it is more useful to someone deciding whether to deploy it.

Make starvation a shape

Most runtime bugs are temporal. A log says that a task ran; it does not show that it waited behind a queue for 400 ms, was woken by the wrong resource, or monopolized a worker with 128 always-ready polls. Eddy emits task lifecycle events, wake sources, worker parks and steals, queue depth, I/O readiness, timer lateness, budget exhaustion, and poll-duration buckets.

The TUI and browser console consume the same length-prefixed event stream over a Unix socket. The replay in the repository is synthetic and deterministic, but the views are the same ones I use to reason about a live runtime: a task swimlane, a worker heatmap, a wake graph, and a poll-duration distribution.

ONE EVENT STREAM · FOUR WAYS TO SEE STARVATION eddy-console / RuntimeEvent Unix socket → TUI or WebSocket task lifecycle swimlanes echo idle ── queued ─ running ── idle accept idle ───── queued ─ running timer idle ───────────── fired worker queue heatmap w0w1 wake causality I/Oechojoin poll duration distribution 100ms warning starvation stops being an intuition when it has a lane, an edge, and a queue depth
Fig 7 One event stream becomes four questions. Swimlanes show waiting, the heatmap shows imbalance, the wake graph shows causality, and the distribution shows the handler that is actually blocking progress.

Eddy console replay: tasks, workers, and queue pressure The console replay is a real pty render of 889 deterministic frames, not a screenshot pasted over a README. It cycles through the task list, worker pressure, resource waits, and wake edges.

Eddy TUI detail replay: task histogram and filtering The second replay drills into a task, filters by name, and sorts by busy time. The point of the visual is not that a dashboard exists; it is that queue pressure and causality have somewhere concrete to appear.

The console was useful because it turned vague performance language into a falsifiable picture. “The runtime is slow” becomes “worker 0 is saturated, worker 1 is parked, the injector is not being checked, and the same task has a long right tail.” A warning lamp for a poll over 100 ms is not observability theatre if it points to the exact task and spawn location that caused the stall.

Correctness is a stack, not a badge

A normal test can tell me that a channel eventually delivered a value. It cannot tell me whether a wake was lost in one interleaving. Loom can explore that interleaving, but it cannot tell me whether a raw pointer violates provenance in a production-shaped allocation. Miri can find that, but it does not test a Windows completion packet. Each tool answers a different question.

A GREEN TEST SUITE IS NOT ONE KIND OF EVIDENCE functionalAPI behavior loomwake racesqueue interleavings Miriprovenanceintrusive unsafe differentialTokio tracescancel propertiessoak + watchdog platformsLinux io_uringWindows IOCPARM / QEMUsanitizers each layer is aimed at a different way the runtime can be wrong
Fig 8 The evidence ladder is organized by failure mode, not by the number of green checks. A runtime needs all of these because the bugs live at different boundaries.

The project keeps those boundaries explicit:

  • Functional tests cover task completion, channel semantics, timer order, and socket behavior.
  • Loom explores task state transitions, waker clone/drop races, Chase–Lev stealing, semaphore waiters, readiness registration, and timer cancellation.
  • Miri checks the unsafe core: task allocation, RawWaker, intrusive lists, and ReadBuf.
  • Differential tests compare bounded channels, select!, and timeouts against Tokio while allowing timer lateness to vary.
  • Property tests drop futures at random poll counts and assert that work lands in Completed or Abandoned, never in a silent third state.
  • Soak, watchdog, Linux io_uring, Windows IOCP, sanitizer, and ARM/QEMU jobs cover the boundaries a laptop cannot.

The most satisfying bug was not a failing assertion. Miri caught a provenance mistake in the waker path: reconstructing a pointer to a header was not enough to preserve the allocation provenance needed by the intrusive cell. The fix kept the original NonNull<Header> path intact. The model checker and the interpreter were not redundant; they were looking at different meanings of “safe.”

The numbers, with the miss left in

The quick benchmark is a local Apple Silicon macOS snapshot from cargo bench -p eddy --bench vs_tokio -- --quick. Lower is better. I am keeping the units in the table instead of turning them into one bar chart, because a 40-microsecond channel operation and an 11-millisecond spawn benchmark do not belong on the same visual axis.

WorkloadEddyTokioEddy / Tokio
spawn/join, 10k tasks11.221 ms3.6755 ms3.05x
ping-pong, 1M round trips531.54 us217.42 us2.45x
bounded channels40.286 us137.87 us0.29x
unbounded channels38.705 us132.52 us0.29x
mutex contention, 1 task35.924 us41.906 us0.86x
mutex contention, 16 tasks76.487 us53.796 us1.42x

The shape is more useful than the headline. Spawn/join and ping-pong lose because one allocation per task and the type-erased waker path are real costs. Channels win in this workload because the intrusive semaphore waiters and batching remove work. Mutex contention crosses over as concurrency rises. Instrumentation adds +3.1% median to the dedicated 10k spawn/join benchmark when enabled. These are not universal claims: they are one host, one quick run, and a reason to run the benchmark on your machine.

Try the argument, not just the API

The repository is intentionally easy to inspect and hard to mistake for a production guarantee. Start with the runtime’s smallest example:

fn main() {
    eddy::Runtime::new().block_on(async {
        let handle = eddy::spawn(async { 40 + 2 });
        assert_eq!(handle.await.unwrap(), 42);
    });
}

Then run the ordinary suite and the model suite:

cargo test -p eddy --all-targets
RUSTFLAGS='--cfg loom' cargo test -p eddy --lib
cargo bench -p eddy --bench vs_tokio -- --quick

The examples include an echo server, chat, HTTP server, TCP proxy, graceful shutdown, and spawn_blocking. The docs site has the architecture notes, cancellation contract, verification matrix, and the 19-phase roadmap. The public repo is sanskarpan/eddy-runtime; the rendered docs are at sanskarpan.github.io/eddy-runtime.

The lesson is smaller than the runtime

I started with Future::poll and one current-thread queue. Everything after that was an attempt to introduce one new source of concurrency without weakening the same promise: a pending task is reachable, a running task is exclusive, and a task that can still be woken is still alive.

The work-stealer preserves it across workers. The reactor preserves it across kernel readiness. The timer wheel preserves it across time. The completion backend preserves it across a future being dropped while the kernel still owns a buffer. The console makes violations visible. Loom, Miri, differential tests, and platform jobs try to break it from different directions.

Eddy is not Tokio, and the benchmark table says that plainly. The useful result is not a faster general-purpose runtime. It is a runtime whose expensive decisions are visible enough to argue about: one bit that prevents a lost wake, one queue rule that prevents starvation, one orphan slab that prevents a kernel use-after-free, and one dashboard that lets you see when the argument stops holding.


← back to writing