Async & Concurrency
async/await, tokio basics, Send/Sync, Arc<Mutex>, channels.
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 ofTcan be transferred to another thread.T: Sync— references&Tcan 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 underunused_must_use. .lock()across.await. Usetokio::sync::Mutexinstead.- Spawning a non-
Sendfuture.Rc<T>,RefCell<T>, raw pointers — make themArc<Mutex<T>>or rethink. - Blocking the runtime. Heavy CPU work belongs in
tokio::task::spawn_blockingor arayonthread 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- librarytokiofree tier
The default async runtime — multi-threaded scheduler, timers, networking, channels.
- libraryasync-stdfree tier
Alternative runtime with std-shaped APIs. Smaller community than tokio today.
- servicetokio-consolefree tier
Live debugger for tokio applications — see tasks, locks, polls in real time.
- libraryrayonfree tier
Data-parallel CPU work. Different problem from async; complementary.