Expert · Concurrency and async · topic 35 of 50 · 30 min
Threads
Explanation
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
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");
}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
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` keywordlet city = String::from("Chennai");
let h = thread::spawn(move || {
println!("{city}"); // the thread now owns city
});
h.join().unwrap();Returning results from threads
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}"); // 10000Scoped 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.
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
}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
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.
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");
}Practice (0/3 done)
Why does thread::spawn usually need a move closure?
What does this print? use std::thread; fn main() { let h = thread::spawn(|| 6 * 7); println!("{}", h.join().unwrap()); }
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
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Message passing with channelsConcurrency and async · same module
- Shared state: Arc, Mutex, Send and SyncConcurrency and async · same module
- Async Rust with tokioConcurrency and async · same module
- Concurrency 1: threads, mutexes and data racesC++ Programming (modern C++ and DSA) · Exceptions, concurrency and building projects with CMake
- Threads: doing things at the same timeJava Programming (Core to Spring Boot) · Multithreading and concurrency
- Concurrency 2: atomics, async, futures and condition variablesC++ Programming (modern C++ and DSA) · Exceptions, concurrency and building projects with CMake
Questions people ask about this
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.
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.
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.
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
- 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.
- Module 9 exam: Concurrency and async · 13 questions · 25 min, without help. 4 topics of this module still to finish first.
- Then the next module, Testing, tooling and unsafe Rust. 50 topics left in the course before the final exam.