Concurrency in Rust: Sharing State
In Rust, shared-state concurrency allows multiple threads to access and modify the same piece of data in memory. While message passing involves transferring ownership of data between threads one-way, shared state relies on synchronization primitives that coordinate concurrent access safely.
1. Mental Model: Message Passing vs. Shared State
Concurrency models in Rust offer distinct ways for threads to communicate and manage data lifecycle.
2. Understanding Mutexes (Mutex<T>)
Mutex is an abbreviation for mutual exclusion. A mutex ensures that only one thread can access a specific piece of data at any given time.
How Mutexes Work
- Locking System: When a thread wants access to data behind a mutex, it must signal its intent and acquire the mutex's lock.
- Exclusive Access: Once a lock is acquired, no other thread can read or write to that data until the lock is released.
- The Two Rules: Traditionally, mutexes are notoriously difficult to manage because programmers must remember to:
- Acquire the lock before accessing data.
- Release the lock when finished so other threads can access it.
- Rust's Guarantee: Rust's type system and ownership model make it impossible to get locking and unlocking wrong.
3. Creating and Using a Mutex
To use a mutex in Rust, we import it from the standard library and wrap our underlying data inside it.
use std::sync::Mutex;
fn main() {
// Create a new mutex holding the integer 5
let m = Mutex::new(5);
{
// Acquire the lock; unwrap handles the Result in case another thread panicked while holding it
let mut num = m.lock().unwrap();
// Use the dereference operator to mutate the inner data
*num = 6;
} // MutexGuard goes out of scope here, automatically releasing the lock
println!("m = {:?}", m);
}
Key Concepts in the Code:
Mutex::new(5): Creates an associated mutex holding an integer.mis not an integer; it is aMutex<i32>. You cannot manipulate the integer directly without acquiring a lock.m.lock().unwrap(): Blocks the current thread until the lock is available. It returns aResultbecause locking fails if another thread holding the lock panicked.MutexGuard: The smart pointer returned by.lock(). ItsDereftrait points to the inner data, and itsDroptrait automatically releases the lock when the guard goes out of scope.
4. Spawning Multiple Threads with Shared Data
Let's scale our example by spawning 10 threads, each incrementing a shared counter stored in a mutex.
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
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());
}
Note: If you run the code above directly, the compiler will emit an error stating that
countercannot be moved into multiple threads across loop iterations because ownership is transferred on the first iteration.
5. Multiple Ownership with Reference Counting (Rc<T>)
To allow multiple threads to own our Mutex, we first reach for the standard reference counting smart pointer, Rc<T>, as introduced in Chapter 15.
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
// ... join threads and print
}
The Compilation Barrier: Rc<T> is Not Thread-Safe
When compiling the code above, Rust will reject it with an error:
cannot be sent between threads safely
The standard Rc<T> reference-counting pointer is not designed to be thread-safe because its reference-counting operations are not atomic. Passing Rc across threads risks data races on the reference count itself.
6. Thread-Safe Atomic Reference Counting (Arc<T>)
To solve the thread-safety problem while maintaining multiple ownership, Rust provides Arc<T> (Atomic Reference Counting).
What are Atomics?
Atomics are concurrency primitives that behave like primitive types except that they can be safely shared across threads. They manage reference counts using atomic CPU instructions.
Refactored Code with Arc<T>
Replacing Rc with Arc resolves all thread-safety errors:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Wrap counter in an Atomic Reference Counter
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 || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Running this program successfully outputs:
Result: 10
7. Interior Mutability & Concurrency Parallels
An interesting aspect of our final program is that counter is declared as an immutable variable (let counter = ...), yet we are successfully mutating the integer value inside it.
- Interior Mutability: Just like
RefCell<T>allows mutation inside anRc<T>in single-threaded contexts,Mutex<T>allows mutation of data behind immutable references in multi-threaded contexts. - Trade-offs:
RefCell<T>carries the risk of creating circular dependencies (and runtime panic on borrow rules).Mutex<T>carries the risk of creating deadlocks when multiple locks are acquired in conflicting orders.
Summary and Key Takeaways
- Shared-State Concurrency: Allows multiple threads to access and modify the same memory location concurrently, contrasted with message-passing's one-way data flow.
- Mutex (
Mutex<T>): Enforces mutual exclusion, ensuring only one thread accesses data at a time through an acquisition and release locking mechanism. - Automatic Lock Release: Rust utilizes
MutexGuard(a smart pointer implementing theDroptrait) to automatically release locks when they go out of scope, eliminating manual unlocking errors. - The
.lock()Method: Blocks the current thread until the lock is acquired and returns aResulthandling potential thread panics. - The
Rc<T>Limitation: Standard reference counting pointers (Rc<T>) cannot be sent between threads because their reference-counting operations are not thread-safe. - Atomic Reference Counting (
Arc<T>): The thread-safe equivalent ofRc<T>, enabling multiple ownership across thread boundaries using atomic operations. - Interior Mutability Pattern:
Mutex<T>combined withArc<T>enables mutable access to data across multiple threads even when the container reference is immutable.