Rust's concurrency model is one of its most powerful features, yet it intimidates many developers coming from languages with garbage collection and implicit thread safety. Concurrency in Rust: a practical guide for developers walks you through the core primitives—threads, traits, locks, and channels—that make fearless concurrency possible. By the end of this guide, you'll understand how to write multi-threaded code with confidence, knowing the compiler has your back.
Threads — OS threads with std::thread::spawn
Rust gives you direct access to operating system threads via std::thread::spawn. Unlike async runtimes that manage many tasks on a few threads, spawning an OS thread is straightforward and useful when you need genuine parallelism on multi-core systems.
Here's a practical example: imagine processing a batch of orders from an e-commerce system. Each order validation is CPU-bound, so spawning threads makes sense:
use std::thread;
fn main() {
let orders = vec![101, 102, 103, 104, 105];
let mut handles = vec![];
for order_id in orders {
let handle = thread::spawn(move || {
let result = validate_order(order_id);
println!("Order {} validated: {}", order_id, result);
});
handles.push(handle);
}
// Wait for all threads to finish
for handle in handles {
handle.join().unwrap();
}
}
fn validate_order(id: u32) -> bool {
// Simulate validation work
std::thread::sleep(std::time::Duration::from_millis(100));
id % 2 == 0
}
The move keyword transfers ownership of order_id into the thread's closure. This is crucial: the spawned thread needs to own its data because the main thread might exit before the spawned thread finishes.
handle.join() blocks until the thread completes. If the thread panics, join() returns an error, allowing you to handle failures gracefully. In production systems, you'd log these errors or retry the task.
Send and Sync — Traits that prove data is safe across threads
Rust's type system includes two marker traits—Send and Sync—that tell the compiler whether a type can safely move between threads or be shared by multiple threads.
Send: A type is Send if it's safe to transfer ownership to another thread. Most types implement Send automatically. Notable exception: Rc<T> (reference counting without atomics) is not Send because its reference count isn't thread-safe.
Sync: A type is Sync if it's safe for multiple threads to reference it simultaneously via &T. Cell<T> is not Sync because it allows mutation without locks. Mutex<T> is Sync because it enforces exclusive access via locking.
In real applications, you rarely implement these traits manually. The compiler automatically derives them based on your type's fields. If you try to move a non-Send type into a thread, compilation fails—preventing a whole class of runtime bugs:
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
// This won't compile:
// thread::spawn(move || {
// println!("{:?}", data);
// });
// error: `Rc>` cannot be sent between threads safely
}
The compiler catches this at build time. If you need shared reference counting across threads, use Arc<T> (atomic reference counting) instead.
Arc<Mutex<T>> — Shared mutable state across threads
When multiple threads need to read and write the same data, Arc<Mutex<T>> is the workhorse pattern. Arc handles thread-safe reference counting, and Mutex ensures only one thread accesses the data at a time.
Consider a ride-hailing service tracking active driver locations. Multiple request threads need to read the current location, and a location-update thread modifies it:
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
struct DriverLocation {
driver_id: u32,
latitude: f64,
longitude: f64,
}
fn main() {
let location = Arc::new(Mutex::new(DriverLocation {
driver_id: 42,
latitude: 40.7128,
longitude: -74.0060,
}));
// Spawn a location-update thread
let loc_update = Arc::clone(&location);
let update_handle = thread::spawn(move || {
for i in 0..5 {
let mut loc = loc_update.lock().unwrap();
loc.latitude += 0.001;
loc.longitude += 0.001;
println!("Updated: ({}, {})", loc.latitude, loc.longitude);
drop(loc); // Release lock explicitly
thread::sleep(Duration::from_millis(200));
}
});
// Spawn reader threads
let mut readers = vec![];
for _ in 0..3 {
let loc_read = Arc::clone(&location);
let reader = thread::spawn(move || {
for _ in 0..3 {
let loc = loc_read.lock().unwrap();
println!("Read: ({}, {})", loc.latitude, loc.longitude);
thread::sleep(Duration::from_millis(100));
}
});
readers.push(reader);
}
update_handle.join().unwrap();
for handle in readers {
handle.join().unwrap();
}
}
Arc::clone(&location) increments the reference count without copying the data. Each thread gets its own Arc pointer to the same underlying Mutex. When a thread calls lock(), it either acquires the lock immediately or blocks until the current holder releases it.
The lock() call returns a guard that automatically releases the lock when dropped (end of scope or explicit drop()). This RAII pattern prevents accidental lock leaks.
In production systems handling thousands of concurrent requests, be cautious: holding a lock while doing I/O creates contention. Better designs often use channels or message passing instead.
Channels — mpsc channels send data between threads
Message passing via channels is often cleaner than shared mutable state. Rust's multi-producer, single-consumer (mpsc) channel lets threads send data safely without explicit locks.
In a trading platform, imagine order requests arriving from multiple WebSocket connections (producers) and a single order-matching engine (consumer) processing them:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
#[derive(Debug, Clone)]
struct Order {
order_id: u64,
symbol: String,
quantity: u32,
price: f64,
}
fn main() {
let (tx, rx) = mpsc::channel::();
// Spawn multiple producer threads (WebSocket handlers)
for client_id in 0..3 {
let tx = tx.clone();
thread::spawn(move || {
for order_num in 0..2 {
let order = Order {
order_id: (client_id * 10 + order_num) as u64,
symbol: "AAPL".to_string(),
quantity: 100,
price: 150.0,
};
tx.send(order).expect("Channel receiver dropped");
thread::sleep(Duration::from_millis(50));
}
});
}
// Drop original sender so the receiver knows when all producers are done
drop(tx);
// Consumer thread processes orders
let consumer = thread::spawn(move || {
let mut count = 0;
for order in rx.iter() {
println!("Processing order {}: {} x {} @ ${}",
order.order_id, order.quantity, order.symbol, order.price);
count += 1;
}
println!("Processed {} orders total", count);
});
consumer.join().unwrap();
}
Key points:
mpsc::channel()returns a sender and receiver. You clone the sender to create multiple producers.- When you call
send(), the data is moved to the receiver's queue. No copying, no locks on the data itself. - The receiver can iterate with
rx.iter(), which blocks until a message arrives or all senders are dropped. - Dropping the original
txis crucial—the receiver's iterator terminates when it can't receive more.
Channels scale better than Mutex for producer-consumer workflows because threads don't contend for locks. Each producer enqueues independently, and the consumer processes at its own pace.
Common mistakes
Forgetting to drop senders: If you spawn a thread with a sender but forget to drop the original, the receiver's iterator never terminates. Always drop senders when they're no longer needed.
Holding locks across await points: In async code, holding a Mutex guard across an await is a deadlock risk. Use tokio::sync::Mutex for async contexts instead.
Cloning Arc excessively: While cheap, cloning Arc in tight loops adds atomic operations. Cache it in a local variable outside the loop.
Panicking in threads: A panic in a spawned thread doesn't crash your program—it just closes that thread. If you need panic awareness, use catch_unwind() or design error handling explicitly.
Deadlocks with nested locks: Acquiring locks in different orders across threads causes deadlock. Document lock ordering or use higher-level abstractions like channels.
Key takeaways
- Use
std::thread::spawnfor OS-level parallelism; the compiler ensures data safety via ownership rules. SendandSynctraits guarantee thread safety at compile time, preventing whole categories of race conditions.Arc<Mutex<T>>enables shared mutable state; prefer channels when possible to avoid lock contention.- Channels (mpsc) are ideal for producer-consumer patterns and scale better than shared locks in high-concurrency scenarios.
- Always drop senders when done, release locks promptly (via drop or scope), and avoid panics silently killing threads in production.






Comments
Loading comments…