Guide contents

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:

  1. Threads (this lesson): starting threads, getting results back, and borrowing data safely with scoped threads.
  2. Shared State: Arc, Mutex, RwLock, Condvar, and friends.
  3. Channels: passing messages between threads.
  4. 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.

Rust
use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        println!("hello from the worker thread");
    });

    handle.join().unwrap();
    println!("worker finished; back in main");
}
Output
hello from the worker thread
worker finished; back in main

Rust 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.

Rust
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.

Rust
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}");
}
Output
main can do other work meanwhile
primes below 50000: 5133

4. 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:

Rust✗ does not compile · E0373
use std::thread;

fn main() {
    let name = String::from("report.csv");
    let handle = thread::spawn(|| {
        println!("processing {name}");
    });
    handle.join().unwrap();
}
Output
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.

Rust
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());
}
Output
report.csv: 3 rows, total 8

Rust 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.

Rust
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());
}
Output
main still has 2 settings
worker counted 20 bytes

Cloning 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.

Rust
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}");
    }
}
Output
a.txt: 0589b15e
b.txt: 31a505dc
c.txt: 05b0bbb8

8. Threads really do run in parallel

Three threads that each sleep 200 ms finish in about 200 ms total, not 600.

Rust
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));
}
Output
["file-1", "file-2", "file-3"]
parallel (under 500 ms): true

9. 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.

Rust
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");
}
Output
good: Ok([1, 2, 3])
worker panicked: not a number: ParseIntError { kind: InvalidDigit }
main keeps running

The 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.

Rust
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}"),
        }
    }
}
Output
ACME: 12.50
NOPE: error: unknown symbol NOPE
GLOBEX: 99.00

11. 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.

Rust
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());
}
Output
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.

Rust
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());
}
Output
different threads: true
same thread twice: true

13. 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.

Rust
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:?}");
}
Output
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.

Rust
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}");
}
Output
total: 500500

15. 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:

Rust✗ does not compile · E0499
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:?}");
}
Output
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:34

Even 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.

Rust
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:?}");
}
Output
[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().

Rust
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);
}
Output
at least one core: true
chunks cover every item: true

18. 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.

Rust
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);
}
Output
sum = 500000500000, matches sequential: true

19. 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.

Rust
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:?}");
}
Output
[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:

Rust✗ does not compile · E0277
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());
}
Output
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:0

The 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.

Rust
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()));
}
Output
worker handled 3: ["handled /a", "handled /b", "handled /c"]
main handled 1

22. 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:

Rust
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());
}
Output
main woke up
cache warmed

park 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).

Rust
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));
}
Output
ticked at most 11 times: true
elapsed at least 100 ms: true

24. 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.

Rust
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}");
    }
}
Output
is     5
rust   3
java   2
safe   2

This 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.

Rust
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:?}");
    }
}
Output
[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.

Rust
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"),
        }
    }
}
Output
api.log:3: POST /pay 502 E502
worker.log: no match
db.log:2: E502 upstream timeout

The 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.

Rust
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);
    }
}
Output
order 1: $19.99 -> auto
order 3: $5.00 -> auto
order 4: $120.00 -> review

Summary

  • thread::spawn(closure) starts an OS thread immediately and returns a JoinHandle<T>; join() waits and returns the closure's result, or Err if it panicked.
  • When main returns, remaining threads are killed. Join the threads whose work matters.
  • Spawned closures must be 'static: use move to hand data over, cloning first if you still need it.
  • thread::scope lets 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 &mut to the same data, and non-thread-safe types like Rc can't be sent to another thread. Split data with chunks_mut/split_at_mut to write in parallel without locks.
  • Builder names threads, thread_local! gives per-thread state, and park/unpark is a low-level wake-up signal.

Next: Shared State, where threads share data through Arc, Mutex, and RwLock.