Skip to content

← Fearless Concurrency: Threads, Channels, Shared State step 10 of 24

Easy Primitives

Share a mutable vector with Arc and Mutex

Fix the starter so each worker appends its chunk's sum to one shared vector. Join every worker, then return the sums in ascending order. Keep duplicate sums.

pub fn collect_into_shared(values: Vec<i64>, workers: usize) -> Vec<i64>

Use these chunking rules:

  1. Return an empty vector immediately if values is empty.
  2. Treat workers == 0 as 1.
  3. Set the chunk size to values.len().div_ceil(workers) using that adjusted worker count.
  4. Spawn one worker for each chunk from values.chunks(size).

There may be fewer chunks than the requested worker count. For 1..=10 and three workers, the chunks are [1, 2, 3, 4], [5, 6, 7, 8], and [9, 10]. Their sums are 10, 26, and 19, so return [10, 19, 26].

Inputs are small enough to create one thread per chunk. Each chunk's sum and intermediate additions fit in i64; overflow handling is outside this exercise.

Why the starter cannot push

The starter shares an Arc<Vec<i64>> between workers. At sink.push(sum), the compiler reports E0596: it cannot borrow the data through the Arc as mutable.

Ordinary dereferencing of Arc<T> gives shared access to T; Arc does not implement DerefMut. But Vec::push requires exclusive access through &mut Vec<_>. An atomic reference count keeps track of ownership; it does not coordinate writes to the vector.

Use Arc<Mutex<Vec<i64>>> for this shared result:

  • Arc lets the main thread and workers own handles to the same mutex.
  • Mutex::lock gives one caller at a time a guard providing mutable access to the vector.
  • Dropping the guard releases the lock.

Compute the sum before locking. Only the append needs access to the shared vector. This keeps the locked section short and lets other workers compute independently.

Here is a complete example of the same access pattern with one worker:

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

fn main() {
    let shared = Arc::new(Mutex::new(Vec::new()));
    let sink = Arc::clone(&shared);
    let worker = thread::spawn(move || {
        let sum: i64 = [2, 3].iter().sum();
        sink.lock().expect("mutex poisoned").push(sum);
    });
    worker.join().expect("worker thread panicked");
    let result = std::mem::take(&mut *shared.lock().expect("mutex poisoned"));
    assert_eq!(result, [5]);
}

The expect calls propagate failure as a panic. A poisoned mutex can indicate that a panic interrupted protected work; this exercise does not attempt recovery. Do not hold the result lock while joining workers that need it, or they cannot finish their appends.

Other ways to mutate through Arc

Arc is not a blanket ban on mutation. Arc::get_mut can return mutable access when no other strong or weak pointers refer to its allocation. For cloneable data, Arc::make_mut can provide copy-on-write access:

use std::sync::Arc;

fn main() {
    let mut data = Arc::new(vec![1]);
    Arc::get_mut(&mut data).unwrap().push(2);
    let snapshot = Arc::clone(&data);
    assert!(Arc::get_mut(&mut data).is_none());
    Arc::make_mut(&mut data).push(3);
    assert_eq!(*data, [1, 2, 3]);
    assert_eq!(*snapshot, [1, 2]);
}

Copy-on-write is not the operation this exercise needs: every worker must append to the same result vector, rather than to separate copies. A mutex supplies that coordinated access. Wrapping arbitrary data in Arc also does not automatically make the data safe to use across threads; the required Send and Sync bounds still apply.

The order of the types matters

Arc<Mutex<Vec<_>>> shares a lock protecting the vector. Mutex<Arc<Vec<_>>> instead protects an Arc handle. The latter is useful when you want to replace a shared snapshot while older clones continue reading the previous value. It is a valid type and can be shared, for example through a reference in scoped threads.

It does not directly solve this exercise's concurrent appends. Locking that outer mutex does not give mutable access to a vector still shared by other Arc handles. Choose the nesting based on what must change.

Wait, extract, then sort

Workers can append in different orders on different runs. Sorting provides the requested result order while preserving repeated sums. These results form a list with possible duplicates, not a set.

For tasks that need input order instead, keep each result's chunk index and reassemble by that index, or collect thread results in spawn order. Sorting by value would lose that association.

After joining all workers, you can lock and clone the result, or use std::mem::take(&mut *guard) to move it out and leave an empty vector behind. The reference uses take to avoid copying the elements.

Another option is Arc::into_inner(shared) followed by Mutex::into_inner. In this program, joining the workers leaves the main thread's handle as the only strong handle, so the first call can return the mutex. If an extra strong clone remains, it returns None. The mutex can then be consumed without acquiring its lock, though poison handling still applies. Consuming an Arc is not a promise of zero synchronization: its reference-count operations are atomic.

Normally the contained value is dropped when the last strong handle is dropped; remaining weak pointers keep the backing allocation alive, not the value. Here, extracting the value transfers ownership to the caller instead.

The visualization below illustrates contention for one lock:

Loading visualization…

Further reading: Arc ownership and mutation APIs and Mutex guards and poisoning.