Skip to content

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

Easy Primitives

Scoped threads: borrow, do not clone

Divide a vector into contiguous chunks and compute each chunk's maximum in a scoped thread. Return the maxima in chunk order.

pub fn chunk_maxes(data: Vec<i64>, workers: usize) -> Vec<i64>

Return an empty vector when workers == 0 or data is empty. Otherwise:

  1. Set the chunk size to data.len().div_ceil(workers).
  2. Use data.chunks(size) to get consecutive, nonempty slices.
  3. Inside one std::thread::scope, spawn a worker for each chunk.
  4. Have each worker borrow its slice and return its maximum.
  5. Collect the results in the same order as the input chunks.

For [3, 1, 4, 1, 5, 9, 2, 6] with three workers, the chunk size is three:

Chunk Maximum
[3, 1, 4] 4
[1, 5, 9] 9
[2, 6] 6

The output is [4, 9, 6], regardless of which thread finishes first. workers is an upper bound on the number of chunks, not a requirement to create that many threads. For two values and five workers, the chunk size is one and only two workers are needed.

Borrow the input storage rather than cloning chunks into new vectors or wrapping the input in Arc. Copying an i64 while finding the maximum, or copying a slice reference into a closure, is allowed. The restriction concerns duplicating the input buffer, not every use of the Copy trait. Output tests alone do not enforce the threading or borrowing strategy.

Why a scope allows local borrows

thread::spawn requires its closure and returned value to satisfy 'static as well as Send. The lifetime bound prevents them from depending on a borrow that could expire while the thread is still running. It does not mean owned values must live forever: a closure can own a Vec and drop it when it finishes. Moving ownership is one alternative to borrowing; copying is not always required.

Scoped threads have a tighter lifetime. Threads spawned through the scope are joined before thread::scope returns, so they may borrow data that remains valid for that scope:

use std::thread;

let data = vec![1, 2, 3];
thread::scope(|s| {
    s.spawn(|| assert_eq!(data.len(), 3));
});
assert_eq!(data, [1, 2, 3]);

The thread's closure must still be Send. Sharing &[i64] works because i64 supports shared access across threads. A scope does not make every type safe to share or remove the borrowing rules.

With s.spawn(move || ...), the closure takes its slice reference by value. It does not take ownership of the vector or clone its elements. The vector stays alive outside the scope while all workers read their slices.

Preserve chunk order when joining

First collect the handles into a vector in chunk order. Then join those handles in that order and collect their returned maxima. This starts all workers before waiting for their results. Spawning and immediately joining each worker would serialize the work instead.

Automatic joining ensures threads cannot outlive the scope, but it does not assemble their return values for you. Explicit join returns a result so you can retrieve each maximum and handle a worker panic. If a thread left for automatic joining panics, thread::scope itself panics.

Each chunk is nonempty, so chunk.iter().copied().max() returns Some. An expect can document that invariant. Negative values are valid; do not start a maximum at zero and accidentally reject an all-negative chunk.

Avoid overflow in the chunk-size formula

The starter computes:

let size = (data.len() + workers - 1) / workers;

The numerator can overflow. This can happen with a small vector and a very large workers value, not just a large allocation. Depending on overflow settings, the arithmetic can panic or wrap and produce an incorrect size.

Use data.len().div_ceil(workers). For these unsigned values, it computes the ceiling of the division without overflowing that intermediate sum. Still handle zero workers before dividing, and empty input before calling chunks: division by zero and chunks(0) panic.

Clippy's manual_div_ceil lint points out this pattern. Fix the calculation rather than suppressing the warning. Passing also requires compilation, correct outputs and Clippy with warnings treated as errors.

A scope does not allow overlapping mutable borrows

This example is rejected because both closures would borrow the same local mutably for overlapping lifetimes:

use std::thread;

let mut count = 0;
thread::scope(|s| {
    s.spawn(|| count += 1);
    s.spawn(|| count += 1);
});

For shared updates, choose appropriate synchronization or split the data into disjoint mutable regions. This exercise only reads shared slices and returns separate results, so no shared mutable accumulator is needed.

Scoped threads are useful when borrowed work must finish before returning. They are not a promise of better performance: spawning operating-system threads can cost more than scanning small chunks. Measure before choosing this strategy for a production workload.