Supporting async and blocking APIs in your library, while letting async do the heavy lifting for you
⚓ Rust 📅 2026-07-18 👤 surdeus 👁️ 1About 9 months ago I was working on a crate called downlowd for downloading files over HTTP. (This has been in our production codebase for a while now, so it's time to share.) I wanted to download some files, with things like automatic retries, resuming partial downloads, etc..., and there didn't seem anything out there that was a turn key solution (at least at the time).
One of the problems I ran into was that I wanted downlowd to support both an async API and a blocking API, using reqwest and tokio for async, and ureq and std::fs for the blocking API. There's a lot of ways you can handle writing a library like this, but a lot of them have different drawbacks:
- Copy paste all your code and have a sync version and an async version. This sucks for obvious reasons.
- Use a macro like maybe_async or bisync.
maybe_asyncis very popular, but doesn't handle the "library" case very well because it can't generate both async and blocking code in the same compile. bisync handles this, but doesn't have many downloads, and has some other drawbacks. - Use sans-io.
Sans-io works, but the problem with sans-io specifically - as noted by James and Amos - is that you have to hand craft this complicated state machine, and why do that when the whole point of async is to get the compiler to build complicated state machines for you?
But this got me thinking; could I do "sans-io"... with async? Basically the idea is to use rust's compiler to turn async code into a state machine, and then provide an async and blocking implementation for the state machine via monomorphization. Or, put more simply, we write async code that contains our business logic, but that async code never calls any IO calls directly. Instead we put all the IO into a trait, and then we have one trait implementation with async methods that calls into reqwest and tokio::fs, and a second trait implementation with async methods that call into ureq and std::fs.
At first, this sounds kind of crazy. You're not supposed to call blocking functions from inside async functions, because you're going to block the executor, and you can't call into the async business logic from a blocking function to kick this all off anyways, right? But the thing is, if you know your async function is going to be called from a blocking context, you don't have to worry about blocking the executor, because there isn't one. And, if all your IO operations are blocking operations, then you know the "state machine" that async generates will generate a Future that never returns Pending, so you can poll your future exactly once and get back an answer. All you need is a paper thin sync_executor (or maybe something like pollster) to handle actually calling into the Future and supplying it with the things it expects.
This is awesome, because you can write your logic once, and produce a sync and async API. It's also gives you a lot of control. In the case of my downlowd library, I could reimplement these traits using other HTTP clients or other async file systems, making it very flexible.
This is not awesome because... it's kinda weird. Also, async was already the "not very rusty part of rust", because there's all these rules that you have to follow, like not blocking the executor, and the compiler doesn't enforce them for you, and this is taking that to a whole new level.
There's more details about this approach here, and feedback is welcome.
2 posts - 2 participants
🏷️ Rust_feed