Recently, I found myself returning to a compelling series of blog posts titled
Zero-cost futures in Rust
by Aaron Turon about what would become the foundation of Rust’s async ecosystem
and the
Tokio
runtime.
This series stands as a cornerstone in writings about Rust. People like Aaron
are the reason why I wanted to be part of the Rust community in the first place.
While 2016 evokes nostalgic memories of excitement and fervor surrounding async
Rust, my sentiments regarding the current state of its ecosystem are now
somewhat ambivalent.
Through this series, I hope to address two different audiences:
Newcomers to async Rust, seeking to get an overview of the current state of
the ecosystem.
Library maintainers and contributors to the async ecosystem, in the hope that
my perspective can be a basis for discussion about the future of async Rust.
In the first article, we will focus on the current state of async Rust runtimes,
their design choices, and their implications on the broader Rust async ecosystem.
An inconvenient truth about async Rust is that
libraries still need to be
written against individual
runtimes
. Writing your
async code in a runtime-agnostic fashion requires
conditional
compilation
,
compatibility layers
and
handling
edge-cases
.
This is the rationale behind most libraries gravitating towards the
One True Runtime —
Tokio
.
Executor coupling is a big problem for async Rust as it breaks the ecosystem
into silos.
Documentation and examples for one runtime don’t work with the
other
runtimes
.
Moreover, much of the existing documentation on async Rust feels outdated or
incomplete. For example, the async book remains in draft, with concepts like
cancellation, timeouts, and
FuturesUnordered
yet to be covered. (There is an open
pull
request
, though.)
That leaves us with a situation that is unsatisfactory for everyone involved:
For new users, it is a big ask to
navigate this space
and make future-proof decisions.
For experienced users and library maintainers,
supporting multiple runtimes is an additional burden
. It’s no surprise that popular crates like
reqwest
simply insist on Tokio as a runtime
.
This close coupling,
recognized by the async working
group
, has me worried about
its potential long-term impact on the ecosystem.
async-std
was an attempt to create an alternative runtime that is closer to
the Rust standard library. Its promise was that you could almost use it as a
drop-in replacement.
Take, for instance, this straightforward synchronous file-reading code:
use std::fs::File;
use std::io::Read;
fn main() -> std::io::Result<()> {
let mut file = File::open("foo.txt")?;
let mut data = vec![];
file.read_to_end(&mut data)?;
Ok(())
In async-std
, it is an async
operation
instead:
use async_std::prelude::*;
use async_std::fs::File;
use async_std::io;
async fn read_file(path: &str) -> io::Result<()> {
let mut file = File::open(path).await?;
let mut data = vec![];
file.read_to_end(&mut data).await?;
Ok(())
The only difference is the await
keyword.
While the name might suggest it, async-std
is not a drop-in replacement
for the standard library as there are many subtle differences between the
two.
Here are some examples of issues that are still open:
New thread is spawned for every I/O request
OpenOptionsExt missing for Windows?
Spawned task is stuck during flushing in
File.drop()
It is hard to create a runtime that is fully compatible with the standard
library. Even if it were a drop-in replacement, I’d still ponder its actual merit.
Rust is a language that values explicitness. This is especially true for
reasoning about runtime behavior, such as allocations and blocking operations.
The async-std’s teams proposal to “Stop worrying about
blocking”
was met with noticeable community skepticism and later retracted.
As of this writing, 1754 public crates have a dependency on
async-std
and there
are companies that rely on it in
production.
However, looking at the commits over time async-std
is essentially abandoned
as there is no active development
anymore:
Update: as of March 1, 2025, async-std
has officially been discontinued.
The suggested replacement is smol, which is a
lightweight, much more explicit runtime, that is different to async-std
in
many ways.
This leaves those reliant on the async-std
API – be it for concurrency
mechanisms, extension traits, or otherwise – in an unfortunate situation, as is
the case for libraries developed on top of async-std
, such as
surf
. The core of async-std
has long been
powered by smol
, but it is probably best to
use it directly for new projects.
Tokio stands as Rust’s canonical async runtime.
But to label Tokio merely as a runtime would be an understatement.
It has extra modules for
net,
process- and signal
handling and
more.
That makes it more of a framework for asynchronous programming than just a
runtime.
Partially, this is because Tokio had a pioneering role in async Rust. It
explored the design space as it went along. And while you could exclusively use
the runtime and ignore the rest, it is easier and more common to buy into the
entire ecosystem.
Yet, my main concern with Tokio is that it makes a lot of assumptions about how
async code should be written and where it runs.
For example, at the beginning of the Tokio
documentation, they state:
“The easiest way to get started is to enable all features. Do this by enabling
the full
feature flag”:
tokio = { version = "1", features = ["full"] }
By doing so, one would set up a
work-stealing,
multi-threaded runtime
which mandates that types are Send
and 'static
and makes it necessary to use
synchronization primitives such as
Arc
and
Mutex
for all but the
most trivial applications.
A 'static
trait bound mandates that the type does not contain any non-static
references. This means the receiver can hold on to the type
indefinitely without it becoming invalid until they decide to drop it.
Here is an example of an async function that has a 'static
lifetime bound:
async fn process_data<T: 'static>(data: T) {