Rust Concurrency: Channels, Mutex, and Arc Patterns
Learn Rust concurrency with channels, Mutex, Arc, and thread-safe patterns. Covers mpsc, shared state, thread pools, and real-world examples.
What you'll learn
- ✓Send messages between threads with channels (mpsc)
- ✓Share state safely with Arc and Mutex
- ✓Choose between message passing and shared state
- ✓Build practical concurrent patterns like worker pools
Prerequisites
- •Rust basics — see /blog/rust-variables-and-types
- •Ownership and borrowing — see /blog/rust-ownership-basics
- •Traits — see /blog/rust-traits-basics
Concurrency in Rust is built on a simple principle: the same ownership and type system that prevents memory bugs also prevents data races. If your code compiles, it is free of data races. This is not a runtime check — it is a compile-time guarantee.
Rust provides two main concurrency models: message passing with channels and shared state with locks. Both are safe by default. This guide covers both approaches with practical patterns you can use immediately.
Spawning Threads
The foundation of concurrency is std::thread::spawn:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("Spawned thread: {}", i);
thread::sleep(Duration::from_millis(100));
}
});
for i in 1..=3 {
println!("Main thread: {}", i);
thread::sleep(Duration::from_millis(150));
}
handle.join().unwrap(); // Wait for the thread to finish
println!("Both threads done");
}
Spawned threads must own their data. You cannot borrow from the main thread because the spawned thread might outlive it:
use std::thread;
fn main() {
let message = String::from("hello from main");
// `move` transfers ownership to the thread
let handle = thread::spawn(move || {
println!("{}", message);
});
// println!("{}", message); // ERROR — message was moved
handle.join().unwrap();
}
Message Passing with Channels
Channels let threads communicate by sending values to each other. Rust’s standard library provides mpsc — multiple producer, single consumer:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec!["hello", "from", "the", "thread"];
for msg in messages {
tx.send(msg).unwrap();
thread::sleep(std::time::Duration::from_millis(200));
}
});
// Receive messages as they arrive
for received in rx {
println!("Got: {}", received);
}
}
The for received in rx loop blocks waiting for messages and automatically stops when all senders are dropped.
Multiple Producers
Clone the sender to share it across threads:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
let result = expensive_work(id);
tx.send((id, result)).unwrap();
});
}
// Drop the original sender so rx knows when all senders are done
drop(tx);
for (id, result) in rx {
println!("Worker {} finished: {}", id, result);
}
}
fn expensive_work(id: u32) -> String {
thread::sleep(std::time::Duration::from_millis(100 * id as u64));
format!("result from worker {}", id)
}
Bounded Channels
mpsc::channel() creates an unbounded channel that can grow without limit. Use mpsc::sync_channel(n) for backpressure:
use std::sync::mpsc;
use std::thread;
fn main() {
// Buffer holds at most 2 messages
let (tx, rx) = mpsc::sync_channel(2);
let producer = thread::spawn(move || {
for i in 0..10 {
println!("Sending {}", i);
tx.send(i).unwrap(); // Blocks if buffer is full
println!("Sent {}", i);
}
});
let consumer = thread::spawn(move || {
for val in rx {
println!("Processing {}", val);
thread::sleep(std::time::Duration::from_millis(500));
}
});
producer.join().unwrap();
consumer.join().unwrap();
}
The producer blocks on send when the buffer is full, preventing unbounded memory growth.
Shared State with Mutex
When multiple threads need to read and write the same data, use Mutex<T> for mutual exclusion:
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
{
let mut num = counter.lock().unwrap();
*num += 1;
} // Lock is released when `num` goes out of scope
println!("Counter: {}", *counter.lock().unwrap());
}
lock() returns a MutexGuard that implements Deref — you can use it like a reference to the inner data. The lock is automatically released when the guard is dropped.
Sharing Mutex Across Threads with Arc
Mutex<T> itself is not Clone. To share it across threads, wrap it in Arc (atomically reference counted):
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);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap()); // 10
}
Arc is the thread-safe version of Rc. It uses atomic operations for reference counting, so it is safe to share across threads.
Avoiding Deadlocks
Deadlocks happen when two threads each hold a lock and wait for the other’s lock. Rust prevents data races but not deadlocks — those are a logic error.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let lock_a = Arc::new(Mutex::new(0));
let lock_b = Arc::new(Mutex::new(0));
// DANGER: this can deadlock
// Thread 1 locks A then B, Thread 2 locks B then A
let a1 = Arc::clone(&lock_a);
let b1 = Arc::clone(&lock_b);
let t1 = thread::spawn(move || {
let _a = a1.lock().unwrap();
thread::sleep(std::time::Duration::from_millis(10));
let _b = b1.lock().unwrap(); // Might deadlock waiting for B
});
let a2 = Arc::clone(&lock_a);
let b2 = Arc::clone(&lock_b);
let t2 = thread::spawn(move || {
let _b = b2.lock().unwrap();
thread::sleep(std::time::Duration::from_millis(10));
let _a = a2.lock().unwrap(); // Might deadlock waiting for A
});
// FIX: always acquire locks in the same order
// Both threads should lock A first, then B
}
Rules to avoid deadlocks:
- Always acquire multiple locks in a consistent order.
- Hold locks for the shortest possible time.
- Prefer channels over shared state when possible.
RwLock: Multiple Readers, One Writer
When reads are much more frequent than writes, RwLock allows multiple simultaneous readers:
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(AppConfig {
max_connections: 100,
timeout_ms: 5000,
}));
let mut handles = vec![];
// Spawn 5 reader threads
for i in 0..5 {
let config = Arc::clone(&config);
handles.push(thread::spawn(move || {
let cfg = config.read().unwrap();
println!("Reader {}: max_connections = {}", i, cfg.max_connections);
}));
}
// Spawn 1 writer thread
{
let config = Arc::clone(&config);
handles.push(thread::spawn(move || {
let mut cfg = config.write().unwrap();
cfg.max_connections = 200;
println!("Writer: updated max_connections to 200");
}));
}
for handle in handles {
handle.join().unwrap();
}
}
struct AppConfig {
max_connections: u32,
timeout_ms: u64,
}
Practical Pattern: Worker Pool
A worker pool distributes tasks across a fixed number of threads:
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
type Job = Box<dyn FnOnce() + Send + 'static>;
struct WorkerPool {
workers: Vec<thread::JoinHandle<()>>,
sender: Option<mpsc::Sender<Job>>,
}
impl WorkerPool {
fn new(size: usize) -> Self {
let (sender, receiver) = mpsc::channel::<Job>();
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
let receiver = Arc::clone(&receiver);
workers.push(thread::spawn(move || {
loop {
let job = {
let lock = receiver.lock().unwrap();
lock.recv()
};
match job {
Ok(job) => {
println!("Worker {} executing job", id);
job();
}
Err(_) => {
println!("Worker {} shutting down", id);
break;
}
}
}
}));
}
WorkerPool {
workers,
sender: Some(sender),
}
}
fn execute<F: FnOnce() + Send + 'static>(&self, job: F) {
if let Some(sender) = &self.sender {
sender.send(Box::new(job)).unwrap();
}
}
fn shutdown(mut self) {
// Drop the sender to signal workers to stop
self.sender.take();
for worker in self.workers {
worker.join().unwrap();
}
}
}
fn main() {
let pool = WorkerPool::new(4);
for i in 0..8 {
pool.execute(move || {
println!("Task {} running on {:?}", i, thread::current().id());
thread::sleep(std::time::Duration::from_millis(100));
});
}
pool.shutdown();
println!("All tasks complete");
}
Practical Pattern: Concurrent Map
Process a collection in parallel and collect results:
use std::sync::{mpsc, Arc};
use std::thread;
fn parallel_map<T, R, F>(items: Vec<T>, f: F, num_threads: usize) -> Vec<R>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> R + Send + Sync + 'static,
{
let (tx, rx) = mpsc::channel();
let f = Arc::new(f);
let chunks: Vec<Vec<(usize, T)>> = items
.into_iter()
.enumerate()
.collect::<Vec<_>>()
.chunks(num_threads.max(1))
.map(|c| c.to_vec())
.collect();
for chunk in chunks {
let tx = tx.clone();
let f = Arc::clone(&f);
thread::spawn(move || {
for (index, item) in chunk {
let result = f(item);
tx.send((index, result)).unwrap();
}
});
}
drop(tx);
let mut results: Vec<(usize, R)> = rx.into_iter().collect();
results.sort_by_key(|(i, _)| *i);
results.into_iter().map(|(_, r)| r).collect()
}
fn main() {
let numbers: Vec<u64> = (1..=20).collect();
let squares = parallel_map(numbers, |n| {
thread::sleep(std::time::Duration::from_millis(50));
n * n
}, 4);
println!("{:?}", squares);
}
Channels vs Shared State: When to Use Which
Use channels when:
- You have producer-consumer workflows.
- Tasks are independent and communicate through messages.
- You want to avoid lock contention.
- The data naturally flows in one direction.
Use shared state (Arc + Mutex) when:
- Multiple threads need random access to the same data structure.
- You are building a cache or lookup table.
- The overhead of serializing/deserializing messages exceeds lock overhead.
Use RwLock when:
- Reads vastly outnumber writes.
- You want multiple threads to read concurrently.
In practice, many programs use a combination. Channels for the main data flow, shared state for configuration or caches.
Atomic Types for Lock-Free Operations
For simple counters and flags, atomic types avoid the overhead of a Mutex:
use std::sync::atomic::{AtomicU64, AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicU64::new(0));
let running = Arc::new(AtomicBool::new(true));
let mut handles = vec![];
for _ in 0..4 {
let counter = Arc::clone(&counter);
let running = Arc::clone(&running);
handles.push(thread::spawn(move || {
while running.load(Ordering::Relaxed) {
counter.fetch_add(1, Ordering::Relaxed);
thread::sleep(std::time::Duration::from_millis(10));
}
}));
}
thread::sleep(std::time::Duration::from_millis(100));
running.store(false, Ordering::Relaxed);
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", counter.load(Ordering::Relaxed));
}
Use Ordering::Relaxed for counters where exact ordering does not matter. Use Ordering::SeqCst when you need strict ordering guarantees.
Wrapping Up
Rust’s concurrency model gives you fearless concurrency — the compiler prevents data races at compile time. The key tools are:
- Threads with
std::thread::spawnandmoveclosures for ownership transfer. - Channels (
mpsc) for message passing between threads. Clone the sender for multiple producers. - Arc for shared ownership across threads. Always pair with Mutex, RwLock, or atomic types.
- Mutex for exclusive access to shared data. Keep critical sections short.
- RwLock for read-heavy workloads that benefit from concurrent readers.
- Atomics for lock-free counters and flags.
Start with channels for communication and Arc+Mutex for shared state. As your needs grow, consider the worker pool pattern, and look into crates like rayon for parallel iterators and crossbeam for advanced concurrent data structures.
Related articles
- Rust Rust Channels and mpsc Tutorial
A practical tour of Rust's std::sync::mpsc channels: senders, receivers, backpressure, and patterns for safe message passing between threads.
- Rust Rust Async Programming with Tokio: Getting Started
Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.
- Rust Understanding Rust Smart Pointers: Box, Rc, and Arc
A hands-on guide to Rust smart pointers covering heap allocation with Box, shared ownership with Rc, and thread-safe sharing with Arc.
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.