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.
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:
- A task must never be polled concurrently with itself. The future may contain self-references or
!Sendstate, andPinonly helps if the executor respects the one-poll-at-a-time rule. - A wake that arrives during a poll must not disappear between
Pendingand the task becoming idle. - 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.
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:
RUNNINGclaims the right to poll.NOTIFIEDrecords a wake that happened while the task was running.COMPLETEmakes later wakes harmless.CANCELLEDlets 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.
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.
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.
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.
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.
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.
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.
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, andReadBuf. - 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
CompletedorAbandoned, 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.
| Workload | Eddy | Tokio | Eddy / Tokio |
|---|---|---|---|
| spawn/join, 10k tasks | 11.221 ms | 3.6755 ms | 3.05x |
| ping-pong, 1M round trips | 531.54 us | 217.42 us | 2.45x |
| bounded channels | 40.286 us | 137.87 us | 0.29x |
| unbounded channels | 38.705 us | 132.52 us | 0.29x |
| mutex contention, 1 task | 35.924 us | 41.906 us | 0.86x |
| mutex contention, 16 tasks | 76.487 us | 53.796 us | 1.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