rust · level 8

Async & Concurrency

async/await, tokio basics, Send/Sync, Arc<Mutex>, channels.

175 XP

Async & Concurrency

Rust ships async syntax in the language and a future trait in the standard library — but no runtime. You bring your own. Tokio is the de-facto choice; async-std and smol are credible alternatives. The split is unusual but deliberate: keep std small, let runtimes innovate independently.

For threading, the standard library is enough. std::thread::spawn, std::sync::Arc, std::sync::Mutex, std::sync::mpsc cover everything you need for OS-thread concurrency.

Threads — the OS primitive

use std::thread;

let handle = thread::spawn(|| {
    println!("hi from thread");
    42
});

let result = handle.join().unwrap();    // wait, get the return value
println!("got {}", result);

Each spawn is one OS thread. They're cheap-ish but not free — for thousands of concurrent tasks, prefer async.

Send and Sync — the thread-safety markers

Two auto-traits the compiler implements for almost every type:

  • T: Send — values of T can be transferred to another thread.
  • T: Sync — references &T can be shared between threads (i.e. &T: Send).

Most types are auto-Send + Sync. The exceptions catch real bugs:

Type Send Sync Why
i32, String, Vec<T> (if T is) Plain data
Rc<T> Non-atomic refcount, would race
Arc<T> ✅ (if T is) ✅ (if T is) Atomic refcount
Cell<T> / RefCell<T> ✅ (if T is) Interior mutability without locks
Mutex<T> ✅ (if T is) ✅ (if T is) Lock-protected
*const T / *mut T Raw pointers — manual safety

thread::spawn requires its closure to be Send + 'static. The compiler refuses to spawn a thread that captures non-Send data — saving you from data races at compile time.

Arc<Mutex<T>> — sharing mutable state across threads

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        let mut n = counter.lock().unwrap();
        *n += 1;
    }));
}

for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap());   // 10

Read it inside-out: Mutex<T> makes T lock-protected. Arc<...> adds reference counting so multiple threads can co-own it. The clone is cheap — only the refcount increments.

.lock() returns LockResult<MutexGuard<T>>. The guard derefs to T; when it drops, the lock is released. Locks are poisoned if a thread panics while holding one — .unwrap() propagates that, which is usually what you want.

Channels — message passing

use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();

for i in 0..5 {
    let tx = tx.clone();
    thread::spawn(move || {
        tx.send(i).unwrap();
    });
}
drop(tx);   // close the original — receiver knows when all senders are gone

while let Ok(msg) = rx.recv() {
    println!("got {}", msg);
}

mpsc = multi-producer, single-consumer. The crossbeam-channel crate gives you mpmc and select. For async, use tokio::sync::mpsc (slightly different API).

async/await — the syntax

async fn fetch_user(id: u64) -> Result<User, Error> {
    let resp = client.get(format!("/users/{}", id)).send().await?;
    let user = resp.json().await?;
    Ok(user)
}

async fn foo() -> T desugars to fn foo() -> impl Future<Output = T>. The function body becomes a state machine. Calling foo() produces the future but does nothing — futures are inert until polled.

.await is the polling point. It says: "yield to the runtime if not ready; resume here when it is."

Drive futures with a runtime

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let user = fetch_user(1).await?;
    println!("{:?}", user);
    Ok(())
}

The #[tokio::main] macro starts the runtime, blocks on the returned future, and shuts down when it completes. Without a runtime, awaiting a future is meaningless.

Spawning concurrent tasks

let h1 = tokio::spawn(fetch_user(1));
let h2 = tokio::spawn(fetch_user(2));
let h3 = tokio::spawn(fetch_user(3));

let (u1, u2, u3) = tokio::try_join!(h1, h2, h3)?;

tokio::spawn schedules a future; the call returns a JoinHandle that's itself a future. try_join! polls them concurrently and returns when all complete or any errors.

The closure passed to spawn requires Send + 'static — same rules as thread::spawn. If you capture an Rc<T>, the compiler stops you.

tokio::sync::Mutex vs std::sync::Mutex

Two different locks for two different worlds:

Kind Use when Behaviour
std::sync::Mutex Lock is dropped before any await OS-blocking — fast, but parks the OS thread
tokio::sync::Mutex Lock crosses an await Async — yields the task instead of parking the thread

Holding a std::sync::Mutex across an await blocks the runtime worker thread, preventing it from running other tasks. The compiler doesn't catch this — clippy::await_holding_lock does.

Channels — async edition

use tokio::sync::mpsc;

let (tx, mut rx) = mpsc::channel::<String>(32);   // buffered

tokio::spawn(async move {
    tx.send("hello".into()).await.unwrap();
    tx.send("world".into()).await.unwrap();
});

while let Some(msg) = rx.recv().await {
    println!("{}", msg);
}

Bounded sender (send returns Result and waits when full). For unbounded use mpsc::unbounded_channel(). For broadcast or oneshot, see tokio::sync::broadcast / tokio::sync::oneshot.

select! — race futures

tokio::select! {
    user = fetch_user(1) => {
        println!("got user: {:?}", user?);
    }
    _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
        eprintln!("timed out");
    }
}

Polls multiple futures concurrently; runs the branch of whichever completes first; cancels the others. Indispensable for timeouts and cancellation.

Pitfalls worth knowing

  • Forgetting .await. let _ = fetch_user(1); is a no-op — you got an unpolled future. Compile-warning under unused_must_use.
  • .lock() across .await. Use tokio::sync::Mutex instead.
  • Spawning a non-Send future. Rc<T>, RefCell<T>, raw pointers — make them Arc<Mutex<T>> or rethink.
  • Blocking the runtime. Heavy CPU work belongs in tokio::task::spawn_blocking or a rayon thread pool — not in an async task.
  • Buffer-size on bounded channels. Too small → backpressure waits everywhere. Too large → unbounded growth. Set deliberately.

When to reach for what

Concurrency need Use
A few CPU-bound jobs std::thread::spawn
Many CPU-bound jobs rayon + parallel iterators
Many I/O-bound jobs (HTTP, DB, sockets) tokio async tasks
One mutable thing shared between threads Arc<Mutex<T>>
Many readers, occasional writer Arc<RwLock<T>>
One-shot value handoff between tasks tokio::sync::oneshot
Pipeline of tasks tokio::sync::mpsc
Wait-for-many or wait-for-any tokio::join! / tokio::select!
Cancel work after a deadline tokio::time::timeout

Concurrency in Rust feels like more bookkeeping than other languages — but the compiler proves data-race-freedom. Once your concurrent code compiles, the entire class of "what if these run in this order" bugs is gone.

Tools in the wild

4 tools
  • tokiofree tier

    The default async runtime — multi-threaded scheduler, timers, networking, channels.

    library
  • async-stdfree tier

    Alternative runtime with std-shaped APIs. Smaller community than tokio today.

    library
  • tokio-consolefree tier

    Live debugger for tokio applications — see tasks, locks, polls in real time.

    service
  • rayonfree tier

    Data-parallel CPU work. Different problem from async; complementary.

    library