One page of async Rust
Summary
Implementing a fake-time async executor in Rust. Manually poll Futures, using a custom Waker to pass "Yield" commands (like Sleep) to the executor, which orders tasks by fake time delays.
Rust async simplifies virtual simulations
A new implementation of Rust’s async trait shows that developers can build virtual-time simulations without relying on heavy external runtimes. This approach uses the language's built-in state machine generation to manage complex task transitions with minimal boilerplate code.
The simulation uses fake virtual time to process tasks through a series of steps. Instead of using real-world delays like sleep(), the system updates variables to advance a global clock. This ensures that every mutation happens in the correct chronological order without wasting CPU cycles on idling.
Rust's compiler automatically transforms async functions into state machines. This removes the need for developers to manually write large match statements to track the progress of individual tasks. The entire boilerplate for this low-level executor fits on a single printed page.
Futures act as state machines
Calling an async function in Rust does not execute its code immediately. Instead, it returns a Future, which holds the initial state of a function's execution. The compiler warns users that these values do nothing unless they are polled.
The Future::poll method is the primary mechanism for advancing these state machines. It requires two specific arguments to function correctly:
- Pin<&mut Self>: A wrapper that ensures the data does not move in memory.
- &mut Context: A structure that provides access to a Waker for task resumption.
- Poll<Self::Output>: An enum that returns either Ready or Pending.
The Pin type is necessary because Rust futures often contain self-referential pointers. If a Future moved to a different memory address while a pointer was active, the program would crash. Moving the Future into a Box on the heap is the simplest way to immobilize it for simulation purposes.
Wakers require low level pointers
The Context argument in the poll method acts as a wrapper for a Waker. In standard async runtimes, the Waker tells the executor when a task is ready to run again. The simplest way to satisfy this requirement is using Waker::noop(), which does nothing when called.
Creating a custom Waker usually requires interacting with RawWaker and RawWakerVTable. This process mirrors manual object-oriented programming in C. It involves raw pointers and unsafe code blocks rather than the type-safe traits typical of modern Rust.
The Wake trait offers a safer alternative for constructing wakers. However, this trait imposes specific architectural restrictions on how primitive futures operate. For simple simulations, the Wake trait often adds more complexity than it solves.
Smuggling commands through the context
A primitive Future typically returns Poll::Pending when it needs to wait for an external event. In a virtual-time simulation, the task needs to tell the executor how long it wants to wait. Since Poll::Pending cannot carry a data payload, developers must use a side-channel.
The Yield enum serves as this side-channel by carrying specific commands. It can represent three distinct states for the executor to process:
- Run: The task is ready to continue immediately.
- Sleep(u32): The task wants to pause for a specific number of virtual ticks.
- Done: The task has finished its execution and can be dropped.
The executor "smuggles" a mutable pointer to a Yield value through the Waker. When a primitive future is polled, it overwrites this value before returning Poll::Pending. This allows the executor to see exactly why the task suspended itself.
Managing time with binary heaps
The simulation executor maintains a BinaryHeap to track multiple concurrent tasks. This data structure acts as a min-heap, always keeping the task with the earliest wake-up time at the top. This allows the simulation to jump directly to the next scheduled event.
Each Task struct tracks its own wake_up time as a u32 integer. When a task yields a Sleep(delay) command, the executor adds that delay to the current wake_up time. The executor then pushes the task back into the heap for future processing.
This loop continues until the heap is empty. Because the simulation uses fake time, it can process 7.5 million years of virtual activity in a fraction of a second. The speed of the simulation is limited only by the complexity of the task logic and the overhead of the heap operations.
Safety and performance considerations
The use of unsafe code to pass pointers through the Waker requires careful validation. Tools like Miri can check for undefined behavior during execution. In this implementation, Miri confirms the pointer manipulations are valid, provided the borrowed data lives long enough.
One potential weakness involves how the compiler perceives the mutation of the Yield value. If the compiler does not realize that poll() can change the value through the raw pointer, it might perform incorrect optimizations. Using UnsafeCell or similar primitives can mitigate these risks in production environments.
This minimal executor demonstrates that Rust's async system is highly extensible. Developers do not need to import Tokio or async-std to utilize async/await syntax. For specialized use cases like discrete-event simulations, a custom, lightweight executor provides better control and less overhead.
The final demo project shows tasks running at different frequencies. Task 1 might wake up every tick, while Task 2 wakes up every two ticks. The BinaryHeap ensures they always execute in the correct order, maintaining the integrity of the shared simulation state.
Future iterations of this pattern could support more complex commands. An executor could handle IO requests or inter-task communication using the same Yield mechanism. By keeping the core logic simple, the system remains easy to debug and verify.
Related Articles
Developer abandons Rust web app after years, migrates to Node.js
A programmer's journey from Pascal to C, then web dev in PHP/Python, and finally Rust for a web app. Despite loving Rust's control and safety, they switched to Node.js due to faster iteration, better web ecosystem, and type-safe templates, concluding Rust excels in CPU-heavy tasks but Node.js is more practical for dynamic web development.
A terminal weather app with ASCII animations driven by real-time weather data
Weathr is a terminal weather app with ASCII animations for rain, snow, and more, using real-time data from Open-Meteo. It supports auto-location, configurable units, and offline simulation.
Stay in the loop
Get the best AI-curated news delivered to your inbox. No spam, unsubscribe anytime.
