Skip to Content
Course content

42: Shared State with Mutex and Arc

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, we've talked about moving data into threads or using channels to pass messages. That works great when data has a clear owner. But in the real world, you often hit a wall where multiple threads need to read and write to the same piece of state simultaneously. For today, let's build a simple Concurrent Request Tracker—something that simulates a web server counting how many requests it's handling across different threads.

The naive approach and the ownership wall

My first instinct is usually to just create a counter and pass it to a few threads. I want a simple integer that gets incremented every time a "request" completes. Here is where I'd start:

use std::thread;

fn main() {
    let mut count = 0;
    let mut handles = vec![];

    for i in 0..10 {
        let handle = thread::spawn(move || {
            count += 1;
            println!("Thread {} incremented count to {}", i, count);
        });
        handles.push(handle);
    }

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

If you try to compile this, the compiler will scream at you. Why? Because of that move keyword. The first thread takes ownership of count, and the second thread finds that count is already gone. Even if I tried to use a reference, the compiler would complain that it can't guarantee the reference lives long enough. We need a way for multiple threads to "own" the counter at the same time.

The mistake: Falling for the Rc trap

Since I know Rc (Reference Counted) pointers allow multiple owners, I might try to wrap the counter in an Rc. I've done this plenty of times in single-threaded UI code, so it's a natural reflex.

use std::rc::Rc;
use std::thread;

fn main() {
    let count = Rc::new(0);
    // ... imagine trying to spawn threads here ...
}

Wait. The compiler catches me immediately. It tells me that Rc<i32> cannot be sent between threads safely. This is because Rc uses a non-atomic reference counter. If two threads tried to increment the reference count at the exact same time, they could create a data race, leading to memory leaks or crashes. This is a classic Rust "gotcha"—Rc is for single-threaded sharing; for multi-threaded sharing, we need Arc (Atomic Reference Counted).

Combining Arc and Mutex for shared mutability

Switching to Arc solves the ownership problem, but it introduces a new one: Arc only gives us immutable access to the data it holds. You can't just change a value inside an Arc. To get around this, we use Interior Mutability. We wrap the data in a Mutex (Mutual Exclusion) and then wrap that Mutex in an Arc.

Think of the Arc as the "handle" that lets multiple threads hold onto the box, and the Mutex as the "lock" on the box that ensures only one thread can reach inside and change the value at a time.

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

fn main() {
    // Arc allows multiple owners, Mutex allows safe mutation
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for i in 0..10 {
        // We clone the Arc, not the data itself. 
        // This just increments the atomic reference count.
        let counter_clone = Arc::clone(&counter);
        
        let handle = thread::spawn(move || {
            // lock() blocks the thread until the mutex is available.
            // It returns a Result, so we unwrap it.
            let mut num = counter_clone.lock().unwrap();
            *num += 1;
            println!("Thread {} incremented count to {}", i, *num);
            // The lock is automatically released when 'num' goes out of scope.
        });
        handles.push(handle);
    }

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

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

A few notes on the mechanics

  • The Clone: Notice I used Arc::clone(&counter) instead of counter.clone(). They do the same thing, but using the explicit Arc::clone makes it clear to anyone reading your code that you are cloning the pointer, not the actual data inside.
  • The Guard: When you call .lock().unwrap(), you aren't getting the integer back. You're getting a MutexGuard. This is a smart pointer that implements DerefMut, which is why we use *num += 1 to reach the actual value.
  • Poisoning: You might wonder why we .unwrap() the lock. If a thread panics while holding a lock, the Mutex becomes "poisoned." Most of the time, if a thread panicked while holding your state, the state is corrupted anyway, so unwrapping and crashing the whole program is often the safest bet.



📋 Practical Task

Build a Thread-Safe Shared Configuration Cache

In a real application, you often have a configuration object that needs to be updated occasionally (e.g., updating a log level or a feature flag) while being read by many different worker threads.

Your Task: Create a program that manages a shared Config struct.

  1. Define a struct Config with a field log_level: String.
  2. Wrap this Config in an Arc<Mutex<...>>.
  3. Spawn 5 "worker" threads that periodically print the current log_level.
  4. Spawn 1 "admin" thread that changes the log_level from "INFO" to "DEBUG" after a short delay (use std::thread::sleep).
  5. Ensure the program waits for all threads to complete before exiting.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.