Concurrency Basics
9 examples to get you started with Rust concurrency - 6 basic and 3 intermediate.
Prerequisites
- Rust 1.97.0 (edition 2024) with
std::thread. - Understand ownership and
moveclosures before spawning threads.
Basic Examples
1. Spawning a Thread
std::thread::spawn runs a closure on a new OS thread.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("hello from a thread");
});
handle.join().unwrap();
}spawntakes a'staticclosure - no borrowed stack data unless scoped (see scoped threads).joinblocks until the thread finishes and returnsResult<T, JoinError>.- The main thread waits here; without
join, the process may exit before the child runs.
Related: Scoped Threads - borrow stack data safely
2. Moving Data into a Thread
Use move to transfer ownership across thread boundaries.
use std::thread;
fn main() {
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("owned data: {:?}", data);
});
handle.join().unwrap();
}moveforces the closure to take ownership of captured variables.- After
move,datais unavailable in the parent thread. - Types moved to threads must implement
Send.
Related: Send & Sync - thread-safety markers
3. JoinHandle and Return Values
Threads can return values through join.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
let sum: i32 = (1..=100).sum();
sum
});
let result = handle.join().unwrap();
println!("sum = {result}");
}- The closure return type becomes
JoinHandle<T>. joinmoves the result out of the thread.- Panics in the child thread propagate as
Errfromjoin.
Related: Deadlocks & Race Conditions - failure modes
4. Multiple Threads
Spawn several workers and join them in order.
use std::thread;
fn main() {
let handles: Vec<_> = (0..4)
.map(|i| {
thread::spawn(move || {
println!("worker {i}");
i * 10
})
})
.collect();
for handle in handles {
let n = handle.join().unwrap();
println!("got {n}");
}
}- Collect handles first, then join - workers run concurrently.
- Join order does not affect which thread finishes first.
- Store handles if you need results or clean shutdown.
Related: Data Parallelism with Rayon - parallel iterators
5. Thread Names and IDs
Name threads for clearer logs and debugging.
use std::thread::{self, ThreadId};
fn main() {
let builder = thread::Builder::new().name("worker".into());
let handle = builder.spawn(|| {
let id: ThreadId = thread::current().id();
println!("{:?}", thread::current().name());
println!("id = {id:?}");
}).unwrap();
handle.join().unwrap();
}- Names appear in
tracing, debuggers, and panic messages. ThreadIdis opaque and useful for correlating logs.- Builder also supports stack size overrides for deep recursion.
Related: Concurrency Best Practices - operational rules
6. Message Passing with Channels
Prefer channels over shared mutable state when possible.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send("job done").unwrap();
});
let msg = rx.recv().unwrap();
println!("{msg}");
}mpsc= multiple producer, single consumer.sendmoves ownership of the message to the receiver.recvblocks until a message arrives.
Related: Channels - full channel guide
Intermediate Examples
7. Shared Counter with Mutex
When sharing state, wrap it in a lock.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
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 num = counter.lock().unwrap();
*num += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("count = {}", *counter.lock().unwrap());
}Arcshares ownership across threads;Mutexserializes mutation.- Always release the lock before doing slow I/O (drop the guard).
lock().unwrap()panics if the mutex is poisoned after a panic in another thread.
Related: Mutex & RwLock - locking patterns
8. Read-Heavy Workloads with RwLock
Many readers, few writers benefit from RwLock.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let cache = Arc::new(RwLock::new(String::from("seed")));
let mut handles = vec![];
for i in 0..5 {
let cache = Arc::clone(&cache);
handles.push(thread::spawn(move || {
let data = cache.read().unwrap();
println!("reader {i}: {data}");
}));
}
{
let mut data = cache.write().unwrap();
data.push_str(" updated");
}
for handle in handles {
handle.join().unwrap();
}
}- Multiple
readguards can coexist. writeblocks all readers and other writers.- Prefer channels if writes are frequent - lock contention hurts throughput.
Related: Arc for Shared State - sharing patterns
9. Choosing Threads vs Async
Threads for CPU parallelism; async for many concurrent I/O waits.
// CPU-bound: threads or rayon
// use rayon::prelude::*;
// I/O-bound: async runtime (Tokio)
// #[tokio::main]
// async fn main() { ... }- OS threads have stack overhead (~2 MB default); thousands of blocking threads are expensive.
- Async tasks are lightweight but need a runtime and non-blocking I/O.
- Hybrid designs use thread pools inside async for CPU work (
spawn_blocking).
Related: Async Basics - when to go async
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.