Skip to content

Expert · Concurrency and async · topic 35 of 50 · 30 min

Threads

Explanation

A thread is a separate line of execution that runs at the same time as others, so a program can use several CPU cores. std::thread::spawn starts one and returns a JoinHandle; join() waits for it to finish. Spawned threads usually need move closures, because they may outlive the variables they use; std::thread::scope lets threads borrow local data safely.

Why threads?

Modern computers have several CPU cores. A program with one thread uses only one core at a time. Concurrency means several tasks make progress during the same period; parallelism means they literally run at the same instant on different cores. Threads give you both. They are useful for CPU-heavy work, such as resizing thousands of photos or calculating reports for every branch of a bank.

Concurrency is famous for hard bugs: data races, where two threads touch the same data at once and at least one writes. Rust's ownership and borrowing rules extend to threads, so most of these bugs become compile errors. Rust calls this "fearless concurrency".

Spawning and joining

rust
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("worker: step {i}");
            thread::sleep(Duration::from_millis(10));
        }
    });

    println!("main: doing other work");
    handle.join().unwrap();      // wait for the worker to finish
    println!("main: all done");
}
The order of "main: doing other work" and the worker lines can change between runs.
Think of it like this: Threads are like opening more billing counters in a supermarket during a festival rush. Each counter (thread) serves its own queue at the same time. More counters finish the crowd faster, but each counter costs a cashier and space, and if two cashiers grab the same cash drawer at once without rules, money goes missing. Rust makes sure that drawer always has rules.

spawn takes a closure and runs it on a new thread. It returns a JoinHandle. join() blocks (waits) until that thread ends and returns a Result: Err if the thread panicked. If main ends first, the whole program exits and other threads are stopped, so always join threads whose work matters.

Why move is needed

output
let city = String::from("Chennai");
let h = thread::spawn(|| {
    println!("{city}");
});
// error[E0373]: closure may outlive the current function, but it borrows `city`
// help: to force the closure to take ownership of `city`, use the `move` keyword
The compiler cannot know when the new thread will run, so a borrow might dangle.
rust
let city = String::from("Chennai");
let h = thread::spawn(move || {
    println!("{city}");          // the thread now owns city
});
h.join().unwrap();
move transfers ownership into the thread. main can no longer use city.

Returning results from threads

rust
let handles: Vec<_> = (1..=4)
    .map(|branch| thread::spawn(move || branch * 1000))   // pretend calculation
    .collect();

let total: u32 = handles.into_iter().map(|h| h.join().unwrap()).sum();
println!("{total}");   // 10000
join() returns whatever the closure returned.

Scoped threads: borrowing safely

std::thread::scope (stable since Rust 1.63) creates a scope in which you spawn threads that are guaranteed to finish before the scope ends. Because of that guarantee, they may borrow local variables without move.

rust
use std::thread;

fn main() {
    let sales = vec![1200, 450, 3000, 800];
    let (left, right) = sales.split_at(2);
    let (a, b) = thread::scope(|s| {
        let h1 = s.spawn(|| left.iter().sum::<u32>());
        let h2 = s.spawn(|| right.iter().sum::<u32>());
        (h1.join().unwrap(), h2.join().unwrap())
    });
    println!("{}", a + b);   // 5450
    println!("{:?}", sales); // still usable: only borrowed
}
All threads spawned on s are joined automatically at the end of the scope.

The operating system decides which thread runs when. This is called scheduling, and it is not predictable: the same program can print lines in a different order on each run. Never rely on threads running in a particular order; if order matters, use join, channels or locks to coordinate.

Common mistakes

  • Forgetting join, so main exits before the work is done.
  • Spawning a thread per tiny task. Threads cost memory (each has its own stack) and time to start. For many small jobs use a pool (for example the rayon crate) or async.
  • Expecting output in a fixed order. Threads are scheduled by the operating system.
  • Ignoring the Result of join: a panicking worker should be noticed.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Threads · 1/6

More cores → more work at once

A thread is a separate line of execution. Several threads let a program use several CPU cores.

Prefer a video by a teacher? Find free videos on “rust threads spawn join move closure scoped threads” on YouTube ↗

In real life: Generating payslips in parallel

At month end, an HR system generates PDF payslips for 8,000 employees. Each payslip is independent CPU work. Splitting the employee list into four chunks and processing each chunk in its own thread (with thread::scope borrowing the list) cuts the time roughly by the number of cores, and the compiler guarantees that no two threads write to the same payslip data.

rust
use std::thread;

fn main() {
    let employees: Vec<u32> = (1..=8000).collect();
    let done: usize = thread::scope(|s| {
        let handles: Vec<_> = employees
            .chunks(2000)
            .map(|chunk| s.spawn(move || chunk.len()))   // pretend: build payslips
            .collect();
        handles.into_iter().map(|h| h.join().unwrap()).sum()
    });
    println!("{done} payslips generated");
}
Prints: 8000 payslips generated

Practice (0/3 done)

Task 1 · Question

Why does thread::spawn usually need a move closure?

Task 2 · Question

What does this print? use std::thread; fn main() { let h = thread::spawn(|| 6 * 7); println!("{}", h.join().unwrap()); }

Task 3 · Practice

Write a program that sums the numbers 1 to 40,000,000 using 4 threads (each thread sums one quarter of the range with move closures), joins them and prints the total. Time it against a single-threaded loop with std::time::Instant in a release build.

Quick quiz

Question 1

1.What does join() do?

Question 2

2.What happens to running threads when main returns?

Question 3

3.What is special about std::thread::scope?

Question 4

4.What is a data race?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What does join() do?

Answer: Waits for a thread to finish and returns its result

join blocks until the thread ends.

What happens to running threads when main returns?

Answer: The program exits and they are stopped

Join threads whose work must complete.

What is special about std::thread::scope?

Answer: Threads in it can borrow local data because they must finish inside the scope

Scoped threads are joined before the scope ends.

What is a data race?

Answer: Two threads accessing the same data at once, at least one writing, without synchronisation

Rust prevents data races at compile time in safe code.

Why use std::lock_guard instead of calling m.lock() and m.unlock() yourself?Related

Answer: It unlocks automatically, even when an exception is thrown (RAII)

Manual unlock is easy to miss on an early return or exception.

From Concurrency 1: threads, mutexes and data races

What is the polite way to ask a thread to stop?Related

Answer: interrupt(), and the thread checks its interrupted flag

Thread.stop is deprecated and unsafe.

From Threads: doing things at the same time

What is the danger of a long CPU loop inside an async task?Related

Answer: It blocks the worker thread, so other tasks stall

Tasks must yield at .await points; use spawn_blocking for heavy work.

From Async Rust with tokio

Two bank balances must be updated together by several threads. Which tool?Related

Answer: One std::mutex locked around both updates

Only a lock makes the combined change appear all at once.

From Concurrency 2: atomics, async, futures and condition variables

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 3 more topics in “Concurrency and async” before its exam: Message passing with channels, Shared state: Arc, Mutex, Send and Sync, Async Rust with tokio.
  2. Module 9 exam: Concurrency and async · 13 questions · 25 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, Testing, tooling and unsafe Rust. 50 topics left in the course before the final exam.

← Previous: Rc, RefCell and Weak