Blog
Choosing a Rust Concurrency Pattern: When Ownership Isn't Enough
When threads need mutable state, Rust's ownership rules force a choice: lock-based safety, atomic lock-free, or raw pointers--here's how to decide.
Published by Tasadduq Burney
21 Dec 2025, 19:45 UTC
4 min23.4K views0

The problem with shared mutable state in Rust
You're building a concurrent pipeline in Rust and need to pass mutable data between threads. The language's ownership model memory-safety guarantees are excellent, but they also mean you can't simply clone or mutate a value from multiple threads without the compiler intervening. If you try to send a raw reference across a thread boundary, the borrow checker will stop you cold -- and rightly so, since data races at runtime can corrupt state and are notoriously hard to debug.
Useful takeaway: Rust gives you three pragmatic patterns for shared-state concurrency, each with compile-time guarantees, runtime costs, and ergonomic trade-offs. Picking the right one for your workload lets you keep the safety Rust is known for without unnecessary performance penalties.
1. the borrow checker as concurrency enforcer
Rust's ownership system enforces memory safety without a garbage collector using compile-time move, borrow and copy semantics. When a value is moved into a thread, the original binding is invalidated, preventing accidental shared mutable access. The borrow checker statically prevents data races in concurrent code by enforcing unique mutable reference ownership: at any point, either one mutable reference or any number of immutable references exist, and the compiler rejects code that violates this rule. This is why idiomatic Rust code rarely needs a mutex for simple data passing -- the type system does the work. Terminology refresher:move transfers ownership, borrow creates a temporary reference (&T or &mut T), and copy duplicates simple types that implement the Copy trait. Understanding these helps you read compiler errors as design guidance rather than nuisances.
2. three patterns for shared-state concurrency
When you need multiple owners or interior mutability, Rust's standard library offers patterns that balance safety and performance:Arc:Arc(atomic reference counting) gives multiple owners, andMutexenforces exclusive interior mutability. Safe, but the mutex can become a serialization point -- if many threads contend for the same lock, throughput drops.Arc:RwLockallows multiple readers or one writer, which can improve throughput when reads dominate writes.- atomic types (
std::sync::atomic): lock-free read-modify-write operations on types likeAtomicUsizeorAtomicPtr. Zero-overhead for simple counters or flags, but insufficient for complex invariants or multi-field updates without additional logic.
3. worked example: a thread-safe job counter
Suppose you want a shared counter that multiple threads increment. Here's a minimal, compile-safe example using atomic types:use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..8 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
c.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in handles {
h.join().unwrap();
}
let final_value = counter.load(Ordering::Relaxed);
println!("expected ~8000, got " + stringify!(final_value));
Where to run it: save as src/main.rs in a new Rust project (cargo new counter) and run cargo run. No special permissions beyond the Rust toolchain are required. To verify the absence of data races, compile with ThreadSanitizer enabled: RUSTFLAGS="-Z thread-sanitizer" cargo build --release and run the binary; TSan will report if any unsynchronized access slipped through. You can also run cargo deny to check for unsafe dependency patterns, though it focuses on advisory vulnerabilities rather than concurrency bugs.
Expected check: after running, the printed value should be close to 8000 (exact match depends on Ordering::Relaxed, which does not guarantee happens-before relationships for arbitrary code, but for fetch_add it does). If TSan passes with no warnings, the pattern is race-free for this workload.
Relevant risk: Ordering::Relaxed is the cheapest fence, but it only ensures the operation is atomic; it does not establish happens-before relationships. If your logic requires ordering guarantees, use Ordering::SeqCst or introduce a Mutex or RwLock.
trade-off and limitation
Ownership rules sometimes require explicit lifetimes in nested types, increasing verbosity for newcomers. Raw pointers (*const T or *mut T) bypass the borrow checker entirely, introducing potential unsafety if misused in concurrent contexts -- they're useful for FFI or highly optimized inner loops, but invariants must be upheld by the programmer. Additionally, mutex-based patterns serialize access, and atomic patterns don't compose for multi-field state without careful design. Choose the pattern that matches your read/write frequency: atomic for simple counters, RwLock for read-heavy workloads, and Mutex for general-purpose interior mutability where simplicity trumps micro-optimization.
actionable closing
If you're starting a new concurrent component in Rust, run through this quick checklist:
- Can the data be owned exclusively and passed across threads via
move? Start there -- it's zero-cost and race-free. - If shared ownership is needed, wrap in
Arcand ask: is the critical section short and contention-low? UseAtomicUsizeor similar. - If you need interior mutability with complex invariants, start with
Mutex, then profile. Switch toRwLockif reads dominate. - Run
cargo test --threads=1and, for release builds,RUSTFLAGS="-Z thread-sanitizer" cargo runto catch data races before they reach production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.