One page of async Rust
2026-02-17 19:42https://dotat.at/@/2026-02-16-async.html
I'm writing a simulation, or rather, I'm procrastinating, and this blog post is the result of me going off on a side-track from the main quest.
The simulation involves a bunch of tasks that go through a series of
steps with delays in between, and each step can affect some shared
state. I want it to run in fake virtual time so that the delays are
just administrative updates to variables without any real
sleep()ing, and I want to ensure that the mutations happen in the
right order.
I thought about doing this by representing each task as an enum
State with a big match state to handle each step. But then I
thought, isn't async supposed to be able to write the enum State and
match state for me? And then I wondered how much the simulation
would be overwhelmed by boilerplate if I wrote it using async.
Rather than digging around for a crate that solves my problem, I thought I would use this as an opportunity to learn a little about lower-level async Rust.
Turns out, if I strip away as much as possible, the boilerplate can fit on one side of a sheet of paper if it is printed at a normal font size. Not too bad!
But I have questions...
no subject
Date: 2026-02-19 15:13 (UTC)I think part of the terminological problem is that "coroutine" is one of those words with a broad sense and a narrow sense. In the broad sense, it means anything that looks like an ordinary function in the source code (containing ifs and whiles and breaks and continues etc) but some things in the middle of the function cause the whole thing to be suspended for later resumption. That's exactly the thing that my C preprocessor machinery implements: it's not concerned with input and output data streams at all, only with suspending and resuming. Data flow is built on top of that, at the programmer's whim.
In the narrower sense, coroutines come with built-in data flow of some kind – the
yieldstatement in Python, orco_yieldin C++20, or similar – and some kind of a notion of specifying which other coroutine they need to run next. For example, if you insert a third "adapter" coroutine in between the producer and consumer in the classic example, then the adapter has two semantically distinct ways to suspend itself: it can yield to the producer when it wants a new item, or yield to the consumer and give it an item. I guess this is where your description of having a fixed number of data streams fits in.An async function is a coroutine in the broad sense, but not the narrow sense. In place of a small set of fixed data streams, its suspend operation involves specifying under what circumstances it next wants to be resumed, in the form of whatever expression you wrote
.awaitafter. And that might be different every time. For example, I have a small HTTP proxy server I wrote as a combination of Baby's First Async Rust Program and a testing harness for PuTTY's proxy support, and within a single async task it will.awaitall sorts of things: await the socket from its client being readable (to find out what it's been asked to connect to), await a socket connect operation (once it knows, and tries to actually connect to there), await the client socket being writable (when it sends responses back).That's where the executor comes in: all the async things currently in progress have to be managed by a central component which knows what set of them exist, and what each one is currently waiting for. And quite often most of them will be waiting for external things like network activity or user input, so the async executor probably also wants to manage the top-level event loop that's listening to all of those sources to see which one does something next – and when there is network or GUI or terminal input, probably the executor's response will simply be to wake up one particular suspended async function which was waiting for that particular stimulus.
If two async tasks want to pass data items between themselves in the style of sensu stricto coroutines, then they'd probably do it by creating some kind of queue object: one task places items into the queue as it produces them, and the other takes them out. When the receiver finds the queue is empty, it uses
.awaiton the queue object itself (or rather, on its read end), asking the executor to wake it up the next time the queue is non-empty; conversely, when the consumer has an item to put in the queue but finds it's full, it.awaits the write end of the queue, asking the executor to wake it up the next time the queue has space to accept another item. So you can implement this kind of data transfer within the general async framework, but it's just one among many things you can await.no subject
Date: 2026-02-22 19:58 (UTC)More I'm working out a picture as I go along!...
Hm. Does this get closer to a taxonomy?
1. Name a "Lowest common denominator coroutine" a 'function' which can contain points at which its execution can be paused and later resumed. This is only *useful* with one of the features 2 or 3 below, hence people tending to think of "coroutine" as 1+2, or 1+3 or 1+4 depending..
This is also the concept that rust implemented in the compiler as a way of implementing async, but didn't stablise any way of using it other than async. IIUC for a long time rust called this "generator", but more recently renamed it to "coroutine", freeing up "generator" as a name for a putative stable syntax for exposing a subset of that machinary.
2. rust (and other implementations) have some fancy compilation to work out what local memory a coroutine needs, so you don't need an arbitrary-sized stack for each one. This makes coroutines a lot more practical if there might be a lot of them (as with async code with lots of parallel tasks, but could apply to #3 situations too).
3. One significant way that coroutines can pause, is to read an extra value from an input or write an extra value from an output. One of the simplest cases and fairly common is a generator (coroutine yielding an output stream one item at a time), but there are lots of generalisations of this. I think of this as control-flow untangling, the sort of thing you talk about in your article with a consumer and a producer both written as functions with yield. The sort of thing I remember talking about before, where you have cpu code and a single thread and determinism and no blocking, but what's interesting is the clearest way of running it in the necessary order.
4. Another significant way that coroutines can pause is what async code does, of pausing when it needs input from the outside world, and wanting to be resumed at some unknown point later.
async does #4, but may not have a way of doing #3. generators do #3, but may not have a way of doing #4.
Your articles are quite old, they may not represent what you think now! But when I read them, most of the examples seemed like examples of #3 (and which was what drew my interest). When I said "some number of input or output streams", I didn't mean a particular number, I meant, if they have *no* input or output streams, then they're not doing #3.
And it seems like by default async can yield "blocked on external IO, I know where to go next but maybe run some unrelated code until I'm ready?" and generators can yield "yo, intermediate value N ready, here, I know where to go next, run me again when you want the next one". But you can't do both unless your coroutine can intermediate-return both kinds of value. (Internet says python didn't originally have yield from async functions but introduced it later.)
It feels like doing #4 ought to do #3 too, but I'm not sure it naturally does, because the poll return doesn't inherently have a way of returning a value, but in fact the executor is ready to re-run the task immediately without any need for a waker to be deferred. Unless the implementation *also* has yield, or you can hack in a return value where the implementation doesn't expect one, or you set up a pipe or otherwise mediates the control flow through the OS or a separate queue structure.
Although I also had in mind, that you could view the places where async code accesses the network or file system or keyboard etc as all being separate input or output streams, just ones that are chosen by the async function itself. And if the calling function wants to inject those outside world things itself, it needs some other way of providing those inputs.
However, I may have got the wrong idea what the coroutine code actually used in PuTTy does, I haven't read it yet, only the examples in the articles. The article talked about the calling code providing packets of input to a coroutine implementing one layer of an ssh protocol, which made me think of the coroutine not doing networking itself, but that may not be true in the real code, or may not be relevant.