Lesson 64 of 94
Threads
Data races are the classic threading bug: forget a lock and two threads corrupt a HashMap, sometimes, in production. Rust's threads are ordinary OS threads, but the compiler checks how they share data. Code that could race doesn't compile. The Rust community calls this "fearless concurrency".
This section has four lessons:
- Threads (this lesson): starting threads, getting results back, and borrowing data safely with scoped threads.
- Shared State:
Arc,Mutex,RwLock,Condvar, and friends. - Channels: passing messages between threads.
- Atomics, Send & Sync: lock-free counters and the two traits behind all of these checks.
| Task | Rust |
|---|---|
| Start a thread | std::thread::spawn(closure) |
| Wait for a thread | handle.join(), which also returns the thread's result |
| Get a result from a thread | the closure's return value, from JoinHandle<T>::join() |
| A thread fails | a panic ends the thread; join() returns Err |
| Get the current thread's name | thread::current().name() |
| Per-thread data | thread_local! |
| Borrow local variables in threads | thread::scope |
| Thread pool | a pool built from channels, or the rayon crate |
A note on output: threads run in an unpredictable order. The examples are written so their output is deterministic, usually by having threads return results and printing them from main. When you run them, the order of anything printed inside a thread may differ.
1. Spawning a thread and waiting for it
thread::spawn takes a closure and runs it on a new thread. It returns a JoinHandle; calling join() waits for the thread to finish.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("hello from the worker thread");
});
handle.join().unwrap();
println!("worker finished; back in main");
}hello from the worker thread
worker finished; back in mainRust has no separate start(): the thread starts running as soon as spawn returns.
2. When main returns, the process ends
In Rust, every spawned thread is like a daemon thread: when main returns, the process exits, and unfinished threads are killed mid-work.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..=5 {
println!("worker: step {i}");
thread::sleep(Duration::from_millis(100));
}
});
thread::sleep(Duration::from_millis(250));
println!("main: done");
// The process exits here; the worker probably printed steps 1-3 and never finishes.
}If a thread's work matters, keep its JoinHandle and join it. Dropping a handle without joining detaches the thread; it keeps running until it finishes or the process exits.
3. Getting a result back from a thread
The closure's return value comes back through join(). JoinHandle<T> represents the thread's eventual result, and join() waits for it.
use std::thread;
fn count_primes(limit: u32) -> usize {
(2..limit).filter(|n| (2..*n).take_while(|d| d * d <= *n).all(|d| n % d != 0)).count()
}
fn main() {
let handle: thread::JoinHandle<usize> = thread::spawn(|| count_primes(50_000));
println!("main can do other work meanwhile");
let primes = handle.join().unwrap();
println!("primes below 50000: {primes}");
}main can do other work meanwhile
primes below 50000: 51334. A closure that borrows needs move
A spawned thread may outlive the function that created it, so it can't borrow that function's local variables. This fails with a clear message:
use std::thread;
fn main() {
let name = String::from("report.csv");
let handle = thread::spawn(|| {
println!("processing {name}");
});
handle.join().unwrap();
}error[E0373]: closure may outlive the current function, but it borrows `name`, which is owned by the current function
--> src/main.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `name`
6 | println!("processing {name}");
| ---- `name` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:5:18
|
5 | let handle = thread::spawn(|| {
| __________________^
6 | | println!("processing {name}");
7 | | });
| |______^
help: to force the closure to take ownership of `name` (and any other referenced variables), use the `move` keyword
|
5 | let handle = thread::spawn(move || {
| ++++The error is right even though we join immediately: spawn requires a 'static closure, and the compiler doesn't reason about the join call.
5. move gives the thread its own data
move transfers ownership of captured variables into the closure, so the thread owns them. After the move, main can't use them anymore.
use std::thread;
fn main() {
let name = String::from("report.csv");
let rows = vec![3, 1, 4];
let handle = thread::spawn(move || {
let total: i32 = rows.iter().sum();
format!("{name}: {} rows, total {total}", rows.len())
});
// println!("{name}"); // error: `name` was moved into the thread
println!("{}", handle.join().unwrap());
}report.csv: 3 rows, total 8Rust makes you choose how a thread gets its data: move it, clone it, or share it explicitly with Arc (next lesson).
6. Clone before moving to keep a copy
If main still needs the data, clone it and move the clone.
use std::thread;
fn main() {
let config = vec![String::from("db=postgres"), String::from("port=5432")];
let for_worker = config.clone();
let handle = thread::spawn(move || for_worker.iter().map(|s| s.len()).sum::<usize>());
println!("main still has {} settings", config.len());
println!("worker counted {} bytes", handle.join().unwrap());
}main still has 2 settings
worker counted 20 bytesCloning is fine for small data. For large data that several threads only read, use scoped threads (example 13) or an Arc.
7. Many threads, results in order
Spawn several threads, keep the handles in a Vec, and join them in order. The threads run concurrently, but the results come back in the order you join them.
use std::thread;
fn checksum(data: &[u8]) -> u32 {
data.iter().fold(0u32, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u32))
}
fn main() {
let files = vec![
(String::from("a.txt"), b"alpha".to_vec()),
(String::from("b.txt"), b"bravo charlie".to_vec()),
(String::from("c.txt"), b"delta".to_vec()),
];
let handles: Vec<_> = files
.into_iter()
.map(|(name, bytes)| thread::spawn(move || (name, checksum(&bytes))))
.collect();
for h in handles {
let (name, sum) = h.join().unwrap();
println!("{name}: {sum:08x}");
}
}a.txt: 0589b15e
b.txt: 31a505dc
c.txt: 05b0bbb88. Threads really do run in parallel
Three threads that each sleep 200 ms finish in about 200 ms total, not 600.
use std::thread;
use std::time::{Duration, Instant};
fn fake_download(id: u32) -> String {
thread::sleep(Duration::from_millis(200)); // pretend network latency
format!("file-{id}")
}
fn main() {
let start = Instant::now();
let handles: Vec<_> = (1..=3).map(|id| thread::spawn(move || fake_download(id))).collect();
let files: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
let elapsed = start.elapsed();
println!("{files:?}");
println!("parallel (under 500 ms): {}", elapsed < Duration::from_millis(500));
}["file-1", "file-2", "file-3"]
parallel (under 500 ms): true9. A panic ends only its own thread
A panic in a spawned thread unwinds that thread, not the program. join() returns Err holding the panic payload, so the parent can decide what to do. The panic message itself goes to stderr.
use std::thread;
fn parse_all(lines: Vec<&'static str>) -> Vec<i32> {
lines.iter().map(|l| l.parse().expect("not a number")).collect()
}
fn main() {
let good = thread::spawn(|| parse_all(vec!["1", "2", "3"]));
let bad = thread::spawn(|| parse_all(vec!["4", "five", "6"]));
println!("good: {:?}", good.join());
match bad.join() {
Ok(v) => println!("bad: {v:?}"),
Err(payload) => {
let msg = payload.downcast_ref::<String>().map(String::as_str).unwrap_or("unknown panic");
println!("worker panicked: {msg}");
}
}
println!("main keeps running");
}good: Ok([1, 2, 3])
worker panicked: not a number: ParseIntError { kind: InvalidDigit }
main keeps runningThe parent finds out about the panic through join(). For expected failures, return a Result instead (next example).
10. Returning Result from threads
Errors that are part of normal operation should be values. A thread returning Result<T, E> gives join() a Result<Result<T, E>, panic>: the outer one for panics, the inner one for your errors.
use std::thread;
fn fetch_price(symbol: &str) -> Result<f64, String> {
match symbol {
"ACME" => Ok(12.5),
"GLOBEX" => Ok(99.0),
other => Err(format!("unknown symbol {other}")),
}
}
fn main() {
let symbols = ["ACME", "NOPE", "GLOBEX"];
let handles: Vec<_> = symbols.iter().map(|&s| thread::spawn(move || (s, fetch_price(s)))).collect();
for h in handles {
let (symbol, result) = h.join().expect("worker panicked");
match result {
Ok(price) => println!("{symbol}: {price:.2}"),
Err(e) => println!("{symbol}: error: {e}"),
}
}
}ACME: 12.50
NOPE: error: unknown symbol NOPE
GLOBEX: 99.0011. Naming threads with Builder
thread::Builder configures a thread before spawning it: a name (shown in panic messages and debuggers) and a stack size. Unlike spawn, Builder::spawn returns an io::Result because the OS may refuse to create a thread.
use std::thread;
fn main() {
let handle = thread::Builder::new()
.name(String::from("indexer-1"))
.stack_size(4 * 1024 * 1024)
.spawn(|| {
let me = thread::current();
format!("running on {:?}", me.name())
})
.expect("failed to spawn thread");
println!("{}", handle.join().unwrap());
println!("main thread is {:?}", thread::current().name());
}running on Some("indexer-1")
main thread is Some("main")12. Every thread has an id
thread::current().id() returns a unique ThreadId. It's handy for logging, and for checking that work really ran elsewhere.
use std::thread;
fn main() {
let main_id = thread::current().id();
let worker_id = thread::spawn(|| thread::current().id()).join().unwrap();
println!("different threads: {}", main_id != worker_id);
println!("same thread twice: {}", main_id == thread::current().id());
}different threads: true
same thread twice: true13. Scoped threads can borrow
thread::scope creates a scope in which you can spawn threads that borrow local data. The scope doesn't end until all its threads have finished, so the borrows are guaranteed to stay valid. No move, no clone, no Arc.
use std::thread;
fn main() {
let words = vec!["apple", "banana", "cherry", "date"];
let mut longest = "";
thread::scope(|s| {
// Both threads borrow `words`; the third borrows `longest` mutably.
s.spawn(|| assert_eq!(words.len(), 4));
s.spawn(|| assert!(words.iter().all(|w| w.is_ascii())));
s.spawn(|| {
for w in &words {
if w.len() > longest.len() {
longest = w;
}
}
});
}); // every thread spawned on `s` is joined here
println!("longest: {longest}");
println!("and `words` is still ours: {words:?}");
}longest: banana
and `words` is still ours: ["apple", "banana", "cherry", "date"]Scoped threads are the best default when threads only need to work on data the current function already has.
14. Scoped threads return values too
s.spawn returns a ScopedJoinHandle, whose join() gives the result, just like a regular handle.
use std::thread;
fn main() {
let orders: Vec<u32> = (1..=1000).collect();
let (left, right) = orders.split_at(orders.len() / 2);
let total = thread::scope(|s| {
let a = s.spawn(|| left.iter().map(|&x| x as u64).sum::<u64>());
let b = s.spawn(|| right.iter().map(|&x| x as u64).sum::<u64>());
a.join().unwrap() + b.join().unwrap()
});
println!("total: {total}");
}total: 50050015. The borrow rules still apply across threads
Scoped threads can share & references freely, but a &mut must still be unique. Two threads trying to mutate the same vector is exactly the data race Rust rejects:
use std::thread;
fn main() {
let mut hits = vec![0; 3];
thread::scope(|s| {
s.spawn(|| hits[0] += 1);
s.spawn(|| hits[1] += 1);
});
println!("{hits:?}");
}error[E0499]: cannot borrow `hits` as mutable more than once at a time
--> src/main.rs:7:17
|
5 | thread::scope(|s| {
| - has type `&'1 Scope<'1, '_>`
6 | s.spawn(|| hits[0] += 1);
| ------------------------
| | | |
| | | first borrow occurs due to use of `hits` in closure
| | first mutable borrow occurs here
| argument requires that `hits` is borrowed for `'1`
7 | s.spawn(|| hits[1] += 1);
| ^^ ---- second borrow occurs due to use of `hits` in closure
| |
| second mutable borrow occurs here
|
note: requirement that the value outlives `'1` introduced here
--> /rustc/<version>/library/std/src/thread/scoped.rs:200:34Even though the threads touch different elements, each closure borrows the whole vector mutably. The fix is to split the data first (next example), or use a Mutex or atomics.
16. Splitting data into disjoint &mut chunks
chunks_mut, split_at_mut, and iter_mut hand out non-overlapping mutable references, one per thread. The compiler can see they don't overlap, so every thread can write without locks.
use std::thread;
fn main() {
let mut prices = vec![100.0, 250.0, 80.0, 40.0, 999.0, 12.5, 60.0];
thread::scope(|s| {
for chunk in prices.chunks_mut(3) {
s.spawn(move || {
for p in chunk.iter_mut() {
*p *= 1.2; // add 20% tax
}
});
}
});
println!("{prices:?}");
}[120.0, 300.0, 96.0, 48.0, 1198.8, 15.0, 72.0]The move here moves the chunk reference (not the data) into each thread. This pattern, where each thread owns a slice of the output, is the basis of most data-parallel code, and what rayon does for you.
17. How many threads to use
thread::available_parallelism() returns the number of CPUs the program may use, the equivalent of Runtime.getRuntime().availableProcessors().
use std::thread;
fn main() {
let cores = thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let items: usize = 1_000;
let chunk_size = items.div_ceil(cores);
println!("at least one core: {}", cores >= 1);
println!("chunks cover every item: {}", chunk_size * cores >= items);
}at least one core: true
chunks cover every item: true18. A parallel sum over all cores
Putting it together: split a large slice into one chunk per core, sum the chunks on scoped threads, and combine the partial sums.
use std::thread;
fn parallel_sum(data: &[u64]) -> u64 {
let cores = thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let chunk_size = data.len().div_ceil(cores).max(1);
thread::scope(|s| {
let handles: Vec<_> = data.chunks(chunk_size).map(|chunk| s.spawn(move || chunk.iter().sum::<u64>())).collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
})
}
fn main() {
let data: Vec<u64> = (1..=1_000_000).collect();
let expected: u64 = data.iter().sum();
let got = parallel_sum(&data);
println!("sum = {got}, matches sequential: {}", got == expected);
}sum = 500000500000, matches sequential: true19. A generic parallel map
A reusable helper: apply a function to every element on several threads and return the results in order. The bounds say what the threads need: the function is shared by all threads (Sync), and the results are sent back (Send). The Atomics, Send & Sync lesson explains these traits.
use std::thread;
fn par_map<T, R, F>(items: &[T], threads: usize, f: F) -> Vec<R>
where
T: Sync,
R: Send,
F: Fn(&T) -> R + Sync,
{
let chunk_size = items.len().div_ceil(threads).max(1);
let f = &f;
thread::scope(|s| {
let handles: Vec<_> = items
.chunks(chunk_size)
.map(|chunk| s.spawn(move || chunk.iter().map(f).collect::<Vec<R>>()))
.collect();
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
})
}
fn main() {
let urls = ["/home", "/about", "/products/42", "/cart", "/checkout"];
let lengths = par_map(&urls, 2, |u| u.len());
let upper = par_map(&urls, 3, |u| u.to_uppercase());
println!("{lengths:?}");
println!("{upper:?}");
}[5, 6, 12, 5, 9]
["/HOME", "/ABOUT", "/PRODUCTS/42", "/CART", "/CHECKOUT"]20. Rc can't cross threads
Some types aren't safe to use from two threads. Rc has a non-atomic reference count, so the compiler refuses to send it to another thread. This is the Send check at work:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(vec![1, 2, 3]);
let copy = Rc::clone(&shared);
let handle = thread::spawn(move || copy.len());
println!("{}", handle.join().unwrap());
}error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
--> src/main.rs:7:32
|
7 | let handle = thread::spawn(move || copy.len());
| ------------- -------^^^^^^^^^^^
| | |
| | `Rc<Vec<i32>>` cannot be sent between threads safely
| | within this `{closure@src/main.rs:7:32: 7:39}`
| required by a bound introduced by this call
|
= help: within `{closure@src/main.rs:7:32: 7:39}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`
note: required because it's used within this closure
--> src/main.rs:7:32
|
7 | let handle = thread::spawn(move || copy.len());
| ^^^^^^^
note: required by a bound in `spawn`
--> /rustc/<version>/library/std/src/thread/functions.rs:125:0The fix is Arc, the thread-safe version of Rc, covered in the next lesson.
21. Thread-local storage
thread_local! declares a static where every thread gets its own independent copy. Access goes through with, and interior mutability (Cell, RefCell) allows changes.
use std::cell::{Cell, RefCell};
use std::thread;
thread_local! {
static REQUESTS_HANDLED: Cell<u32> = const { Cell::new(0) };
static LOG_BUFFER: RefCell<Vec<String>> = RefCell::new(Vec::new());
}
fn handle(request: &str) {
REQUESTS_HANDLED.with(|n| n.set(n.get() + 1));
LOG_BUFFER.with(|buf| buf.borrow_mut().push(format!("handled {request}")));
}
fn main() {
let worker = thread::spawn(|| {
for r in ["/a", "/b", "/c"] {
handle(r);
}
(REQUESTS_HANDLED.with(|n| n.get()), LOG_BUFFER.with(|b| b.borrow().clone()))
});
handle("/main-only");
let (count, log) = worker.join().unwrap();
println!("worker handled {count}: {log:?}");
println!("main handled {}", REQUESTS_HANDLED.with(|n| n.get()));
}worker handled 3: ["handled /a", "handled /b", "handled /c"]
main handled 122. Parking and unparking a thread
thread::park() blocks the current thread until another thread calls unpark() on its handle. It's a low-level building block; most code uses channels or Condvar instead. Here main waits for the worker's signal:
use std::thread;
use std::time::Duration;
fn main() {
let main_thread = thread::current();
let worker = thread::spawn(move || {
thread::sleep(Duration::from_millis(50)); // pretend to load something
main_thread.unpark(); // wake main up
"cache warmed"
});
thread::park(); // sleep until unparked (may also wake spuriously)
println!("main woke up");
println!("{}", worker.join().unwrap());
}main woke up
cache warmedpark may return spuriously, so real code parks in a loop that checks a condition. That condition needs shared state, which is the next lesson's topic.
23. sleep, yield_now, and timeouts
thread::sleep pauses the current thread; thread::yield_now hints that others may run. To stop a thread early, you send it a message or set a flag it checks (shown in the next lessons).
use std::thread;
use std::time::{Duration, Instant};
fn main() {
let start = Instant::now();
let worker = thread::spawn(move || {
let mut ticks = 0;
while start.elapsed() < Duration::from_millis(100) {
ticks += 1;
thread::sleep(Duration::from_millis(10));
}
ticks
});
let ticks = worker.join().unwrap();
println!("ticked at most 11 times: {}", (1..=11).contains(&ticks));
thread::yield_now();
println!("elapsed at least 100 ms: {}", start.elapsed() >= Duration::from_millis(100));
}ticked at most 11 times: true
elapsed at least 100 ms: true24. Realistic: parallel word count
Count word frequencies across several documents, one scoped thread per document, and merge the per-thread maps at the end. Each thread builds its own HashMap, so there's no locking at all.
use std::collections::HashMap;
use std::thread;
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
counts
}
fn main() {
let documents = [
"Rust is fast. Rust is safe.",
"Java is everywhere; Java is mature.",
"Safe concurrency is why Rust exists.",
];
let partials: Vec<HashMap<String, usize>> = thread::scope(|s| {
let handles: Vec<_> = documents.iter().map(|doc| s.spawn(|| count_words(doc))).collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
let mut total: HashMap<String, usize> = HashMap::new();
for partial in partials {
for (word, n) in partial {
*total.entry(word).or_insert(0) += n;
}
}
let mut top: Vec<(String, usize)> = total.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
for (word, n) in top.iter().take(4) {
println!("{word:<6} {n}");
}
}is 5
rust 3
java 2
safe 2This is a "map in parallel, then reduce" shape, spelled out by hand.
25. Realistic: image processing on row bands
Convert an RGB image to grayscale by giving each thread a band of rows. chunks_mut on the output buffer gives every thread its own disjoint rows to write into.
use std::thread;
const WIDTH: usize = 4;
const HEIGHT: usize = 6;
fn main() {
// RGB pixels, row by row: a simple gradient.
let input: Vec<[u8; 3]> = (0..WIDTH * HEIGHT).map(|i| [(i * 10) as u8, (i * 5) as u8, 200]).collect();
let mut gray = vec![0u8; WIDTH * HEIGHT];
let rows_per_band = 2;
thread::scope(|s| {
for (band, out) in gray.chunks_mut(WIDTH * rows_per_band).enumerate() {
let start = band * WIDTH * rows_per_band;
let pixels = &input[start..start + out.len()];
s.spawn(move || {
for (dst, [r, g, b]) in out.iter_mut().zip(pixels) {
// Standard luminance weights.
*dst = (0.299 * *r as f32 + 0.587 * *g as f32 + 0.114 * *b as f32).round() as u8;
}
});
}
});
for row in gray.chunks(WIDTH) {
println!("{row:?}");
}
}[23, 29, 35, 41]
[47, 52, 58, 64]
[70, 76, 82, 88]
[94, 100, 106, 112]
[118, 124, 129, 135]
[141, 147, 153, 159]26. Realistic: parallel search with early results
Search several log files for the first line containing an error code. Each thread returns Option<(line number, line)>, and main reports per file.
use std::thread;
fn find_first<'a>(lines: &[&'a str], needle: &str) -> Option<(usize, &'a str)> {
lines.iter().enumerate().find(|(_, l)| l.contains(needle)).map(|(i, l)| (i + 1, *l))
}
fn main() {
let logs: Vec<(&str, Vec<&str>)> = vec![
("api.log", vec!["GET / 200", "GET /x 200", "POST /pay 502 E502", "GET / 200"]),
("worker.log", vec!["job 1 ok", "job 2 ok"]),
("db.log", vec!["conn ok", "E502 upstream timeout", "retry ok"]),
];
let results = thread::scope(|s| {
let handles: Vec<_> = logs
.iter()
.map(|(name, lines)| s.spawn(move || (*name, find_first(lines, "E502"))))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect::<Vec<_>>()
});
for (name, hit) in results {
match hit {
Some((n, line)) => println!("{name}:{n}: {line}"),
None => println!("{name}: no match"),
}
}
}api.log:3: POST /pay 502 E502
worker.log: no match
db.log:2: E502 upstream timeoutThe returned &strs borrow from logs, which lives outside the scope, so they can be used after the threads finish. With thread::spawn this wouldn't compile without cloning every line.
27. Realistic: a pipeline of stages with owned data
Threads can also form a pipeline: each stage owns its input, transforms it, and hands the result to the next stage. With only spawn and join the stages run one after another; the Channels lesson makes them run at the same time.
use std::thread;
#[derive(Debug)]
struct Order {
id: u32,
cents: u32,
}
fn main() {
let raw = vec!["1,1999", "2,oops", "3,500", "4,12000"];
let parse = thread::spawn(move || {
raw.into_iter()
.filter_map(|line| {
let (id, cents) = line.split_once(',')?;
Some(Order { id: id.parse().ok()?, cents: cents.parse().ok()? })
})
.collect::<Vec<Order>>()
});
let orders = parse.join().unwrap();
let enrich = thread::spawn(move || {
orders.into_iter().map(|o| (o.id, o.cents, if o.cents >= 10_000 { "review" } else { "auto" })).collect::<Vec<_>>()
});
for (id, cents, route) in enrich.join().unwrap() {
println!("order {id}: ${}.{:02} -> {route}", cents / 100, cents % 100);
}
}order 1: $19.99 -> auto
order 3: $5.00 -> auto
order 4: $120.00 -> reviewSummary
thread::spawn(closure)starts an OS thread immediately and returns aJoinHandle<T>;join()waits and returns the closure's result, orErrif it panicked.- When
mainreturns, remaining threads are killed. Join the threads whose work matters. - Spawned closures must be
'static: usemoveto hand data over, cloning first if you still need it. thread::scopelets threads borrow local data safely, and joins them all at the end of the scope. Prefer it when you don't need threads to outlive the function.- Data races don't compile: two threads can't both hold
&mutto the same data, and non-thread-safe types likeRccan't be sent to another thread. Split data withchunks_mut/split_at_mutto write in parallel without locks. Buildernames threads,thread_local!gives per-thread state, andpark/unparkis a low-level wake-up signal.
Next: Shared State, where threads share data through Arc, Mutex, and RwLock.
