Skip to content
Codeloom
Rust

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.

·9 min read · By Codeloom
Intermediate 14 min read

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:

  1. Always acquire multiple locks in a consistent order.
  2. Hold locks for the shortest possible time.
  3. 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::spawn and move closures 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.