Disclaimer: an arbitrarily deliberate use of strongly worded language coming next. No offense implied or intended. Nor is there any implicit attempt to devalue the work of countless people that lead up to this point. So as to make this discussion as constructive as possible: for every issue / opinion / argument presented the LCD solution will be provided. Reader's discretion advised.
I've been looking into the Rust's approach to async/await
for a while now. The deeper into the weeds, the stronger my "something's clearly wrong here" sense has got. From the lack of any comprehensive end-to-end resource on the matter to the mountain of edge cases to consider.
The following is an attempt to piece together the current state of affairs - as of June 2025, one; highlight some of the most glaring (IMPO) shortcomings to the current design and implementation, two; as well as to brainstorm the most sensible / efficient / productive way forward - from now on.
In order from the least to the most significant:
[1]
On the documentation side, having both the async
and the await
keyword say:
We have written an async book detailing
async
/await
and trade-offs compared to using threads.
When the actual book in question immediately contradicts the "written" part:
NOTE: this guide is currently undergoing a rewrite after a long time without much work. It is work in progress, much is missing, and what exists is a bit rough.
Lands somewhere between "a bit surprising" and "outright embarrassing" for me. Why does it say "written" when it's clearly "unfinished"? Is the documentation wrong/out-of-date? Is there some fully "written" version of the book elsewhere that the new "undergoing" rewrite fails to mention? What should be expected of a newcomer who stumbles upon this bit - other than utter confusion?
Solution/s
- use the Chapter 17: Fundamentals of Asynchronous Programming: Async, Await, Futures, and Streams - The Rust Programming Language as an official endorsed alternative
- remove the external link altogether in favour of an upgrade to the documentation to the
std::future::Future
itself; with clear examples and use cases in line withstd::pin::Pin
[2]
Fragmentation of the ecosystem.
I'm not talking about the choice in between tokio
or smol
or async_std
. I'm talking about the absolutely gargantuan amount of careful plug-A-from-X with use-B-from-Y all while making sure neither X or Y use
the set of utils
or wrappers
or adapters
from Z which you might still need D and E from; all the while making sure you don't use any of the F or G or H from it: since they were explicitly reimplemented in X or Y altogether and are no longer compatible.
Example: opening a file, reading it line by line, enumerating each one in the process. [1]
std
fn read_file_sync() -> std::io::Result<()> {
// #1
use std::fs::OpenOptions;
let mut open = OpenOptions::new();
let file = open.read(true).open("./foo.txt")?;
// #2
use std::io::{BufRead, BufReader};
let lines = BufReader::new(file).lines().enumerate();
for (i, line) in lines {
println!("`{i}`: `{}`", line?);
}
Ok(())
}
tokio
async fn read_file_async() -> std::io::Result<()> {
// #1
use tokio::fs::OpenOptions;
let mut open = OpenOptions::new();
let file = open.read(true).open("./foo.txt").await?;
// #2
use tokio::io::{BufReader, AsyncBufReadExt};
// ^ but not `use futures::AsyncBufReadExt;`
let lines = BufReader::new(file).lines().enumerate();
// #3 - `LinesStream` is in a separate crate,
// locked behind its own feature
use tokio_stream::wrappers::LinesStream;
let stream = LinesStream::new(lines);
// #4 - for `enumerate()`?
use futures::StreamExt;
// ^ but not `use tokio_stream::StreamExt;`
let mut lines = stream.enumerate();
while let Some((i, line)) = lines.next().await {
// fairly intuitive, isn't it?
println!("`{i}`: {}", line?);
}
Ok(())
}
This isn't about tokio
alone. smol
has their futures_lite
which reinvents the AsyncBufReadExt
wheel yet again. async_std
has its own set. tokio::pin!
is not the same as std::pin:pin!
while its tokio::join!
seems identical to futures::join!
with no parallel for futures::join_all
at all.
Poll-based async/await
is sufficiently hard as it is. There are more than enough variables to keep track of: given the next point - especially. Complicating things further by reduplicating / reinventing / rehashing the same few methods across a dozen different crates makes no sense.
To be perfectly clear: this isn't about opting in/out of nightly
or unstable
channels with its AsyncIterator
and/or core::stream::Stream
. Rather: minimizing the amount of friction and cognitive load people must subject themselves to. Both newcomers and people familiar with the sync side only, alike.
Solution/s
- define a clear-cut standard of
macro_rules!
/trait
s instd::future
/std::stream
- extract / merge / re-export the most widely used ones into a single crate - fully independent from any given asynchronous runtime / executor / approach / philosophy at large
[3]
My memory might be playing a few tricks on me at this point, yet for some reason I still remember rather well a handful of comments with regards to the way this language handled assumptions. Especially the assumptions regarding the ability of the developer behind it to do the right thing.
One phrase in particular stuck out more than usual. It was -
The Pit of Success: in stark contrast to a summit, a peak, or a journey across a desert to find victory through many trials and surprises, we want our customers to simply fall into winning practices by using our platform and frameworks. To the extent that we make it easy to get into trouble we fail. - Falling Into The Pit of Success
From the borrow checker to the exclusive/mutable vs shared/read-only &
's to the Sync
and Send
markers: everything seemed to have been built around the same few core tenets.
- people are not that smart: no matter how strongly they might feel of the contrary
- they will make mistakes: regardless of the extent of their knowledge and experience
- it is not their fault alone: even the best craftsman can only do so much with a horrible tool
Which is a perfectly reasonable set of assumptions to hold.
Unless we're talking about async/await
.
Here you're required to be [1] expert enough to [2] avoid all the mistakes you can possibly make while porting any and all of the blocking code you have into the realm of async/await
; and should you fail at such a clearly trivial task - it is most certainly [3] your own fault alone. No exceptions.
If you fail at any of the three, things become even more interesting. You code will compile perfectly fine. It will run perfectly fine. Some of the time. Until a section of your code that never once blocked during a standard #[test]
run gets busy processing some abnormally large chunk of data.
Suddenly: things just freeze. Until they don't. Until they do again. Reproducible? Some of the time. Unexpected? Definitely. Infuriating? Always. If only you were [1] a tiny bit smarter you would have realized that it is absolutely critical for you to [2] never leave any section of code, no matter how seemingly transitory at a glance, to chance with regards to its ability to block on a given task / worker / thread. Unfortunately, [3] you're not that smart. async/await
was never to blame.
Or was it?
- why is the underlying
impl Future
in no way constrained by default? - how come the cancellation safety is entirely optional?
- what is the
async
alternative to thestd::thread::yield_now()
call?
tokio::task::yield_now()
only adds an.await
point to an existingasync
block;
assuming the need toyield
from an arbitrary execution point within - what's the way?
Without any semblance of an enforced constraint and/or a preemptive capability of the underlying executor - there can't be an async
"pit of success". It is far too easy to skim over a single while
/ loop
/ for
loop; to forget (or never have found the perfect crate for) a non-blocking alternative to an otherwise perfectly valid piece of code; to lose track of the exact, potentially exponential or worse, number of CPU cycles until the next .await
point within each and every Task
.
Expecting people to do all of the above and more is not any different from expecting them to keep track of each and every raw pointer to each and every heap allocation across each and every thread they are ever going to work with. We all know how "well" that works for C/C++.
Solution/s? Implicit configurable constraint (in ops/cycles) for each impl Future
might be a good start. Mirroring the implicit #repr(Rust)
on any enum
/ union
/ struct
declaration:
// (1) `restrained` by default = auto `.await` every X ops
#[restrained|restrained(ops: 50)|unrestrained|]
async {
async_fn_1().await;
// (2) suspend in place or revert
// to the last `Poll::Pending` point
std::task::yield_now();
// (3) are we talking fibers at this point?
sync_call_async_spawn();
}
fn sync_call_async_spawn() {
let mut str = String::new();
let task_current = std::task::current();
let str_task_local = std::task::spawn_local(async {
async_fn_3(&mut str).await;
task_current.unpark();
});
// suspend + yield_now
std::task::park();
}
Alternative option: a whole bunch of lints all over the crate with clippy
or similar. Not only the linter would have to scan through the entire codebase and separate potentially (costly) blocking sections from the rest of async
code. Twisting people's arms into inspecting all of their projects all over just to stop a linter from screaming them doesn't feel like the most sensible solution out there, however.
Bring your own suggestions and post them up/down below. I'm not particularly attached to any particular spectrum of solutions. Only (ever so) mildly dissatisfied with the current status quo.
What's even more amusing here is I can clearly remember myself having the exact same issue a few years ago. Back when I had no clue or interest in why
std::pin::Pin<&mut Self>
was the receiving argument of thepoll
; when reading through the definition of theFuture
trait itself gave me a headache; and when trying to implement thetrait
itself from scratch myself seemed just insane. I think I never quite managed to get that exact combination going, too. ↩︎