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 fn declares a function that returns a Future.
  • .await drives 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], inside tokio::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?;
ChannelUse case
mpscMany senders, one receiver. Work queues, event streams.
oneshotSingle message, one sender, one receiver. Request-response.
broadcastOne sender, many receivers, all get every message.
watchLatest-value broadcast. Configuration updates.

For most "actor-style" code, mpsc + oneshot reply channels is the pattern.

Locks

Two flavors of locks exist. Pick deliberately:

TypeWhen to use
std::sync::MutexSynchronous code. Never hold across .await.
tokio::sync::MutexWhen you need to hold a lock across .await.
tokio::sync::RwLockRead-heavy async access.
dashmap::DashMapConcurrent 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.