Skip to main content
Rust intermediate Lesson 20 of 30

Concurrency in Rust

Write safe concurrent code with threads, Arc<Mutex<T>>, channels (mpsc), Send/Sync traits, and Rayon.

Spawning Threads

Rust’s threading model maps directly to OS threads. Each thread is an independent unit of execution with its own stack. The type system enforces that data crossing thread boundaries is safe — you cannot accidentally share a non-thread-safe type across threads. The compiler will reject the code at compile time, not crash at runtime.

use std::thread;
use std::time::Duration;

fn main() {
    // thread::spawn takes a closure and runs it on a new OS thread
    let handle = thread::spawn(|| {
        for i in 1..=5 {
            println!("spawned thread: {}", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..=3 {
        println!("main thread: {}", i);
        thread::sleep(Duration::from_millis(1));
    }

    // join() waits for the thread to finish and propagates any panic
    handle.join().unwrap();
}

thread::spawn returns a JoinHandle. Call .join() to wait for the thread and propagate panics.

Moving Data into Threads

Threads require owned data. If a thread borrowed a variable, the variable’s owner might drop it while the thread is still running — a use-after-free. The move keyword on a closure transfers ownership of all captured variables into the closure, ensuring the thread owns everything it uses and the data lives long enough.

use std::thread;

fn main() {
    let data = vec![1, 2, 3, 4, 5];

    // move transfers ownership of data into the closure
    // Without move, the borrow would dangle if main() returned first
    let handle = thread::spawn(move || {
        let sum: i32 = data.iter().sum();
        println!("sum: {}", sum);
        sum // threads can return a value via JoinHandle
    });

    let result = handle.join().unwrap();
    println!("thread returned: {}", result);
}

Shared State: Arc<Mutex<T>>

When multiple threads need to read and write the same data, you need two things: shared ownership (Arc) and mutual exclusion (Mutex). Arc<T> (Atomically Reference Counted) is a thread-safe reference-counted pointer — cloning it gives you another handle to the same heap allocation. Mutex<T> ensures only one thread accesses the inner value at a time by blocking others until the lock is released.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Arc allows multiple threads to share ownership of the Mutex
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        // Clone the Arc to get another handle to the same Mutex
        let counter = Arc::clone(&counter);
        let h = thread::spawn(move || {
            let mut num = counter.lock().unwrap(); // acquire the lock
            *num += 1;
            // lock is automatically released when `num` goes out of scope (RAII)
        });
        handles.push(h);
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("final count: {}", *counter.lock().unwrap()); // 10
}

Deadlock Prevention

Rust does not prevent deadlocks at compile time, but good practices keep them rare: keep lock scopes as small as possible, always acquire multiple locks in the same order across all threads, and prefer channels for communication over shared mutable state.

Channels — Message Passing

Channels implement the “share memory by communicating” philosophy. Instead of multiple threads reaching into shared state, one thread produces a value and passes ownership to another via a channel. This eliminates the class of bugs where multiple threads observe inconsistent intermediate state. std::sync::mpsc provides Multiple Producer, Single Consumer channels.

use std::sync::mpsc;
use std::thread;

fn main() {
    // tx = transmitter (sender), rx = receiver
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let messages = vec!["hello", "from", "thread"];
        for msg in messages {
            tx.send(msg).unwrap(); // ownership of msg transfers through the channel
        }
        // tx is dropped here — the channel closes, rx will stop blocking
    });

    // Iterate until the channel is closed (all senders dropped)
    for msg in rx {
        println!("{}", msg);
    }
}

Multiple Producers

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        // Clone the sender for each producer thread — all share one receiver
        let tx = tx.clone();
        thread::spawn(move || {
            tx.send(format!("message from thread {}", id)).unwrap();
        });
    }

    // Drop the original sender so rx knows when all clones are gone
    drop(tx);

    for msg in rx {
        println!("{}", msg);
    }
}

sync_channel — Bounded Channel

A bounded channel applies backpressure: the sender blocks when the buffer is full, preventing a fast producer from overwhelming a slow consumer. This is the right choice for producer-consumer pipelines where you want to limit memory usage.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::sync_channel(2); // buffer holds at most 2 items

    thread::spawn(move || {
        for i in 0..5 {
            println!("sending {}", i);
            tx.send(i).unwrap(); // blocks when buffer is full — provides backpressure
        }
    });

    thread::sleep(std::time::Duration::from_millis(50));

    for val in rx {
        println!("received {}", val);
    }
}

Send and Sync Marker Traits

Send and Sync are the compile-time mechanism that makes Rust’s concurrency guarantees possible. They are automatically derived for most types and cannot be implemented incorrectly — the compiler verifies the rules. Any type that violates the rules (like Rc<T> which uses non-atomic reference counting) is automatically excluded from cross-thread use, and the error appears at the call site rather than as a runtime crash.

  • Send: Safe to transfer ownership to another thread.
  • Sync: Safe to share a reference across threads (&T is Send).
fn requires_send<T: Send>(_: T) {}
fn requires_sync<T: Sync>(_: T) {}

fn main() {
    // String is Send — fine to move to another thread
    let s = String::from("hello");
    requires_send(s);

    // Rc<T> is NOT Send — it uses non-atomic reference counting
    // Uncommenting the next two lines would be a compile error:
    // let rc = std::rc::Rc::new(1);
    // requires_send(rc); // ERROR: Rc<i32> cannot be sent between threads safely
}

Most types are automatically Send + Sync. Notable exceptions:

  • Rc<T> — not Send (use Arc<T>)
  • Cell<T> / RefCell<T> — not Sync (use Mutex<T>)
  • Raw pointers *const T / *mut T — neither

RwLock<T> — Multiple Readers, Single Writer

Mutex grants exclusive access on every lock. If the data is read far more often than written, this is unnecessarily restrictive. RwLock allows any number of concurrent readers while still enforcing that writes are exclusive — a significant throughput improvement for read-heavy workloads like caches and configuration stores.

use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let data = Arc::new(RwLock::new(vec![1, 2, 3]));

    // Multiple readers can hold read locks simultaneously
    let mut handles = vec![];
    for _ in 0..3 {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let read = data.read().unwrap(); // shared read lock
            println!("read: {:?}", *read);
        }));
    }

    for h in handles { h.join().unwrap(); }

    // Write lock is exclusive — waits for all readers to finish
    data.write().unwrap().push(4);
    println!("after write: {:?}", data.read().unwrap());
}

Atomic Types

For simple numeric operations like counters and flags, atomics are faster than a mutex because they use hardware-level atomic CPU instructions rather than OS locks. There is no blocking, no context switching, and no heap allocation. The Ordering parameter controls the memory visibility guarantees — SeqCst is the safest choice when in doubt.

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

fn main() {
    let counter = Arc::new(AtomicUsize::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let c = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            // fetch_add is a single atomic CPU instruction — no lock needed
            c.fetch_add(1, Ordering::SeqCst);
        }));
    }

    for h in handles { h.join().unwrap(); }
    println!("{}", counter.load(Ordering::SeqCst)); // 10
}

Rayon — Data Parallelism

Rayon makes data parallelism easy: swap .iter() for .par_iter() and Rayon automatically divides the work across all available CPU cores using a work-stealing thread pool. No manual thread management, no channels, no mutexes — just a one-word change to your iterator chain.

[dependencies]
rayon = "1"
use rayon::prelude::*;

fn is_prime(n: u64) -> bool {
    if n < 2 { return false; }
    if n == 2 { return true; }
    if n % 2 == 0 { return false; }
    let limit = (n as f64).sqrt() as u64;
    !(3..=limit).step_by(2).any(|i| n % i == 0)
}

fn main() {
    // Sequential — single core
    let primes_seq: Vec<u64> = (2..100_000)
        .filter(|&n| is_prime(n))
        .collect();

    // Parallel — all cores, identical output, one method change
    let primes_par: Vec<u64> = (2u64..100_000)
        .into_par_iter()         // the only change from the sequential version
        .filter(|&n| is_prime(n))
        .collect();

    println!("found {} primes", primes_par.len());

    // Parallel map-reduce — sum of squares
    let sum: u64 = (1u64..=1_000_000)
        .into_par_iter()
        .map(|x| x * x)
        .sum();
    println!("sum of squares: {}", sum);
}

Rayon also provides parallel sort:

use rayon::prelude::*;

fn main() {
    let mut data: Vec<i32> = (0..1_000_000).map(|x| 1_000_000 - x).collect();
    data.par_sort(); // parallel sort — significantly faster on large collections
    println!("sorted: {} .. {}", data[0], data[data.len() - 1]);
}

Frequently Asked Questions

How does Rust prevent data races?
The Send and Sync marker traits combined with the ownership and borrow checker prevent data races at compile time. You cannot share a non-Sync type across threads or send a non-Send type to another thread.
When should I use a Mutex vs a channel?
Use a Mutex when multiple threads need shared mutable access to a single piece of state. Use channels when threads need to communicate by passing messages — channels express ownership transfer, which is more idiomatic in Rust.
What is Rayon?
Rayon is a data parallelism library that provides parallel iterators. Replacing .iter() with .par_iter() divides the work across a thread pool automatically.