Why you might actually want async in your project

John Nunley · September 9, 2023

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.

Twitter, Facebook

This website's source code is hosted via Codeberg