0x55aa
← Back to Blog

Rust Arc<Mutex<T>>: Sharing State Across Threads Without Global Variable Hell πŸ¦€πŸ”’

β€’9 min read

Rust Arc<Mutex<T>>: Sharing State Across Threads Without Global Variable Hell πŸ¦€πŸ”’

Hot take: Every bug I've ever had with shared state in PHP could have been caught at compile time. Every single one. I just needed to be using Rust.

Coming from 7 years of Laravel and Node.js, my relationship with shared state was... casual. PHP is mostly single-process per request. Node.js is single-threaded. You don't think about two pieces of code touching the same data simultaneously β€” because by design, they mostly can't. You get lazy. I got lazy.

Then I picked up Rust for my RF/SDR hobby projects, where I genuinely needed two threads running simultaneously β€” one reading raw IQ samples from the radio hardware, one processing and displaying them. And Rust looked at my first attempt to share a buffer between those threads and said, politely, no.

That's when I met Arc<Mutex<T>>.

Why Web Devs Don't Think About Thread Safety πŸ€”

In Laravel, a request comes in, PHP spins up, does its thing, and dies. Isolated. The next request is a fresh PHP process. There's no "concurrent modification" problem because each request doesn't share memory with another request. You share state through a database or Redis β€” an external system that handles its own locking. PHP handles the hard part invisibly.

Node.js is single-threaded. One event loop. One thing running at a time. You get concurrency via the event loop (non-blocking I/O), but you never have two JavaScript statements executing simultaneously on the same object. Race conditions can still happen, but they're subtle and rare β€” and usually someone else's problem.

So you develop a mental model that shared state is... fine? Just use a variable. It's fine.

It is not fine when you have actual threads. Two threads reading and writing the same memory simultaneously is a data race β€” undefined behavior in most languages, a crash or silent corruption, the kind of bug that only reproduces on Tuesdays in production. 🎲

Rust refuses to let you write this class of bug. Full stop.

The Problem: Sharing Data Between Threads ⚑

Let me show you what I was trying to do. I have a radio hardware thread that fills a buffer with signal samples, and a processing thread that reads and analyzes that buffer:

use std::thread;

fn main() {
    let samples: Vec<f32> = Vec::new();

    // Thread 1: writes samples from radio
    thread::spawn(|| {
        samples.push(read_from_radio()); // ❌ ERROR
    });

    // Thread 2: reads and processes samples
    thread::spawn(|| {
        let avg = samples.iter().sum::<f32>(); // ❌ ERROR
    });
}

The compiler refuses to compile this. Not with a vague runtime crash β€” with a clear compile error: "closure may outlive the current function, but it borrows samples" and "cannot borrow samples as mutable because it is also borrowed as immutable".

Rust saw the data race before I ran the program. In any other language, this compiles fine and you discover the bug six months later when a customer reports corrupted data.

Meet Arc<Mutex<T>>: The Dynamic Duo πŸ¦Έβ€β™‚οΈπŸ¦Έβ€β™€οΈ

Fixing this requires two separate tools that work together:

Mutex<T> β€” Mutual exclusion. Wraps your data so only one thread can access it at a time. To read or write, you must lock the mutex. While locked, all other threads wait. This eliminates data races.

Arc<T> β€” Atomic Reference Counted. Like Rc<T> (reference counting), but safe to use across threads. It lets multiple threads own the same piece of data by counting references atomically β€” so the data lives as long as anyone holds a reference to it.

Together, Arc<Mutex<T>> means: "multiple threads can own this, and whoever wants to read/write must take a lock first."

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

fn main() {
    // Wrap the data in Arc<Mutex<...>>
    let samples = Arc::new(Mutex::new(Vec::<f32>::new()));

    // Clone the Arc for thread 1 (increments reference count)
    let samples_writer = Arc::clone(&samples);
    let writer = thread::spawn(move || {
        let mut data = samples_writer.lock().unwrap(); // acquire lock
        data.push(42.0); // safe to write
        // lock automatically released when `data` drops
    });

    // Clone the Arc for thread 2
    let samples_reader = Arc::clone(&samples);
    let reader = thread::spawn(move || {
        let data = samples_reader.lock().unwrap(); // acquire lock
        let avg: f32 = data.iter().sum::<f32>() / data.len() as f32;
        println!("Average: {}", avg);
    });

    writer.join().unwrap();
    reader.join().unwrap();
}

This compiles. This is safe. The compiler is happy. And β€” crucially β€” the compiler proved it's safe, not just hoped.

What .lock().unwrap() Actually Means πŸ”

The .lock() call blocks the current thread until the mutex is available. It returns LockResult<MutexGuard<T>> β€” and you unwrap it to get the MutexGuard.

The MutexGuard is Rust's clever way of implementing RAII (Resource Acquisition Is Initialization). When the guard goes out of scope β€” when it drops β€” it automatically releases the lock. You cannot forget to unlock. The language makes it structurally impossible.

In PHP, you'd have to manually call sem_release() or use a lock file and remember to clean it up. Forget it in an error path? The lock never releases and your application deadlocks at 2 AM. In Rust, the lock releases automatically. Even if your code panics.

{
    let mut data = shared.lock().unwrap();
    data.push(sample);
    // lock released HERE automatically, no matter what
}
// <-- by this point, other threads can access shared again

For My SDR Project: A Live Signal Buffer πŸ“‘

Here's a simplified version of what I actually built β€” a radio reading thread and a display thread sharing a rolling buffer of signal samples:

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

struct SignalBuffer {
    samples: Vec<f32>,
    max_size: usize,
}

impl SignalBuffer {
    fn push(&mut self, sample: f32) {
        self.samples.push(sample);
        if self.samples.len() > self.max_size {
            self.samples.remove(0);
        }
    }

    fn average(&self) -> f32 {
        if self.samples.is_empty() {
            return 0.0;
        }
        self.samples.iter().sum::<f32>() / self.samples.len() as f32
    }
}

fn main() {
    let buffer = Arc::new(Mutex::new(SignalBuffer {
        samples: Vec::new(),
        max_size: 1000,
    }));

    // Radio reader thread
    let buf_writer = Arc::clone(&buffer);
    thread::spawn(move || loop {
        let sample = read_from_hardware(); // pretend this exists
        buf_writer.lock().unwrap().push(sample);
        thread::sleep(Duration::from_micros(100));
    });

    // Display thread
    let buf_reader = Arc::clone(&buffer);
    loop {
        let avg = buf_reader.lock().unwrap().average();
        println!("Current average signal: {:.2} dBm", avg);
        thread::sleep(Duration::from_millis(500));
    }
}

This is genuinely running in my hobby projects. The compiler verified that neither thread can corrupt the buffer. I've never had a race condition in this code β€” not because I'm careful, but because the language makes it mechanically impossible. 🎯

The PHP Equivalent Would Be... πŸ˜…

Imagine trying to do this in PHP. You'd reach for shared memory extensions (shmop_*), semaphores (sem_get, sem_acquire, sem_release), or just punt to Redis. None of it is checked at compile time. All of it can go wrong silently.

In Node.js, you'd probably use Worker Threads and SharedArrayBuffer, which is... possible, but requires careful manual management of locks via Atomics. Also not checked at compile time.

What excited me about Rust's approach: The type system encodes the threading rules. Arc<Mutex<T>> isn't just a convention or a library pattern β€” it's the only way to share mutable state across threads. The compiler won't let you share a plain T or a Rc<T> across thread boundaries. The rules are enforced, not suggested.

The Deadlock Caveat ⚠️

Rust prevents data races. It does not prevent deadlocks.

A deadlock is when Thread A holds Lock 1 and waits for Lock 2, while Thread B holds Lock 2 and waits for Lock 1. They both wait forever. Rust can't catch this at compile time β€” it's a runtime behavior problem.

The usual rules apply:

  • Keep critical sections (time spent holding a lock) as short as possible
  • Always acquire multiple locks in the same order across all threads
  • Consider using try_lock() instead of lock() if you can't afford to wait
// try_lock() returns immediately instead of blocking
if let Ok(mut data) = shared.try_lock() {
    data.push(sample);
} else {
    // couldn't get lock right now, handle it
}

Rust protects you from the compiler-checkable bugs. The architectural bugs still require your brain.

When You Need More Than One Reader πŸ“–

Mutex<T> gives exclusive access β€” one thread at a time, period. If you have many readers and occasional writers, you're potentially bottlenecking unnecessarily. That's where RwLock<T> comes in:

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

let data = Arc::new(RwLock::new(vec![1.0f32, 2.0, 3.0]));

// Multiple readers simultaneously β€” fine!
let r1 = Arc::clone(&data);
thread::spawn(move || {
    let d = r1.read().unwrap(); // read lock β€” shared
    println!("Reader 1: {:?}", *d);
});

// Writers get exclusive access
let w = Arc::clone(&data);
thread::spawn(move || {
    let mut d = w.write().unwrap(); // write lock β€” exclusive
    d.push(4.0);
});

RwLock allows many concurrent readers or one exclusive writer. For my signal processing: many display threads can read the buffer simultaneously; only the radio thread writes. RwLock is a better fit there than Mutex.

TL;DR 🏁

After 7 years of treating shared state like a casual fling, Rust forced me into a committed relationship with it β€” and honestly, the relationship is much healthier.

The key points:

  • Arc<T> β€” lets multiple threads share ownership of the same data (atomic reference counting)
  • Mutex<T> β€” ensures only one thread accesses the data at a time (mutual exclusion)
  • Arc<Mutex<T>> β€” the combination you need for shared mutable state across threads
  • .lock() blocks until the mutex is available; the returned guard auto-releases when it drops
  • RwLock<T> β€” use when you have many readers and rare writers (faster than Mutex for read-heavy workloads)
  • Rust prevents data races at compile time; deadlocks are still your problem

What excited me most: The PHP and Node.js world hides thread safety by avoiding threads. Rust hides nothing. It makes the rules visible in the type system β€” Arc<Mutex<T>> is literally a declaration in your code that says "this is shared, mutable, and thread-safe." It's honest in a way that implicit safety-by-avoidance is not.

For my SDR work, where milliseconds matter and getting the radio thread and processing thread right is critical β€” this compile-time guarantee is the difference between trustworthy software and "it works on my machine."

The compiler is strict. The code is correct. That's the deal. πŸ¦€πŸ”’


Want to experiment? Start with cargo new thread-demo, then try sharing a Vec<f32> between two threads without Arc<Mutex<T>> β€” watch the compiler error, read it carefully, and then fix it. The error message tells you exactly what to do.

Building concurrent systems in Rust? Find me on LinkedIn β€” always happy to compare notes with people escaping the single-threaded comfort zone.

See the SDR projects: GitHub β€” including signal processing tools built with real Arc<Mutex<T>> in the wild.

Now go wrap your shared state properly. The borrow checker will make sure you did. πŸ¦€πŸ”’βœ¨