Skip to content

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

Medium Framework

Safe parallel mutation with chunks_mut

Scale the rows of a flat grid in place, in parallel.

pub fn parallel_scale_rows(
    grid: Vec<i64>,
    width: usize,
    factors: Vec<i64>,
    workers: usize,
) -> Vec<i64>

grid is rows * width elements in row-major order and factors has one entry per row. Multiply every cell of row r by factors[r], distribute bands of rows across workers scoped threads, and return the grid.

Empty grid or width == 0 returns the grid unchanged. Treat workers == 0 as 1. The tests check workers = 1, 3, 16 against the same expected grid.

Constraints, and they are the point: no unsafe, no Arc, no Mutex, no atomics, no copying the grid. Threads mutate the same allocation directly and the borrow checker proves it is sound.

Contrast this with the raw-pointer version

Earlier in this track you wrote scatter_unsafe: a SendPtr newtype, an unsafe impl Send, a // SAFETY: comment, and a promise that your index ranges were disjoint — a promise no tool could check, where a mistake was undefined behaviour rather than a wrong answer.

Here you make the same guarantee, and the compiler checks it. That is not a small difference: it is the difference between a code review that has to reason about your arithmetic and one that does not.

How a safe API can hand out several &mut to one buffer

It looks like it should be impossible. Aliasing-xor-mutation says one &mut at a time, and here are several, live at once, into the same Vec.

The resolution: they point at disjoint parts. &mut [i64] is not “a mutable reference to the vector”, it is exclusive access to these elements. Two exclusive accesses to non-overlapping regions do not alias, so nothing is violated.

What the borrow checker cannot do is prove disjointness from arithmetic. Given grid[i] and grid[j] it has no idea whether i != j. So std provides splitting functions that are structurally disjoint, and each does the unsafe reasoning once, internally, where it can be audited:

slice.split_at_mut(mid)     // -> (&mut [T], &mut [T])
slice.chunks_mut(n)         // -> iterator of non-overlapping &mut [T]
slice.iter_mut()            // -> one &mut T per element, all distinct

chunks_mut(width) gives you exactly one &mut [i64] per row, and by construction no two overlap. This is precisely how rayon‘s par_chunks_mut is sound underneath. You are writing the real thing, just with a manual thread pool.

The starter’s error

The starter indexes the grid from several closures:

error[E0499]: cannot borrow `*grid_ref` as mutable more than once at a time
   |             s.spawn(move || {
   |                     ^^^^^^^ second mutable borrow occurs here
   |                         grid_ref[r * width + c] *= f;
   |                         -------- second borrow occurs due to use in closure

The strided access pattern is disjoint — worker w touches rows w, w + workers, ... and no two workers share a row. The borrow checker cannot see it, and it is right not to guess. Rejecting a correct program is the price of never accepting a broken one.

Note the error is E0499, the plain “two mutable borrows” error you met long before threads existed. thread::scope removed the 'static requirement; it did not and could not remove aliasing rules.

The obstacle you have to get past

chunks_mut returns an iterator, and you cannot index an iterator or hand slice k of it to thread k directly. That is deliberate — it is what stops you accidentally holding two.

The way through, in one line: collect the row slices first, then chunk that.

let mut rows: Vec<&mut [i64]> = grid.chunks_mut(width).collect();
for band in rows.chunks_mut(band_size) {
    s.spawn(move || { /* band: &mut [&mut [i64]] */ });
}

Vec<&mut [i64]> is a vector of exclusive row handles, and chunks_mut on it hands out disjoint groups of rows. Two applications of the same idea. Track which row index each band starts at, because the band no longer knows.

Once you have written it, the reason rayon exists is obvious — and so is the fact that it is a convenience over this, not a different mechanism.

Loading visualization…