XplormityXplormity
HomeHandbooks
Browse
Xplormity

TLDR developer handbooks for
seasoned developers.

Handbooks

RustNestJSNext.jsGitDockerTypeScriptReactNode.jsDSASQLSystem DesignTailwind CSS

Site

HomeHandbooksAboutPrivacyTerms

Connect

GitHubTwitterLinkedIn

© 2026 Xplormity. All rights reserved.

HandbooksRustConcurrency & Shared State

Concurrency & Shared State

concurrencythreadsarcmutexchannelshandbook

TL;DR

  • Rust guarantees fearless concurrency — data races caught at compile time.
  • Arc<T> for shared ownership across threads. Mutex<T> for mutable shared state.
  • Channels (mpsc) for message passing between threads.
  • The type system enforces Send + Sync — unsafe threading is impossible by accident.

Step 1: Threading Basics

Rust's threading model provides OS-level threads with compile-time safety guarantees that prevent data races entirely. This was Rust's founding innovation — in C/C++, concurrent code is a minefield of undefined behavior, and in managed languages, you get safety but pay with garbage collection pauses. Rust's ownership system ensures at compile time that shared data is either immutable or exclusively owned, eliminating the "two threads write to the same memory" bugs that crash production systems and are nearly impossible to reproduce.

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

fn main() {
    // Spawn a thread
    let handle = thread::spawn(|| {
        for i in 0..5 {
            println!("Thread: {i}");
            thread::sleep(Duration::from_millis(100));
        }
        42 // Return value from thread
    });

    // Main thread continues
    for i in 0..3 {
        println!("Main: {i}");
        thread::sleep(Duration::from_millis(150));
    }

    // Wait for thread to finish, get return value
    let result = handle.join().unwrap(); // 42
    println!("Thread returned: {result}");
}

Moving Data Into Threads

// ❌ Can't borrow across threads
let data = vec![1, 2, 3];
thread::spawn(|| {
    println!("{:?}", data); // Error: closure may outlive data
});

// ✅ Move ownership into thread
let data = vec![1, 2, 3];
thread::spawn(move || {
    println!("{:?}", data); // ✅ data is owned by this thread now
});
// Can't use `data` here anymore — it moved

Step 2: Arc — Shared Ownership Across Threads

Arc (Atomic Reference Counted) solves the problem of multiple threads needing read access to the same data. Regular Rc uses non-atomic operations (fast but not thread-safe); Arc uses atomic operations (slightly slower but safe across threads). Without Arc, you'd need to clone data for each thread (wasteful for large structures) or use unsafe raw pointers. Arc lets multiple threads share ownership of heap data, with the last thread to drop its Arc handle freeing the memory.

Arc (Atomic Reference Counted) lets multiple threads own the same data:

use std::sync::Arc;
use std::thread;

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

    let mut handles = vec![];

    for i in 0..3 {
        let data_clone = Arc::clone(&data); // Increment reference count
        let handle = thread::spawn(move || {
            // Each thread has read access
            let sum: i32 = data_clone.iter().sum();
            println!("Thread {i}: sum = {sum}");
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
    // When all Arcs are dropped, data is deallocated
}

Rc vs Arc

Rc<T> Arc<T>
Thread safety Single-thread only Multi-thread safe
Performance Faster (no atomic ops) Slightly slower
Implements !Send Send + Sync
Use when Everything in one thread Sharing across threads

Step 3: Mutex — Mutable Shared State

Mutex provides mutual exclusion — only one thread can access the inner data at a time. It exists because Arc alone only gives shared immutable access; when threads need to modify shared state (counters, caches, connection pools), you need synchronized mutable access. Rust's Mutex is unique: it wraps the data itself (not a code section), so you literally cannot access the data without locking. The lock guard auto-releases on drop, preventing the "forgot to unlock" bugs common in other languages.

Mutex provides exclusive access. Only one thread can hold the 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);
        let handle = thread::spawn(move || {
            // Lock — blocks until available
            let mut num = counter.lock().unwrap();
            *num += 1;
            // Lock automatically released when `num` goes out of scope
        });
        handles.push(handle);
    }

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

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

RwLock — Multiple Readers OR One Writer

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

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

// Multiple readers simultaneously — OK
let reader1 = data.read().unwrap();
let reader2 = data.read().unwrap(); // ✅ Both can read at once

// One writer — blocks all readers
drop(reader1);
drop(reader2);
let mut writer = data.write().unwrap(); // Exclusive access
writer.push(4);

When to Use

Mutex RwLock
Read pattern Mostly writes or mixed Mostly reads, few writes
Complexity Simpler Slightly more complex
Performance Always locks Better when reads dominate

Step 4: Channels — Message Passing

Channels implement the "share memory by communicating" philosophy (from Go and Erlang). Instead of threads sharing mutable state through locks, they send messages to each other through typed channels. This model is often safer and easier to reason about: each piece of data has one owner at a time, transferred through the channel. Rust's mpsc (Multiple Producer, Single Consumer) channel moves ownership of values between threads, making it impossible to accidentally access sent data from the sender.

Rust's mpsc (Multiple Producer, Single Consumer) channel:

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

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

    // Spawn producer
    thread::spawn(move || {
        let messages = vec!["hello", "from", "thread"];
        for msg in messages {
            tx.send(msg.to_string()).unwrap();
            thread::sleep(Duration::from_millis(200));
        }
        // tx is dropped here — channel closes
    });

    // Receive in main thread
    for received in rx {
        // Blocks until message arrives or channel closes
        println!("Got: {received}");
    }
}

Multiple Producers

let (tx, rx) = mpsc::channel();

for i in 0..5 {
    let tx_clone = tx.clone(); // Clone sender for each thread
    thread::spawn(move || {
        tx_clone.send(format!("Message from thread {i}")).unwrap();
    });
}

drop(tx); // Drop original — only clones are sending now

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

Bounded Channel (Backpressure)

// sync_channel blocks sender when buffer is full
let (tx, rx) = mpsc::sync_channel(10); // Buffer of 10

thread::spawn(move || {
    for i in 0..100 {
        tx.send(i).unwrap(); // Blocks if buffer full — backpressure!
    }
});

Step 5: Send & Sync Traits

Send and Sync are marker traits that encode thread-safety at the type level — they're what makes Rust's "fearless concurrency" actually work. Send means a value can be transferred to another thread; Sync means a value can be shared (via reference) between threads. The compiler auto-derives these based on a type's contents, and refuses to compile code that passes non-Send types across thread boundaries. This catches data race bugs at compile time that would be runtime crashes in any other language.

The type system enforces thread safety:

// Send: Type can be TRANSFERRED to another thread
// Sync: Type can be SHARED (via &T) between threads

// Most types are Send + Sync by default
// Exceptions:
// - Rc<T>: !Send (not safe to transfer — non-atomic reference count)
// - Cell<T>, RefCell<T>: !Sync (not safe to share — interior mutability without atomic)
// - Raw pointers: !Send, !Sync

Practical Impact

// ✅ Vec, String, numbers — all Send + Sync
let data = vec![1, 2, 3];
thread::spawn(move || println!("{:?}", data)); // Fine

// ❌ Rc is !Send — can't move to another thread
let data = Rc::new(42);
thread::spawn(move || println!("{}", data)); // Compile error!

// ✅ Use Arc instead
let data = Arc::new(42);
thread::spawn(move || println!("{}", data)); // Fine

Step 6: Concurrency Patterns

These patterns combine Rust's concurrency primitives into production-ready architectures. Worker pools distribute tasks across threads (web servers, job queues). Fan-out/fan-in splits work and collects results (data processing pipelines). Rate limiters control throughput (API clients). Each pattern leverages Rust's ownership system to be both safe and zero-cost — no garbage collector pauses during high-throughput concurrent workloads, which is why Rust dominates in systems requiring predictable latency.

Pattern 1: Worker Pool

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

struct ThreadPool {
    workers: Vec<thread::JoinHandle<()>>,
    sender: Option<mpsc::Sender<Box<dyn FnOnce() + Send>>>,
}

impl ThreadPool {
    fn new(size: usize) -> Self {
        let (sender, receiver) = mpsc::channel::<Box<dyn FnOnce() + Send>>();
        let receiver = Arc::new(Mutex::new(receiver));

        let workers: Vec<_> = (0..size)
            .map(|_| {
                let rx = Arc::clone(&receiver);
                thread::spawn(move || {
                    while let Ok(job) = rx.lock().unwrap().recv() {
                        job();
                    }
                })
            })
            .collect();

        ThreadPool { workers, sender: Some(sender) }
    }

    fn execute<F: FnOnce() + Send + 'static>(&self, f: F) {
        self.sender.as_ref().unwrap().send(Box::new(f)).unwrap();
    }
}

Pattern 2: Producer-Consumer with Shared State

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

struct SharedQueue<T> {
    queue: Mutex<Vec<T>>,
    not_empty: Condvar,
}

impl<T> SharedQueue<T> {
    fn new() -> Self {
        SharedQueue {
            queue: Mutex::new(Vec::new()),
            not_empty: Condvar::new(),
        }
    }

    fn push(&self, item: T) {
        let mut queue = self.queue.lock().unwrap();
        queue.push(item);
        self.not_empty.notify_one(); // Wake one waiting consumer
    }

    fn pop(&self) -> T {
        let mut queue = self.queue.lock().unwrap();
        while queue.is_empty() {
            queue = self.not_empty.wait(queue).unwrap(); // Sleep until notified
        }
        queue.remove(0)
    }
}

Step 7: Avoiding Common Concurrency Bugs

Even with Rust's compile-time guarantees, logical concurrency bugs (deadlocks, livelocks, starvation) are still possible because they're about program logic, not memory safety. Deadlocks happen when two threads each hold a lock the other needs. Starvation happens when one thread monopolizes a lock. These patterns show how to prevent them: consistent lock ordering, timeout-based acquisition, try_lock for non-blocking attempts, and preferring channels over shared state when possible.

Deadlock Prevention

// ❌ Deadlock — two threads lock in opposite order
// Thread 1: lock A, then lock B
// Thread 2: lock B, then lock A

// ✅ Always lock in the same order
// Or use try_lock() with timeout
if let Ok(guard) = mutex.try_lock() {
    // Got the lock
} else {
    // Lock is held — try again later
}

Poisoned Mutex

// If a thread panics while holding a lock, the Mutex is "poisoned"
let result = mutex.lock();
match result {
    Ok(guard) => { /* use data */ }
    Err(poisoned) => {
        // Recover the data (may be inconsistent)
        let guard = poisoned.into_inner();
        // Or propagate: panic!("mutex poisoned")
    }
}

Interview Questions

  1. What's "fearless concurrency" in Rust?

    • The type system (Send + Sync traits) prevents data races at compile time. If your code compiles, it's free of data races. No runtime checks needed.
  2. When would you use channels vs Arc<Mutex>?

    • Channels for message passing (producer-consumer, event systems). Arc<Mutex> for shared mutable state that multiple threads read/write. Channels are generally preferred — easier to reason about.
  3. What's the difference between Rc and Arc?

    • Both are reference-counted smart pointers. Rc uses non-atomic operations (faster, single-thread). Arc uses atomic operations (thread-safe, slightly slower).
  4. What causes a mutex to be "poisoned"?

    • When a thread panics while holding the lock. The data inside might be in an inconsistent state. Subsequent lock() calls return Err(PoisonError).
Lifetimes Deep DiveSmart Pointers & Interior Mutability

On this page

  • TL;DR
  • Step 1: Threading Basics
  • Moving Data Into Threads
  • Step 2: Arc — Shared Ownership Across Threads
  • Rc vs Arc
  • Step 3: Mutex — Mutable Shared State
  • RwLock — Multiple Readers OR One Writer
  • When to Use
  • Step 4: Channels — Message Passing
  • Multiple Producers
  • Bounded Channel (Backpressure)
  • Step 5: Send & Sync Traits
  • Practical Impact
  • Step 6: Concurrency Patterns
  • Pattern 1: Worker Pool
  • Pattern 2: Producer-Consumer with Shared State
  • Step 7: Avoiding Common Concurrency Bugs
  • Deadlock Prevention
  • Poisoned Mutex
  • Interview Questions