Step 4 of 7
Scoped threads: borrowing local data
Copying chunks with to_vec() just to satisfy 'static is wasteful. Borrowing would be safe if you could promise that every thread ends before the borrowed data is dropped. thread::scope turns that promise into something the compiler can check:
use std::thread;
fn main() {
let readings: Vec<u32> = (1..=12).collect();
let total = thread::scope(|s| {
let mut handles = Vec::new();
for chunk in readings.chunks(4) {
handles.push(s.spawn(|| chunk.iter().sum::<u32>()));
}
handles.into_iter().map(|h| h.join().unwrap()).sum::<u32>()
});
println!("total: {total}");
println!("main still owns all {} readings", readings.len());
}
total: 78
main still owns all 12 readings
How it works:
thread::scope(|s| { ... })calls your closure with a scopes. Threads started withs.spawn(...)are scoped threads.- Before
thread::scopereturns, it joins every thread spawned in the scope that you didn't join yourself. So no scoped thread can outlive the scope, and the compiler lets them borrow anything that lives longer than the scope, likereadings. s.spawnreturns aScopedJoinHandle, which youjoinexactly like a normal handle.- Whatever the closure returns,
thread::scopereturns: here the total.
No to_vec(), no move, no copies: each thread reads its chunk straight out of readings.
Mutable borrows work too
The borrowing rules still apply across threads: one &mut at a time per piece of data. chunks_mut cuts a slice into pieces that don't overlap, so each thread can get its own &mut piece:
use std::thread;
fn main() {
let mut prices = vec![100, 250, 80, 40, 60, 500];
thread::scope(|s| {
for chunk in prices.chunks_mut(2) {
s.spawn(|| {
for p in chunk {
*p = *p * 90 / 100;
}
});
}
});
println!("{prices:?}");
}
[90, 225, 72, 36, 54, 450]
This time we don't keep the handles: the scope joins all the threads for us before println! runs.
Lazy iterators and joining
A tempting one-liner is data.chunks(n).map(|c| s.spawn(...)).map(|h| h.join().unwrap()).sum(). Iterators are lazy (module 27): each item goes through both maps before the next chunk is touched, so every thread is joined right after it starts, and they run one at a time. Collect the handles into a Vec first, then join.
scope or spawn?
thread::scope: the threads do a piece of work inside one function and are done before it returns. Most "split this job over the cores" code looks like this.thread::spawnwithmove: the thread must outlive the function that started it, like a background worker that runs for the whole program.
Common mistakes
chunks(0)panics. A chunk size fromdata.len() / workersis 0 when there are fewer items than workers.div_ceildivides and rounds up (10_usize.div_ceil(4)is3), and.max(1)keeps the size at least 1.- Loop variables still need
move.for i in 0..4 { s.spawn(|| i * 2); }borrowsi, which dies at the end of each turn of the loop:E0373. Writes.spawn(move || i * 2). If the closure also uses aVec,movewould move the wholeVecinto the first thread; make a reference first (let data = &data;) and move that: copying a reference is free. - Two threads writing the same variable:
E0499, explained in the next step.
Your task
Write fn parallel_sum(data: &[u64], workers: usize) -> u64. Split data into chunks of data.len().div_ceil(workers).max(1) items, sum each chunk in its own scoped thread, and return the total. Borrow the data; don't copy it. You can assume workers is at least 1.
Challenge 2
Write fn apply_discount(prices: &mut [u32], percent: u32, workers: usize). It lowers every price by percent percent, rounding down (price * (100 - percent) / 100), working on the slice in place with one scoped thread per chunk. Use chunks_mut with a chunk size of prices.len().div_ceil(workers).max(1).
Challenge 3
In one thread::scope, run two threads over the same borrowed temps vector at the same time: one finds the lowest reading, the other the highest. Then print them, and show that main still owns the vector:
min: -4
max: 31
readings: 7
Previous: Many threads, results in order Next: Send and Sync: what may cross threads