Async
Async Rust uses one runtime (Tokio, in practice) and a small set of primitives. Once you have the mental model, most async code reads straightforwardly. The traps cluster around a few specific footguns.
The basics
async fn fetch(url: &str) -> Result<Bytes, reqwest::Error> {
let resp = reqwest::get(url).await?;
let bytes = resp.bytes().await?;
Ok(bytes)
}Three things:
async fndeclares a function that returns aFuture..awaitdrives the future forward, yielding control back to the runtime while waiting.- The function must be called from an async context (inside another
async fn, inside#[tokio::main], insidetokio::spawn).
The runtime
You need a runtime to execute futures. Tokio is the answer:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let body = fetch("https://example.com").await?;
println!("{} bytes", body.len());
Ok(())
}#[tokio::main] is a macro that builds a runtime and runs your main on it. For libraries, you usually do not start a runtime; the consumer does.
Spawning tasks
let handle = tokio::spawn(async move {
do_work().await
});
let result = handle.await?;tokio::spawn puts a future on the runtime as a new task. The returned JoinHandle lets you wait for it and recover its result. Always capture the handle. Fire-and-forget loses errors and crashes.
Channels
tokio::sync provides async-aware channels:
let (tx, mut rx) = tokio::sync::mpsc::channel::<Job>(100);
tokio::spawn(async move {
while let Some(job) = rx.recv().await {
process(job).await;
}
});
tx.send(my_job).await?;| Channel | Use case |
|---|---|
mpsc | Many senders, one receiver. Work queues, event streams. |
oneshot | Single message, one sender, one receiver. Request-response. |
broadcast | One sender, many receivers, all get every message. |
watch | Latest-value broadcast. Configuration updates. |
For most "actor-style" code, mpsc + oneshot reply channels is the pattern.
Locks
Two flavors of locks exist. Pick deliberately:
| Type | When to use |
|---|---|
std::sync::Mutex | Synchronous code. Never hold across .await. |
tokio::sync::Mutex | When you need to hold a lock across .await. |
tokio::sync::RwLock | Read-heavy async access. |
dashmap::DashMap | Concurrent map without locking the whole map. |
The most common async footgun is holding a std::sync::Mutex guard across .await. The lock blocks the entire runtime thread, and worse, can deadlock. Either drop the guard before .await, or use tokio::sync::Mutex.
// Wrong
let mut g = std_mutex.lock().unwrap();
g.touch();
some_async_op().await; // lock still held!
g.commit();
// Right
{
let mut g = std_mutex.lock().unwrap();
g.touch();
} // lock dropped here
some_async_op().await;
{
let mut g = std_mutex.lock().unwrap();
g.commit();
}Send and Sync
A future is Send if it can be moved between threads. For tokio::spawn on the multi-threaded runtime, the future must be Send. Everything you hold across .await must also be Send.
Rc<T>, RefCell<T>, raw pointers — not Send. If you accidentally hold one across .await, the spawn site won't compile. The error message names the offending type.
Sync means safe to share between threads via &T. Most data types are Sync if their internals are Sync.
CPU-bound work
async/await is for I/O-bound work. For CPU-bound work, hand it off to a thread pool:
let result = tokio::task::spawn_blocking(|| {
do_expensive_compute()
}).await?;spawn_blocking runs the closure on a separate thread pool that does not block the async runtime. Use it for: file I/O on systems where async file I/O isn't great, image processing, regex compilation, anything CPU-heavy.
Streams
use tokio_stream::StreamExt;
let mut stream = some_stream();
while let Some(item) = stream.next().await {
handle(item);
}A Stream is "an async iterator." Most network protocols, database queries, file reads, and message subscriptions surface as streams. The combinators (.map, .filter, .then, .buffer_unordered) match iterators.
Common patterns
Timeout
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
long_op(),
).await;result is Result<T, Elapsed>. Always wrap external calls in a timeout.
Select
tokio::select! {
result = network_op() => handle(result),
_ = tokio::time::sleep(Duration::from_secs(5)) => println!("timed out"),
}Run multiple futures concurrently, take the first to complete.
join_all
let results = futures::future::join_all(
urls.iter().map(|u| fetch(u))
).await;Run many futures concurrently, wait for all.
Async trait
#[async_trait::async_trait]
pub trait Provider: Send + Sync {
async fn get(&self, key: &str) -> Result<Bytes, Error>;
}Until very recently, Rust did not support async fn in traits with dynamic dispatch. The async_trait macro desugars to Pin<Box<dyn Future + Send>> returns. Sail uses this pattern throughout (see Sail trait design).
Native async-fn-in-trait (AFIT) is now stable for static dispatch, but for dyn Trait use cases, #[async_trait] is still the production choice.