Skip to content

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

Easy Primitives

Balanced ranges: the arithmetic before the threads

Divide the indices in 0..len into balanced, consecutive ranges. Return their (start, end) pairs; this function does not create threads.

pub fn balanced_ranges(len: usize, workers: usize) -> Vec<(usize, usize)>

Keeping partitioning separate from thread creation makes it easier to check that each index is assigned exactly once.

Required output

If len == 0 or workers == 0, return an empty vector. In particular, zero workers means no ranges, even when len is positive.

Otherwise, return exactly min(len, workers) nonempty ranges in order:

  • Ranges are half-open: (2, 5) contains indices 2, 3, and 4.
  • The first starts at 0, the last ends at len, and adjacent boundaries match. There are no gaps or overlaps.
  • Sizes differ by at most one. Put the larger ranges first.
(10, 3) -> [(0, 4), (4, 7), (7, 10)]
(3, 5)  -> [(0, 1), (1, 2), (2, 3)]
(7, 7)  -> [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7)]
(0, 4)  -> []
(5, 0)  -> []

Inputs may have large endpoints, but the number of output ranges is small enough to fit in memory. Construct the boundaries without iterating over every index in 0..len.

Spread the remainder over the first ranges

After handling zero inputs, calculate:

base  = len / workers
extra = len % workers

Give the first extra ranges base + 1 elements, then give the remaining ranges base elements. Stop before producing a zero-size range.

With len = 10 and workers = 3, base is 3 and extra is 1, so the sizes are 4, 3, 3. Keep a running boundary: start at 0, add the first size to reach 4, then reach 7, then 10.

If workers > len, base is zero and extra equals len. The first len sizes are one; all later sizes would be zero and must be omitted.

The arithmetic identity len = workers * base + extra explains why the sizes add up to len. You do not need to evaluate a larger intermediate expression such as workers * (base + 1).

Why a fixed chunk size gives different results

A fixed size of len.div_ceil(workers) is useful for other partitioning policies, but it does not always satisfy this exercise's balance rule.

For (10, 3), fixed-size chunking produces sizes 4, 4, 2; the required sizes are 4, 3, 3. For (17, 8), chunks of size three produce six chunks with sizes 3, 3, 3, 3, 3, 2. They do not produce two additional empty chunks. Balanced partitioning instead produces eight ranges with sizes 3, 2, 2, 2, 2, 2, 2, 2.

slice::chunks takes a chunk size, not a requested number of workers. It yields a shorter final chunk when needed. Neither strategy guarantees equal execution time: equal numbers of elements can still require different amounts of work.

Repair both the lint and the algorithm

The starter calculates (len + workers - 1) / workers, which Clippy flags as manual_div_ceil. That addition can overflow before division. With overflow checks enabled it can panic; with checks disabled it can wrap and produce an incorrect size. A size of zero can even leave its loop unable to advance.

usize::div_ceil avoids that addition overflow for a nonzero divisor. It still panics for division by zero. Replacing the expression with div_ceil fixes that calculation, but does not fix the starter's uneven fixed-size ranges.

Use the quotient-and-remainder plan instead. For each range, compute only its actual size and add it to the running boundary. These boundaries never exceed len, so the additions fit in usize, including when the final endpoint is usize::MAX.

usize is unsigned and its width depends on the target. Do not assume it is always 64 bits, or that using only addition prevents overflow. The relevant guarantee here is that every intermediate boundary is bounded by len.