There is a common sentiment I’ve seen over and over in the Rust community that I think is ignorant at best and harmful at worst.
This blogpost is largely a response to this post by Matt Kline, but I’ve seen this kind of sentiment all over the Rust community. I’ve found that, in almost every case where async
/await
is mentioned, at least one person says this. It always gets on my nerves a little bit.
The guilty phrase is as follows: “async
rust is only useful for a small number of programs, so why do library authors insist on using it in their APIs?”
I can understand where this kind of thinking comes from. Especially for newer Rustaceans, async
/await
is quite a bit of complexity up front. But, I think actively shying away from async
is the wrong way to go.
Disclaimer: I am one of the maintainers for smol
, a small and fast async
runtime for Rust. So, obviously, I am somewhat biased. However, I think that async
can be cool, small and fun; it’s just the presence of complicated async
code used commonly across the ecosystem that scared people off.
Why async?
Greenspun’s tenth rule comes to mind quite often. For those unfamiliar, Greenspun’s tenth rule of programming is that every sufficiently complicated program contains a bug-ridden version of half of Common Lisp. Likewise, there are a worrying number of Rust programs that contain a bug-ridden version of half of an async
runtime.
I call this “poor man’s async
”. async
/await
is a natural pattern for doing multiple things at once; usually, non-async
code tends to evolve into something closer and closer to async
code, like carcinisation. It’s all over the place in the Rust ecosystem, once you start looking for it.
It happens like this: programs are naturally complicated. Even the simple, Unix-esque atomic programs can’t help but do two or three things at once. Okay, now you set it up so, instead of waiting on read
or accept
or whatnot, you register your file descriptors into poll
and wait on that, then switching on the result of poll
to figure out what you actually want to do.
Eventually, two or three sockets becomes a hundred, or even an unlimited amount. Guess it’s time to bring in epoll
! Or, if you want to be cross-platform, it’s now time to write a wrapper around that, kqueue
and, if you’re brave, IOCP.
Great, now you need a way to organize all of this work, because switching on it means you have to add two hundred lines to your program every time you want to handle some other corner case. No problem, let’s set up a queue of tasks. Whoops, turns out that queueing strategy is inefficient. Better make a new one. Let’s hope it’s thread safe!
At the end of this process, you’ve re-invented async-io
and async-executor
, two of the core components of smol
.
This isn’t a knock on anyone in particular; this is a knock on me too! I’ve written quite a few Rust projects where I expect it to only involve blocking primitives, only to find out that, actually, I’m starting to do a lot of things at once, guess I’d better use async
. The original async
setup at the beginning of the project is somewhat annoying, but it’s a walk in the park compared to going back and rewriting my entire program setup to use async
/await
.
The point being, many people say that only five percent of Rust projects use async
, and the remaining ninety-five percent have to put up with it. I disagree. Many of the remaining ninety-five percent (if that is an accurate number) are currently using async
/await
; they just haven’t admitted it yet!
Why don’t people like async?
Now here’s where I speculate why this attitude is so pervasive in the Rust community. I personally think it’s a combination of poor advertising on the part of async
combined with poor standard library support.
I’d argue that, if you walked up to your average Rustacean on the street and asked what they thought of async
programming, they’d argue that it’s just a obtuse, niche way to create web servers.
That’s not true! Even if you ignore the benefits of using async
from a network clients, you can still definitely use async
for desktop apps. async-winit
is my attempt at bringing async
/await
to desktop apps in a managable way. I just think that too many people see async
/await
as part of Rust’s whole web shindig, instead of a reasonable way to structure highly concurrent applications.
In addition, the standard library is definitely built around synchronous code first and async
code second. This means that a lot of async
code that should be in the standard library ends up being pushed into external crates. This is definitely a problem that, thankfully, is being fixed as traits like Stream
are now finally making their way into the standard library.
Even if you’re writing one of the simple programs that doesn’t explicitly need async
/await
, it’s not too difficult to move between the two worlds. Function colors get brought up a lot in this area of debate. However, personally, I find it much easier to go from async
to sync and vice versa than JavaScript does. For instance, to run an async
function in synchronous code, you can bring in the zero-dependency pollster
crate and run this:
use pollster::FutureExt as _;
async { "Hello world!" }.block_on();
Likewise, to run sync code in the async
world, it’s usually easy to spawn it onto a blocking
task and then poll it from async
code. There’s some thread-safety subtlety I’m papering over here, but overall it looks something like this:
blocking::unblock(|| "Hello world!").await;
Another benefit of async
/await
that I don’t know how to bring up organically above: it translates a lot better to web targets. Blocking synchronous code isn’t allowed in WASM in browsers, so by using async
code, you can be reasonably sure that your algorithm can be ported to the web very easily.
Generally, async
/await
makes things more portable and easier to work into different application setups. I think that, if more Rustaceans invested the effort into learning how async
/await
ticks, we’d see it used in much more programs.
Keeping the Faith
I’ve been dancing around it for too long, let’s finally dive into this post.
The main complaint that the author has around async
/await
is that it requires your futures to be Send
and 'static
. This property tends to spread throughout the program.
async fn foo(&BIG_GLOBAL_STATIC_REF_OR_SIMILAR_HORROR, sendable_chungus.clone())
Except, this isn’t a problem with Rust’s async
, it’s a problem with tokio
. tokio
uses a 'static
, threaded runtime that has its benefits but requires its futures to be Send
and 'static
. In smol
, on the other hand, it’s perfectly possible to pass around things by reference.
let big = /* ... */;
let chungus = /* ... */;
// With smol, you can create an executor...
let ex = smol::Executor::new();
// ...and, as long as its captured variables outlive it, you can pass things around from the stack!
ex.spawn(async {
async fn foo(&big, &chungus).await
}).detach();
Actually, the main draw here is that this particular executor isn’t multithreaded. But it’s very easy to make it multithreaded.
// Create an executor.
let ex = smol::Executor::new();
// Create a channel used to stop the threadpool.
let (signal, shutdown) = smol::channel::bounded::<()>(1);
// Create a threadpool to run this executor on.
std::thread::scope(|s| {
// Spawn 4 worker threads.
for _ in 0..4 {
let shutdown = shutdown.clone();
let ex = &ex;
s.spawn(move || smol::block_on(ex.run(shutdown)));
}
// Run a future on this executor.
smol::block_on(ex.spawn(async {
// Variables can be passed along just like before!
async fn foo(&big, &chungus).await
}));
});
Let’s say you don’t even want to use threads. You’re a fan of RefCell
and Rc
so thread-safety doesn’t really fit your use-case. That’s okay too! smol::LocalExecutor
doesn’t require anything to be Send
at all.
let my_thing = RefCell::new(4);
let ex = smol::LocalExecutor::new();
// Look, ma! A thread-unsafe task!
ex.spawn(async {
*ex.borrow_mut() = 5;
}).detach();
Really, Send
and 'static
are not intrinsic properties of async
Rust; it’s just what the biggest runtime decided on. If you’re not a fan of that, consider taking smol
for a spin!
Wrap it up
Really, I think that most Rustacean’s fears of async
are unjustified. Yes, there are complicated semantics at play, but really no more complicated than, say, a borrow checker. In exchange, you gain access to much more powerful program semantics (that you’re probably trying to use anyways!)
So, consider using async
/await
today! Even if you’ve been turned of by it in the past, there may be parts of the ecosystem that fit your use cases better.