Async Rust: "I can't let you do that"
February 10, 2024 -This is a story of trying out async Rust.
The Rust Project has spent upwards of five years working on async Rust. This was part of a deliberate strategy to attract corporate funding given that Mozilla wasn't going to support of the language forever. In that regard, it was a huge success. Use of Rust has continued to grow, and by all accounts the Rust Foundation is on sound financial footing.
As a long time Rust user, I've tried out async a couple times over the course of its development. The note that follows doesn't track any particular attempt, but tries to condense an overall feeling into a single story line. I hope it will be instructive to ecosystem leaders to see how the experience of trying out async Rust can go painfully wrong.
Backstory
Let's start with how you would have performed an HTTP request in Rust circa 2018. The reqwest crate documentation shows a simple example:
fn main() {
let body = reqwest::get("https://www.rust-lang.org").unwrap()
.text().unwrap();
println!("body = {:?}", body);
}
By 2024, the simple example is buried a bit deeper in the reqwest documentation, but the overall pattern hasn't changed:
fn main() {
let body = reqwest::blocking::get("https://www.rust-lang.org").unwrap()
.text().unwrap();
println!("body = {:?}", body);
}
So far, so good! We had to skip past a bunch of async APIs, but our code works fine.
Trying out async
Blocking calls are fine and all, but perhaps at this point we'll see what all the hype around async is about?
Obviously, in any real project we couldn't just re-write all our code in one go. Knowing that we won't get the full benefit of async, let's take a shot at incrementally re-writing our code.
Although not fully settled, the general consensus seems to be that tokio is the preferred async runtime. The crate documentation might be a little dense, but a quick skim of the docs later and we're ready to go:
#[tokio::main]
async fn main() {
// old code
let body = reqwest::blocking::get("https://www.rust-lang.org").unwrap()
.text().unwrap();
println!("body = {:?}", body);
// new code
tokio::task::spawn_blocking(|| {
println!("Hello from async!");
}).await.uwrap();
}
And… nope:
thread 'main' panicked at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/blocking/shutdown.rs:51:21:
Cannot drop a runtime in a context where blocking is not allowed. This happens when a runtime is dropped from within an asynchronous context.
stack backtrace:
0: rust_begin_unwind
at /rustc/d5fd0997291ca0135401a39dff25c8a9c13b8961/library/std/src/panicking.rs:647:5
1: core::panicking::panic_fmt
at /rustc/d5fd0997291ca0135401a39dff25c8a9c13b8961/library/core/src/panicking.rs:72:14
2: tokio::runtime::blocking::shutdown::Receiver::wait
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/blocking/shutdown.rs:51:21
3: tokio::runtime::blocking::pool::BlockingPool::shutdown
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/blocking/pool.rs:261:12
4: <tokio::runtime::blocking::pool::BlockingPool as core::ops::drop::Drop>::drop
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/blocking/pool.rs:278:9
5: core::ptr::drop_in_place<tokio::runtime::blocking::pool::BlockingPool>
at /rustc/d5fd0997291ca0135401a39dff25c8a9c13b8961/library/core/src/ptr/mod.rs:507:1
6: core::ptr::drop_in_place<tokio::runtime::runtime::Runtime>
at /rustc/d5fd0997291ca0135401a39dff25c8a9c13b8961/library/core/src/ptr/mod.rs:507:1
7: reqwest::blocking::wait::enter
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/reqwest-0.11.24/src/blocking/wait.rs:76:21
8: reqwest::blocking::wait::timeout
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/reqwest-0.11.24/src/blocking/wait.rs:13:5
9: reqwest::blocking::client::ClientHandle::new
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/reqwest-0.11.24/src/blocking/client.rs:1083:15
10: reqwest::blocking::client::ClientBuilder::build
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/reqwest-0.11.24/src/blocking/client.rs:103:9
11: reqwest::blocking::get
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/reqwest-0.11.24/src/blocking/mod.rs:107:5
12: async_blog::main::{{closure}}
at ./src/main.rs:4:16
13: tokio::runtime::park::CachedParkThread::block_on::{{closure}}
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/park.rs:281:63
14: tokio::runtime::coop::with_budget
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/coop.rs:107:5
15: tokio::runtime::coop::budget
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/coop.rs:73:5
16: tokio::runtime::park::CachedParkThread::block_on
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/park.rs:281:31
17: tokio::runtime::context::blocking::BlockingRegionGuard::block_on
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/context/blocking.rs:66:9
18: tokio::runtime::scheduler::multi_thread::MultiThread::block_on::{{closure}}
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/scheduler/multi_thread/mod.rs:87:13
19: tokio::runtime::context::runtime::enter_runtime
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/context/runtime.rs:65:16
20: tokio::runtime::scheduler::multi_thread::MultiThread::block_on
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/scheduler/multi_thread/mod.rs:86:9
21: tokio::runtime::runtime::Runtime::block_on
at /home/jonathan/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.36.0/src/runtime/runtime.rs:350:45
22: async_blog::main
at ./src/main.rs:9:5
23: core::ops::function::FnOnce::call_once
at /rustc/d5fd0997291ca0135401a39dff25c8a9c13b8961/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
The first thing to notice about the error is that it actually occurs before running any of our new code. The mere act of switching to an async main function has broken our previously functional code.
The second detail is in the phrasing of the error itself (emphasis added):
Cannot drop a runtime in a context where blocking is not allowed. This happens when a runtime is dropped from within an asynchronous context.
A reasonable conclusion from that message would be that our incremental re-write strategy is dead-on-arrival. We tried mixing async and normal code, and Tokio immediately crashed our program in retribution.
Revisiting the reqwest docs that we skimmed past before seem to confirm that same conclusion:
Conversely, the functionality in reqwest::blocking must not be executed within an async runtime, or it will panic when attempting to block. If calling directly from an async function, consider using an async reqwest::Client instead. If the immediate context is only synchronous, but a transitive caller is async, consider changing that caller to use tokio::task::spawn_blocking around the calls that need to block.
Specifically, notice that there's no case for when the function will be called from both sync and async contexts! If you want to use reqwest from async, then you apparently have to go "all in" and re-write your entire app to only work in async.
Aftermath
At this point, I'm sure many readers are eager to jump in and point out all the ways my example snippet could be adjusted to avoid the error. And to be clear, there are multiple ways this particular panic could be avoided.
It should also be acknowledged that there are legitimate reasons for the design decisions that led to the current situation. There's a whole genre of blog posts arguing that async Rust's design was necessary and unavoidable, though I can't help feeling like they sound a little like a different questionable argument.
But I urge you to step back and consider things from the user perspective.
What conclusion would you draw if your first async program crashed with a panic? Would you soldier ahead confident it was an isolated incident that wouldn't happen again? Or turn back, fearful that this represented only the first of many gotchas and pitfalls that would await you?
And perhaps an even more fundamental question: Is this the on-boarding experience we want users to have?