Async/await has been stable in Rust since late 2019, and the ecosystem has spent the time since catching up around it. Tokio hit 1.0 at the end of last year, the warp and hyper stacks moved over, and the combinator-soup style that we all wrote in 2018 now looks like a relic. I finally bit the bullet and rewrote one of my services off the old Future combinators and onto async/.await. This is the long version of what that was like. The short version is: do it, but budget more time for trait objects than you think.
what the old code looked like
The service is a small thing, a network proxy that fans requests out to a few backends, merges responses, and streams the result back. In the pre-async world I wrote it as chains of combinators on the old futures 0.1 Future trait. You know the shape:
fn handle(req: Request) -> impl Future<Item = Response, Error = Error> {
fetch_upstream(&req)
.and_then(|body| parse(body))
.and_then(|parsed| enrich(parsed))
.map(|data| build_response(data))
.or_else(|e| recover(e))
}
It works. It is also, once the logic stops being a straight pipeline and starts having branches, conditionals and early returns, an absolute misery to read. The moment you want an if in the middle of that chain you are reaching for Either, boxing things to unify types, and writing and_then closures that capture half the function. Error handling is a parade of or_else and map_err. I had functions where the type signature was longer than the body, and the body was a single unreadable expression that I'd built up by accretion and was now afraid to touch.
what it looks like now
The same logic, in async/await, reads like the synchronous version with .await sprinkled where the blocking would have been:
async fn handle(req: Request) -> Result<Response, Error> {
let body = fetch_upstream(&req).await?;
let parsed = parse(body)?;
let data = match enrich(parsed).await {
Ok(d) => d,
Err(e) => recover(e).await?,
};
Ok(build_response(data))
}
That is the whole pitch, really. The ? operator works. Control flow is just control flow. You can put a loop in the middle, an early return, a match that awaits different things on different arms, and the compiler turns the lot into a state machine for you without you having to think about Either ever again. The borrow checker now reasons about your async function roughly the way it reasons about a normal one, which after years of fighting 'static lifetimes on boxed futures feels like someone opened a window.
where it bit me
It was not all clean. Two things ate most of the week.
The first was trait objects. I have a Backend trait and several implementations, and I store them as Box<dyn Backend>. The trait has an async method. Rust does not yet support async fn in traits, which surprised me until I thought about why: an async fn desugars to a method returning impl Future, and you can't have impl Trait in a trait method's return position when you need to put the whole thing behind a dyn. The answer, for now, is the async-trait crate, which rewrites your async trait methods to return Pin<Box<dyn Future + Send + '_>> behind a macro:
#[async_trait]
trait Backend {
async fn fetch(&self, key: &str) -> Result<Bytes, Error>;
}
It works and it's the de facto standard, but it does mean every trait method call allocates a boxed future, and the error messages when something doesn't line up are written in the desugared types, not the ones you wrote. That's a leaky abstraction you have to learn to read.
The second, and the one that genuinely nearly broke me, was Send. The moment you spawn a future onto a multi-threaded runtime like Tokio's, that future has to be Send, which means everything held across an .await point has to be Send. I had a Rc and a MutexGuard from a non-Send mutex living across awaits in a couple of places, and the compiler error for that is enormous, points at the spawn call rather than the actual offending value, and lists the entire chain of why the future isn't Send. Learning to read from the bottom of that error upward, to find the Rc or the guard that started it, is a skill I did not have on Monday and did by Friday. Swap Rc for Arc, drop the guard before the await, and it goes quiet.
was it worth it
Yes, unambiguously. The rewrite touched almost every file, took the better part of a week, and produced a binary that performs about the same as the old one, so there was no speed win to show for it. The win is entirely in the reading. Code that I'd been avoiding because I couldn't safely modify the combinator chains is now ordinary code with ordinary control flow that I can change without fear. A bug I'd been carrying for months, a mishandled error path buried in an or_else, became obvious the instant the logic was written as a normal match, and I fixed it almost by accident during the port.
If you're still on futures 0.1 combinators, the ecosystem has moved and stable async/await is genuinely ready. Set aside more time than you think for the Send bounds and the trait-object dance, read the big errors from the bottom up, and enjoy deleting a great deal of Either.