19.7 Arc<T>: Thread-Safe Reference Counting

Rc<T> is unsuitable for multi-threaded environments because its reference count updates are not atomic (not protected against race conditions). When you need to share ownership of data across multiple threads, Rust provides Arc<T> (Atomically Reference Counted).

Arc<T> behaves very similarly to Rc<T> but uses atomic operations for incrementing and decrementing the reference count. These operations guarantee correctness even when performed concurrently by multiple threads, albeit with a higher performance cost than Rc’s non-atomic updates.

19.7.1 Arc<T> Basics

  • Provides shared ownership of heap-allocated data usable across threads.
  • Like Rc<T>, Arc::new(value) allocates the value T and its reference counts (strong and weak, both atomic) together on the heap.
  • Arc::clone(&arc_ptr) increments the atomic strong reference count and creates a new pointer to the same data. The cloned Arc can be moved (Send) to another thread.
  • Dropping an Arc<T> atomically decreases the strong count. The data T is dropped and memory deallocated when the strong count reaches zero (and the weak count also reaches zero, see Section 19.8).
  • Requires T to be Send + Sync to allow the Arc<T> itself to be sent between threads and shared immutably. If mutable access across threads is needed, T must be wrapped in a Mutex or RwLock (see below), and T itself inside the lock must be Send.
  • Like Rc<T>, Arc<T> only provides immutable access (&T) to the underlying data via dereferencing.

Example: Sharing immutable data across threads.

use std::sync::Arc;
use std::thread;

fn main() {
    // Data wrapped in Arc for thread-safe sharing
    let numbers = Arc::new(vec![10, 20, 30, 40, 50]); // Vec<i32> is Send + Sync
    let mut handles = vec![];

    // Arc::strong_count is useful for demonstration/debugging
    println!("Initial Arc strong count: {}", Arc::strong_count(&numbers)); // Output: 1

    // Spawn multiple threads, each cloning the Arc
    for i in 0..3 {
        let numbers_clone = Arc::clone(&numbers); // Clone Arc, increments atomic count
        let handle = thread::spawn(move || { // `move` takes ownership of numbers_clone
            // Access the shared data immutably from the thread
            println!("Thread {}: Element at index {}: {}", i, i, numbers_clone[i]);
            // numbers_clone dropped here, count decreases atomically
        });
        handles.push(handle);
    }

    // `numbers` still exists in the main thread
    // Count might fluctuate as threads start/finish cloning/dropping
    println!("Count after spawning threads (approx): {}", Arc::strong_count(&numbers));

    // Wait for all threads to complete
    for handle in handles {
        handle.join().unwrap();
    }

    // After all threads finish, only the original `numbers` Arc remains
    println!("Final Arc strong count: {}", Arc::strong_count(&numbers)); // Output: 1
    // `numbers` dropped here, count becomes 0, Vec is dropped, memory freed.
}

19.7.2 Combining Arc<T> with Mutexes/RwLocks for Shared Mutability

Since Arc<T> only grants immutable access, how do you mutate data shared across threads? You combine Arc<T> with a thread-safe interior mutability primitive, typically std::sync::Mutex<T> or std::sync::RwLock<T>.

  • Arc<Mutex<T>>: Allows multiple threads to share ownership (Arc) of a mutex (Mutex) which guards the actual data (T). To access T, a thread must first lock the mutex using the lock() method. This blocks until the lock is acquired, ensuring exclusive access. The lock returns a “lock guard” (e.g., MutexGuard). When the guard goes out of scope, the lock is automatically released.
  • Arc<RwLock<T>>: Similar, but allows multiple concurrent readers (read()) or one exclusive writer (write()). Better performance than Mutex if reads are much more frequent than writes, but potentially more complex regarding lock acquisition fairness or starvation.

Example: Shared counter using Arc<Mutex<T>>

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration; // Not needed for core logic

fn main() {
    // Shared counter: Arc for shared ownership, Mutex for exclusive access for mutation
    // 0u32 is Send because u32 is Send
    let counter = Arc::new(Mutex::new(0u32));
    let mut handles = vec![];

    for i in 0..5 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            // Lock the mutex to gain exclusive access.
            // .lock() returns Result<MutexGuard<..>, PoisonError<..>>
            // .unwrap() panics if the lock was "poisoned" (a thread panicked
            // while holding it).
            let mut num = counter_clone.lock().unwrap(); // `num` is a MutexGuard<u32>

            // Mutate the data safely (dereferences guard to &mut u32)
            *num += 1;
            println!("Thread {} incremented counter to {}", i, *num);

            // Mutex is automatically unlocked when `num` (the lock guard) goes
            // out of scope here.
        });
        handles.push(handle);
    }

    // Wait for all threads to finish
    for handle in handles {
        handle.join().unwrap();
    }

    // Lock the mutex in the main thread to read the final value
    println!("Final counter value: {}", *counter.lock().unwrap()); // Output: 5
}

While Mutex provides simple exclusive access, RwLock can be more efficient if the data is read much more often than it’s written, because it allows any number of readers to access the data concurrently. Only write access requires exclusivity.

Example: Shared data using Arc<RwLock<T>>

This example simulates multiple threads reading shared data concurrently, while fewer threads occasionally acquire exclusive access to modify it.

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

fn main() {
    // Data shared via Arc, protected by RwLock for read/write access
    // Let's store a simple value, initially 100
    let shared_data = Arc::new(RwLock::new(100));
    let mut handles = vec![];

    println!("Initial data: {}", *shared_data.read().unwrap());

    // --- Spawn multiple reader threads ---
    // These threads can access the data concurrently if no writer holds the lock.
    for i in 0..5 {
        let data_clone = Arc::clone(&shared_data);
        let handle = thread::spawn(move || {
            // Acquire read lock using .read().unwrap()
            // Blocks only if a writer currently holds the lock.
            let data_guard = data_clone.read().unwrap(); // Returns RwLockReadGuard
            println!("Reader {} sees data: {}", i, *data_guard);

            // Simulate some work while holding the read lock
            thread::sleep(Duration::from_millis(50 + (i * 10) as u64));
            // Stagger sleeps

            // Read lock is automatically released when data_guard goes out of scope
            println!("Reader {} finished.", i);
        });
        handles.push(handle);
    }

    // Allow readers to start
    thread::sleep(Duration::from_millis(10));

    // --- Spawn fewer writer threads ---
    // These threads need exclusive access to modify the data.
    for i in 0..2 {
        let data_clone = Arc::clone(&shared_data);
        let handle = thread::spawn(move || {
            // Acquire write lock using .write().unwrap()
            // Blocks if any readers OR another writer holds the lock.
            let mut data_guard = data_clone.write().unwrap();
            // Returns RwLockWriteGuard
            *data_guard += (i + 1) * 10; // Writer 0 adds 10, Writer 1 adds 20
            println!("Writer {} modified data to: {}", i, *data_guard);

            // Simulate some work while holding the write lock
            thread::sleep(Duration::from_millis(100));

            // Write lock is automatically released when data_guard goes out of scope
            println!("Writer {} finished.", i);
        });
        handles.push(handle);
    }

    // Wait for all threads to complete
    for handle in handles {
        handle.join().unwrap();
    }

    // Read the final value (acquires a read lock)
    println!("Final data: {}", *shared_data.read().unwrap());
    // Expected: 100 + 10 (from writer 0) + 20 (from writer 1) = 130
}

This example demonstrates:

  • Using Arc::clone to share the Arc<RwLock<T>>.
  • Acquiring a read lock with read(), allowing multiple threads to potentially hold it concurrently.
  • Acquiring an exclusive write lock with write().
  • The automatic release of locks when the guards (RwLockReadGuard, RwLockWriteGuard) go out of scope.

Arc<T> (often combined with Mutex or RwLock) is fundamental for managing shared state safely and effectively in concurrent Rust programs. It comes with the overhead of atomic operations for reference counting and the potential blocking overhead of acquiring locks.