RustShared-State Concurrency

Shared-State Concurrency

Rust offers two complementary approaches to concurrency. The first — message passing with channels — moves data between threads without sharing it. The second — shared state — lets multiple threads access the same piece of memory, but controls that access with synchronisation primitives so only one thread mutates it at a time.

Both are safe in Rust. The type system and ownership rules prevent data races in either model, at compile time.

Approach

Mechanism

Best for

Message passing

Channels (mpsc)

Pipelines, producer/consumer, decoupled threads

Shared state

Mutex, RwLock, Atomics

Shared configuration, caches, counters

Mutex<T>: Mutual Exclusion

A Mutex (mutual exclusion lock) ensures that only one thread can access the inner data at a time. Before reading or writing, a thread must acquire the lock. When it is done, it releases the lock so another thread can proceed.

In Rust, Mutex is part of the standard library at std::sync::Mutex. The lock is acquired by calling .lock(), which blocks until the lock is available and returns a MutexGuard<T>. The guard gives you mutable access to the inner value and automatically releases the lock when it goes out of scope — this is the RAII pattern and it means you can never forget to unlock.

RUST
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut val = m.lock().unwrap(); // acquire the lock
        *val += 1;
        println!("val = {}", *val); // prints 6
    } // MutexGuard dropped here — lock is released automatically

    println!("m = {:?}", m);
}
val = 6
m = Mutex { data: 6, poisoned: false, .. }
Note
.lock() returns a Result because a mutex can be "poisoned" — if a thread panics while holding the lock, the mutex is marked poisoned so other threads know the data may be in an inconsistent state..unwrap() is fine in examples; in production, handle the error.
Arc<Mutex<T>>: Sharing Across Threads

A plain Mutex cannot be sent to multiple threads because ownership can only exist in one place. The solution is to wrap it in an Arc (Atomically Reference Counted) so that multiple threads can hold a reference-counted handle to the same mutex.

Arc<T> is the thread-safe equivalent of Rc<T>. Cloning an Arc increments the reference count atomically; when the last clone is dropped, the inner value is freed.

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

fn main() {
    // Wrap the counter so it can be shared across threads
    let counter = Arc::new(Mutex::new(0usize));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter); // cheap reference-count bump
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

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

    println!("Result: {}", *counter.lock().unwrap()); // 10
}
Result: 10
Tip
The pattern Arc::clone(&counter) before the spawn is idiomatic. It makes clear you are cloning the Arc (bumping the reference count), not deep-copying the data inside.
RwLock<T>: Multiple Readers or One Writer

A Mutex gives exclusive access to every caller — even threads that only want to read must wait for each other. An RwLock (read-write lock) is more nuanced:

  • Many threads can hold a read lock simultaneously — they share immutable access.
  • Only one thread can hold a write lock — no readers are allowed at the same time.

This makes RwLock a better choice for read-heavy workloads where reads are frequent but writes are rare, such as a shared configuration object.

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

fn main() {
    let config = Arc::new(RwLock::new(vec!["timeout=30", "retries=3"]));

    // Spawn several reader threads — they all run concurrently
    let mut handles = vec![];
    for i in 0..4 {
        let cfg = Arc::clone(&config);
        handles.push(thread::spawn(move || {
            let data = cfg.read().unwrap(); // multiple readers allowed
            println!("reader {} sees {} entries", i, data.len());
        }));
    }

    // Spawn one writer thread — waits until all readers finish
    let cfg = Arc::clone(&config);
    handles.push(thread::spawn(move || {
        let mut data = cfg.write().unwrap(); // exclusive access
        data.push("loglevel=info");
        println!("writer added entry, total: {}", data.len());
    }));

    for h in handles {
        h.join().unwrap();
    }
}
reader 0 sees 2 entries
reader 1 sees 2 entries
reader 2 sees 2 entries
reader 3 sees 2 entries
writer added entry, total: 3

Lock type

Read guard

Write guard

Concurrency

Mutex<T>

MutexGuard<T>

MutexGuard<T>

One thread at a time

RwLock<T>

RwLockReadGuard<T>

RwLockWriteGuard<T>

Many readers OR one writer

Warning
On some platforms, write starvation can occur if there is a constant stream of readers — the writer never gets a chance to acquire the lock. PreferMutex if fairness matters more than read concurrency.
Atomic Types: Lock-Free Primitives

For simple values — counters, flags, single integers — reaching for a Mutex is overkill. The std::sync::atomic module provides atomic types that can be read and modified across threads without any lock:

  • AtomicUsize, AtomicIsize, AtomicI32, AtomicU64
  • AtomicBool
  • AtomicPtr<T>

Atomic operations are implemented with CPU-level instructions (e.g., x86 LOCK XADD) that guarantee the operation completes without interruption.

RUST
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 || {
            c.fetch_add(1, Ordering::SeqCst); // atomic increment
        }));
    }

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

    println!("Counter: {}", counter.load(Ordering::SeqCst)); // 10
}
Counter: 10
Common Atomic Operations

RUST
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

fn main() {
    let n = AtomicUsize::new(10);

    // load / store
    println!("load: {}", n.load(Ordering::Relaxed));
    n.store(20, Ordering::Relaxed);

    // fetch_add / fetch_sub — returns the OLD value
    let old = n.fetch_add(5, Ordering::SeqCst);
    println!("old was {}, now {}", old, n.load(Ordering::SeqCst)); // 20, 25

    // compare_exchange(expected, new, success_ordering, failure_ordering)
    // Only swaps if the current value equals expected
    let result = n.compare_exchange(25, 100, Ordering::SeqCst, Ordering::Relaxed);
    println!("CAS result: {:?}", result); // Ok(25) — swap succeeded
    println!("n is now: {}", n.load(Ordering::SeqCst)); // 100

    // AtomicBool for flags
    let flag = AtomicBool::new(false);
    flag.store(true, Ordering::Release);
    println!("flag: {}", flag.load(Ordering::Acquire));
}
load: 10
old was 20, now 25
CAS result: Ok(25)
n is now: 100
flag: true
Memory Ordering

Every atomic operation requires a memory ordering argument that tells the CPU and compiler how much synchronisation is needed around the operation. Choosing the wrong ordering can lead to subtle bugs on multi-core systems.

Ordering

Guarantee

Use when

Relaxed

Only the atomic op itself is atomic — no ordering relative to other memory ops

Simple counters where only the final value matters

Acquire

All reads/writes after this load see memory written before the paired Release store

Reading a flag that guards other data

Release

All reads/writes before this store are visible to threads that do an Acquire load

Writing data then setting a ready flag

AcqRel

Combines Acquire + Release — for read-modify-write ops like fetch_add

fetch_add, compare_exchange

SeqCst

Total sequential consistency across all atomic ops in all threads

When in doubt — strongest guarantee, slight overhead

Note
When in doubt, use Ordering::SeqCst. It is the strongest ordering and is always correct, at the cost of a small performance penalty. Optimise to weaker orderings only when you understand the memory model well.
Shared Configuration with Arc<RwLock<T>>

A frequently read, occasionally updated configuration object is a classic use case for Arc&lt;RwLock&lt;Config&gt;&gt;. Readers take a read lock (concurrent), the updater takes the write lock (exclusive).

RUST
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

type Config = HashMap<String, String>;

fn read_config(cfg: &Arc<RwLock<Config>>, key: &str) -> Option<String> {
    let guard = cfg.read().unwrap();
    guard.get(key).cloned()
}

fn update_config(cfg: &Arc<RwLock<Config>>, key: &str, value: &str) {
    let mut guard = cfg.write().unwrap();
    guard.insert(key.to_string(), value.to_string());
}

fn main() {
    let config: Arc<RwLock<Config>> = Arc::new(RwLock::new(HashMap::new()));
    update_config(&config, "host", "localhost");
    update_config(&config, "port", "8080");

    println!("host = {:?}", read_config(&config, "host"));
    println!("port = {:?}", read_config(&config, "port"));
}
host = Some("localhost")
port = Some("8080")
Thread-Safe Cache with Arc<Mutex<HashMap>>

RUST
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let cache: Arc<Mutex<HashMap<u32, String>>> =
        Arc::new(Mutex::new(HashMap::new()));

    let mut handles = vec![];
    for i in 0..5u32 {
        let cache = Arc::clone(&cache);
        handles.push(thread::spawn(move || {
            let value = format!("result_{}", i * i);
            cache.lock().unwrap().insert(i, value);
        }));
    }

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

    let cache = cache.lock().unwrap();
    let mut keys: Vec<u32> = cache.keys().copied().collect();
    keys.sort();
    for k in keys {
        println!("{} => {}", k, cache[&k]);
    }
}
0 => result_0
1 => result_1
2 => result_4
3 => result_9
4 => result_16
Deadlock Avoidance

A deadlock occurs when two or more threads each hold a lock the other needs, and all are waiting indefinitely. Rust does not prevent deadlocks at compile time — they are a logical error, not a memory-safety issue. Follow these strategies to avoid them.

  1. Always acquire locks in the same order. If every thread locks A then B (never B then A), no cycle can form.

  2. Minimize lock scope. Hold locks for the shortest time possible. Drop the guard before doing I/O or calling external code.

  3. Prefer fine-grained locks. Lock individual fields rather than a giant struct — reduces contention and shrinks the window for deadlocks.

  4. Use try_lock() when possible. Mutex::try_lock() returns immediately with Err if the lock is taken — retry or bail out rather than block forever.

  5. Prefer message passing when the design allows it. Channels eliminate shared mutable state entirely, making deadlocks impossible for those code paths.

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

fn main() {
    let lock_a = Arc::new(Mutex::new(0u32));
    let lock_b = Arc::new(Mutex::new(0u32));

    // SAFE: both threads acquire lock_a then lock_b — same order
    let (la, lb) = (Arc::clone(&lock_a), Arc::clone(&lock_b));
    let t1 = thread::spawn(move || {
        let _a = la.lock().unwrap();
        let _b = lb.lock().unwrap();
        println!("thread 1 holds A then B");
    });

    let (la, lb) = (Arc::clone(&lock_a), Arc::clone(&lock_b));
    let t2 = thread::spawn(move || {
        let _a = la.lock().unwrap(); // same order — no deadlock
        let _b = lb.lock().unwrap();
        println!("thread 2 holds A then B");
    });

    t1.join().unwrap();
    t2.join().unwrap();
}
thread 1 holds A then B
thread 2 holds A then B
Warning
If thread 2 acquired lock_b first, a deadlock could occur: thread 1 holds A and waits for B while thread 2 holds B and waits for A. Consistent lock ordering is the simplest and most reliable solution.
Choosing the Right Primitive

Situation

Reach for

Simple counter or flag shared across threads

AtomicUsize / AtomicBool

One writer, many readers, structured data

Arc<RwLock<T>>

General mutable shared data

Arc<Mutex<T>>

Decoupled producer/consumer threads

std::sync::mpsc channel

High-throughput queues, work stealing

crossbeam crate

Success
Rust's shared-state concurrency is safe by design. The type system ensures that you cannot access an Arc<Mutex<T>> without locking, and the RAII guard guarantees the lock is always released. Follow the patterns here — consistent lock ordering, narrow lock scopes, and atomics for simple values — and you will write concurrent Rust that is both correct and fast.