Come Async You Are

corentin-core1 pts0 comments

Come Async You Are | Corentin Corgié

Bring your own async runtime to ruxe.

No, this post isn’t about hearing Nirvana on shuffle. It’s about async events reaching a synchronous store on their own schedule.

ruxe is my Redux-flavored Rust learning library: state lives in one store, and it changes only when you dispatch an event through a reducer. Last time I got the compiler to guarantee that each state slice has exactly one reducer, which is what makes running them in parallel safe, without a lock. That post was about how the store turns an event into new state. This one is about the step before that: how events reach the store.

The store is synchronous. On its own it has no way to take an event from work that finishes later, and real systems are mostly made of that kind of work: an MQTT message coming in, a system signal firing, an SMS arriving over mmcli, or a periodic job ticking away. In the energy-management systems I have in mind, a service polls the meters and inverters on a period and feeds each reading into the store as an event.

flowchart LR<br>subgraph add["Async work — what this post adds"]<br>direction TB<br>S["socket frame"]<br>T["timer tick"]<br>J["finished job"]<br>end<br>S -.-> Store<br>T -.->|dispatch, later| Store<br>J -.-> Store<br>App([App code]) -->|dispatch event| Store<br>Store -->|state + event| Reducer<br>Reducer -->|new state| Store<br>Store -->|read| App

Adding async isn&rsquo;t hard. The constraint I put on it matters more: it shouldn&rsquo;t make ruxe depend on an async runtime. Someone on tokio, on async-std, on smol, or on their own executor should be able to use it. Keeping that constraint is less obvious than it sounds.

Why not just ship the synchronous store?#

A fair question before the design: why build an async layer into ruxe? It could stop at the synchronous store and let you wire it into your own runtime.

The reason not to is that driving a single-writer store from many async tasks has an obvious do-it-yourself answer that&rsquo;s wrong. Better to provide the one correct driver than have every user rebuild it, a little differently each time. And it costs nothing if you don&rsquo;t use it: the synchronous store is unchanged, and async stays opt-in. What ruxe ships is one primitive, not a framework.

What &ldquo;runtime-agnostic&rdquo; actually costs#

Rust&rsquo;s async is runtime-agnostic at the language level: async/await and the Future trait live in the standard library. The standard library then stops. There&rsquo;s no executor and no spawn, so running a task means naming a runtime.

For a library, that&rsquo;s a real constraint: spawn a task and you&rsquo;ve picked a runtime for everyone who depends on you. The async book puts it plainly:

Libraries exposing async APIs should not depend on a specific executor or reactor, unless they need to spawn tasks or define their own async I/O or timer futures. Ideally, only binaries should be responsible for scheduling and running tasks.

Most libraries skip it, because staying agnostic costs conditional compilation and shims, and so the ecosystem standardized on tokio, now a dependency of tens of thousands of crates. There&rsquo;s even a wg-async story, &ldquo;Barbara writes a runtime-agnostic library&rdquo;, on how awkward the path is.

For an application, committing to tokio is fine. A library might run somewhere tokio can&rsquo;t. Tokio needs an operating system and the standard library, and plenty of embedded targets have neither; they run async on embassy instead. My own hardware runs a full Linux, so tokio would work for me. But I can&rsquo;t decide the runtime for the people who use ruxe.

ruxe does no I/O. It never reads a byte; the socket, the MQTT client, the timer live in the user&rsquo;s tasks, and only events cross into the store. (I/O is the sharper trap: tokio&rsquo;s AsyncRead isn&rsquo;t the one async-std and smol use, so touching a socket picks a camp.) This is the same split sans-IO protocol libraries make: the logic is a pure state machine, the I/O and the runtime are the caller&rsquo;s. ruxe just gets it for free, since a store has no bytes to move. The other half is spawning, and it&rsquo;s the rule the rest of the design builds on: ruxe never spawns.

What &ldquo;never spawn&rdquo; forces#

That line does more than it looks. If the library can&rsquo;t spawn, it can&rsquo;t run a background loop of its own, which means it can&rsquo;t own the store inside a task nobody handed it. Someone else has to drive it.

Start from ownership. dispatch takes &mut self:

pub fn dispatch(&mut self, event: E) -> Result(), DispatchError>

One writer at a time, enforced by the compiler. Async ingestion means many tasks, on many threads, all wanting to dispatch. They can&rsquo;t all hold &mut Store.

Arc> would compile. I didn&rsquo;t want it. It adds contention, and it gives up the property that makes the store worth having: dispatch as a single ordered sequence of state transitions. A mutex serializes too, but in lock-wakeup order, and...

rsquo store async runtime ruxe library

Related Articles