Without boats, dreams dry up
Last week, Tyler Mandry published an interesting post about a problem that the Rust
project calls “Barbara battles buffered streams.” Tyler does a good job explaining the issue, but
briefly the problem is that the buffering adapters from the futures library (Buffered
and
BufferUnordered
) do not interact well with for await
if the processing in the body is
asynchronous (i.e. if it contains any await
expressions).
I think we can better understand the problem if we examine it visually. First, let’s consider the
control flow that occurs when a user processes a normal, non-asynchronous Iterator
using a for
loop:
┌── SOME ────────────────┐
╔═══════════════╗ ╔═══════▼═══════╗
║ ║▐▌ ║ ║▐▌
──────▶ NEXT ║▐▌ ║ LOOP BODY ║▐▌
║ ║▐▌ ║ ║▐▌
╚════════════▲══╝▐▌ ╚═══════════════╝▐▌
▀▀│▀▀▀▀▀▀▀▀▀│▀▀▀▀▘ ▀▀▀▀▀▀▀│▀▀▀▀▀▀▀▀▀▘
│ └───────────────────┘
└── NONE ──────────────────────────────▶
The for loop first calls the iterator’s next
method, and then passes the resulting item (if there
is one) to the loop body. When there are no more items, it exits the loop.
When we developed the Pin API, our vision was that “ordinary users” - that is, users using the “high-level” registers of Rust, would never have to interact with it. We intended that only users implementing Futures by hand, in the “low-level” register, would have to deal with that additional complexity. And the benefit that would accrue to all users is that futures, being immovable while polling, could store self-references in their state.
Things haven’t gone perfectly according to plan. The benefits of Pin
have certainly been accrued -
everyone is writing self-referential async functions all the time, and low-level concurrency
primitives in all the major runtimes take advantage of Pin
to implement intrusive linked lists
internally. But Pin
still sometimes rears its ugly head into “high-level” code, and users are
unsurprisingly frustrated and confused when that happens.
In my experience, there a three main ways that this happens. Two of them can be solved by better
affordances for AsyncIterator
(a part of why I have been pushing stabilizing this so hard!). The
third is ultimately because of a mistake that we made when we designed Pin
, and without a breaking
change there’s nothing we could about it. They are:
Future
in a loop.Stream::next
.Future
behind a pointer (e.g. a boxed future).I wanted to follow up my previous post with a small note elaborating on the use of
coroutines for asynchrony and iteration from a more abstract perspective. I realized the point I
made about AsyncIterator
being the product of Iterator
and Future
makes a bit more sense if
you also consider the “base case” - a block of code that is neither asynchronous nor iterative.
It’s also an excuse to draw another fun ASCII diagram, and I’ve got to put that Berkeley Mono license to good use.
…In my previous post, I said that the single best thing the Rust project could do for
users is stabilize AsyncIterator. I specifically meant the interface that already exists in
the standard library, which uses a method called poll_next
. Ideally this would have happened years
ago, but the second best time would be tomorrow.
The main thing holding up the AsyncIterator
stabilization is a commitment by some influential
contributors of the project to pursue an alternative design. This design, which I’ll call the
“async next” design, proposes to use an async method for the interface instead of the poll method of
the “poll next” design implemented today. In my opinion, continuing to pursue this design is a
mistake. I’ve written about this before, but I don’t have the sense my post was
fully received by the Rust project.
Yosh Wuyts, a leading contributor to the async working group, has written his own post about
why the async next design is preferable to poll next. A lot of this is structured as an attempted
refutation of points made by me and others about problems with the async next design. I do not find
the argument in this post compelling, and my position about what the project should do is unchanged.
I’ve written this to attempt to express again, in more detail and more definitively, why I believe
the project should accept the poll next design and stabilize AsyncIterator
now.
Four years ago today, the Rust async/await feature was released in version 1.39.0. The announcement post says that “this work has been a long time in development – the key ideas for zero-cost futures, for example, were first proposed by Aaron Turon and Alex Crichton in 2016”. It’s now been longer since the release of async/await than the time between the first design work on futures and the release of async/await syntax. Despite this, and despite the fact that async/await syntax was explicitly shipped as a “minimum viable product,” the Rust project has shipped almost no extensions to async/await in the four years since the MVP was released.
This fact has been noticed, and I contend it is the primary controllable reason that async Rust has developed a negative reputation (other reasons, like its essential complexity, are not in the project’s control). It’s encouraging to see project leaders like Niko Matsakis recognize the problem as well. I want to outline the features that I think async Rust needs to continue to improve its user experience. I’ve organized these features into features that I think the project could ship in the short term (say, in the next 18 months), to those that will take longer (up to three years), and finally a section on a potential change to the language that I think would take years to plan and prepare for.
…Async/await syntax in Rust was initially released to much fanfare and excitement. To quote Hacker News at the time:
This is going to open the flood gates. I am sure lot of people were just waiting for this moment for Rust adoption. I for one was definitely in this boat.
Also, this has all the goodness: open-source, high quality engineering, design in open, large contributors to a complex piece of software. Truly inspiring!
Recently, the reception has been a bit more mixed. To a quote a comment on Hacker News again, discussing a recent blog post on the subject:
I genuinely can’t understand how anybody could look at the mess that’s Rust’s async and think that it was a good design for a language that already had the reputation of being very complicated to write.
I tried to get it, I really did, but my god what a massive mess that is. And it contaminates everything it touches, too. I really love Rust and I do most of my coding in it these days, but every time I encounter async-heavy Rust code my jaw clenches and my vision blurs.
Of course, neither of these comments are completely representative: even four years ago, some people had pointed concerns. And in the same thread as this comment about jaws clenching and vision blurring, there were many people defending async Rust with equal fervor. But I don’t think I would be out of pocket to say that the nay-sayers have grown more numerous and their tone more strident as time has gone on. To some extent this is just the natural progression of the hype cycle, but I also think as we have become more distant from the original design process, some of the context has been lost.
Between 2017 and 2019, I drove the design of async/await syntax, in collaboration with others and building on the work of those who came before me. Forgive me if I am put a bit off when someone says that they don’t know how anyone could look at that “mess” and “think that it was a good design,” and please indulge me in this imperfectly organized and overly long explanation of how async Rust came to exist, what its purpose was, and why, in my opinion, for Rust there was no viable alternative. I hope that along the way I might shed more light on the design of Rust in a broader and deeper sense, at least slightly, and not merely regurgitate the justifications of the past.
…I want to address a controversy that has gripped the Rust community for the past year or so: the choice by the prominent async “runtimes” to default to multi-threaded executors that perform work-stealing to balance work dynamically among their many tasks. Some Rust users are unhappy with this decision, so unhappy that they use language I would characterize as melodramatic:
The Original Sin of Rust async programming is making it multi-threaded by default. If premature optimization is the root of all evil, this is the mother of all premature optimizations, and it curses all your code with the unholy
Send + 'static
, or worse yetSend + Sync + 'static
, which just kills all the joy of actually writing Rust.
It’s always off-putting to me that claims written this way can be taken seriously as a technical criticism, but our industry is rather unserious.
…One of the most famous anecdotes that forms the basis of the United States’ political self-identity is the story of an interaction between Benjamin Franklin and Elizabeth Willing Powel after the Constitutional Convention of 1787, which established the United States’ present form of government. Powel asked Franklin what sort of government the U.S. was to have, to which he replied: “a republic, if you can keep it.”
Given the self-conscious references to “constitutions” and “checks and balances” in the Rust project’s recent governance RFC and the discourse around it, some further reflection on this quote and its implications about governance as such might now be appropriate for the project and its community.
…I have been devoting a lot of my free time in the past month to thinking about structured concurrency, and a blog post about that is coming soon, but first I want to revisit iterators and generators.
In a previous post, I wrote about one of the hardest problems for generators: self-referential generators. Unlike the Future trait when we were designing async functions, the Iterator trait is already stable, and it does not take a pinned reference to itself. This means an Iterator cannot be self-referential.
…One of the main emphases of my recent posts has been that I believe shipping generators would solve a lot of user problems by making it easy to write imperative iterative code, and especially to make that iterative code interact well with asynchrony and fallibility as well. One thing that frustrates me about the situation is that generators have been nearly ready to ship for years now, but very little visible progress has been made. In particular, the core compiler transform to take a generator and produce a state machine already exists, because it’s exactly how async functions are implemented.
…