Skip to content
Learn Rust
Theme

Threads

Concurrency · Lesson 41

thread::spawn runs a closure on a new OS thread. join waits for that thread to finish.

Coming from Gothread::spawn is go func(). handle.join() waits for that one thread and hands back its return value, where wg.Wait() waits for a count and returns nothing; the nearest Rust to a WaitGroup is thread::scope, or handles joined in turn. Each Rust thread is an operating-system thread, where goroutines are scheduled by Go's runtime onto fewer of them (the page counts both). A panic in a thread comes back to join as an Err, where a goroutine's panic ends the program. The closure needs move to take the data it uses.

Coming from Kotlinthread::spawn is thread { }, and both start a thread of the operating system. Rust's join() also gives back the closure's result, as an Err if the thread panicked. The closure needs move to take ownership of what it uses.

Coming from Pythonthread::spawn plus join() is threading.Thread plus join(), or pool.submit plus future.result(). A Rust thread is an operating-system thread like a Python one, and Rust has no global interpreter lock. A Python thread's exception is printed and join() returns as if nothing happened; here join() returns an Err.

Coming from TypeScriptThere is no worker file or postMessage: thread::spawn runs a closure on another thread in the same program. move hands the data to the thread instead of copying it (a worker gets a structured clone, except for a SharedArrayBuffer), and join() waits and gives back the result.

Coming from Javathread::spawn starts an operating-system thread (Java calls it a platform thread), like new Thread, and join() returns the closure's value, like Future.get(). The difference is that the closure can't borrow locals, so you move them in, or use thread::scope to borrow them for the length of the scope.

Coming from C#thread::spawn is new Thread, not Task.Run: it is not a thread-pool thread, and join() hands back the closure's return value. The closure must own what it uses, so you write move, since no garbage collector keeps borrowed locals alive.

Coming from Cthread::spawn is pthread_create with a typed closure instead of a void * argument, and join() returns its result. The compiler refuses a thread that borrows a local, unless you use thread::scope, which joins before the local goes away.

Coming from C++thread::spawn(move || ...) is std::thread with a lambda that captures by move, and join() returns the closure's value. Dropping a JoinHandle without joining detaches the thread, where destroying a joinable std::thread calls std::terminate. std::jthread joins in its destructor.

The quick comparison

Rustsrc/main.rsRun ↗
use std::thread;
fn main() {
let names = vec!["a", "b", "c"];
let handle = thread::spawn(move || { // move: the thread takes names
for n in &names {
println!("worker sees {n}");
}
names.len() // threads can return a value
});
let count = handle.join().unwrap(); // wait for the thread
println!("worker counted {count}");
}
$ cargo run
worker sees a
worker sees b
worker sees c
worker counted 3
package main
import (
"fmt"
"sync"
)
func main() {
names := []string{"a", "b", "c"}
var count int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for _, n := range names {
fmt.Println("worker sees", n)
}
count = len(names)
}()
wg.Wait()
fmt.Println("worker counted", count)
}
$ go run main.go
worker sees a
worker sees b
worker sees c
worker counted 3
import kotlin.concurrent.thread
fun main() {
val names = listOf("a", "b", "c")
var count = 0
val t = thread {
for (n in names) {
println("worker sees $n")
}
count = names.size
}
t.join()
println("worker counted $count")
}
$ kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar
worker sees a
worker sees b
worker sees c
worker counted 3
from concurrent.futures import ThreadPoolExecutor
names = ["a", "b", "c"]
def work() -> int:
for n in names:
print(f"worker sees {n}")
return len(names) # a thread can hand back a value through a future
with ThreadPoolExecutor() as pool:
future = pool.submit(work)
count = future.result() # wait for the thread
print(f"worker counted {count}")
$ python3 main.py
worker sees a
worker sees b
worker sees c
worker counted 3
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
if (isMainThread) {
const names = ["a", "b", "c"];
// this same file runs again in the worker; names is copied to it
const worker = new Worker(__filename, { workerData: names });
let count = 0;
worker.on("message", (n: number) => (count = n)); // the worker's answer
worker.on("exit", () => console.log(`worker counted ${count}`));
} else {
const names: string[] = workerData;
for (const n of names) {
console.log(`worker sees ${n}`);
}
parentPort!.postMessage(names.length);
}
$ tsc --strict --module nodenext --types node main.ts && node main.js
worker sees a
worker sees b
worker sees c
worker counted 3
import java.util.List;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) throws Exception {
var names = List.of("a", "b", "c");
try (var pool = Executors.newSingleThreadExecutor()) {
// a Callable can return a value, unlike a plain Thread
var future = pool.submit(() -> {
for (var n : names) {
System.out.println("worker sees " + n);
}
return names.size();
});
int count = future.get(); // wait for the worker
System.out.println("worker counted " + count);
}
}
}
$ javac Main.java && java Main
worker sees a
worker sees b
worker sees c
worker counted 3
string[] names = ["a", "b", "c"];
// Task.Run runs the work on a pool thread. It can return a value.
var worker = Task.Run(() =>
{
foreach (var n in names)
{
Console.WriteLine($"worker sees {n}");
}
return names.Length;
});
var count = await worker; // wait for it
Console.WriteLine($"worker counted {count}");
$ dotnet run
worker sees a
worker sees b
worker sees c
worker counted 3
#include <pthread.h>
#include <stdio.h>
struct Job {
const char *names[3];
size_t len;
size_t count; // the thread writes its answer here
};
void *worker(void *arg) {
struct Job *job = arg;
for (size_t i = 0; i < job->len; i++) {
printf("worker sees %s\n", job->names[i]);
}
job->count = job->len;
return NULL;
}
int main(void) {
struct Job job = {{"a", "b", "c"}, 3, 0};
pthread_t t;
pthread_create(&t, NULL, worker, &job);
pthread_join(t, NULL); // wait for the thread
printf("worker counted %zu\n", job.count);
return 0;
}
$ gcc -Wall -Wextra main.c -o main && ./main
worker sees a
worker sees b
worker sees c
worker counted 3
#include <future>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
int main() {
std::vector<std::string> names{"a", "b", "c"};
// std::async runs the lambda on a new thread and hands back its result later
auto result = std::async(std::launch::async, [names = std::move(names)] { // the thread takes names
for (const auto& n : names) {
std::cout << "worker sees " << n << "\n";
}
return names.size();
});
auto count = result.get(); // wait for the thread
std::cout << "worker counted " << count << "\n";
}
$ g++ -std=c++20 -Wall -Wextra main.cpp -o main && ./main
worker sees a
worker sees b
worker sees c
worker counted 3

What's different

  • Each Rust thread is an operating-system thread (the Book calls this the 1:1 model). For many tasks that mostly wait, Rust code usually uses async on a runtime crate such as Tokio instead: Async Basics starts there.
  • join() returns a Result because the thread might have panicked. .unwrap() on that Err starts a new panic in the joining thread.
  • thread::scope lets threads borrow local variables, because the scope joins all of them before it ends.
  • std has no way to kill or interrupt a thread. To stop one, share a flag it checks or close the channel it reads from.
Gotcha

A thread that borrows a local variable

Looks fine: in Go, Kotlin, Python, Java, C#, C and C++ it runs fine; in TypeScript it crashes at runtime. Rust won't compile it.

Fine in Go. Rust can't prove it's safe.Fine in Kotlin. Rust can't prove it's safe.Fine in Python. Rust can't prove it's safe.Broken in TypeScript. Rust catches it.Fine in Java. Rust can't prove it's safe.Fine in C#. Rust can't prove it's safe.Fine in C. Rust can't prove it's safe.Fine in C++. Rust can't prove it's safe.
Rustsrc/main.rs✗ won't compile
use std::thread;
fn main() {
let names = vec!["a", "b", "c"];
let handle = thread::spawn(|| {
println!("{}", names.len());
});
handle.join().unwrap();
}
package main
import (
"fmt"
"sync"
)
func main() {
names := []string{"a", "b", "c"}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(len(names))
}()
wg.Wait()
}
$ go run main.go✓ runs
3
import kotlin.concurrent.thread
fun main() {
val names = listOf("a", "b", "c")
val t = thread { println(names.size) }
t.join()
}
$ kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar✓ runs
3
import threading
names = ["a", "b", "c"]
t = threading.Thread(target=lambda: print(len(names)))
t.start()
t.join()
$ python3 main.py✓ runs
3
import { Worker } from "node:worker_threads";
const names = ["a", "b", "c"];
// the worker's code runs in a separate JS world
new Worker("console.log(names.length)", { eval: true });
$ tsc --strict --module nodenext --types node main.ts && node main.js! crashes at runtime
node:internal/event_target:1149
  process.nextTick(() => { throw err; });
                           ^
[worker eval]:1
console.log(names.length)
            ^

ReferenceError: names is not defined
    at [worker eval]:1:13
    at runScriptInThisContext (node:internal/vm:219:10)
    at node:internal/process/execution:451:12
    at [worker eval]-wrapper:6:24
    at runScriptInContext (node:internal/process/execution:449:60)
    at evalFunction (node:internal/process/execution:283:30)
    at evalTypeScript (node:internal/process/execution:295:3)
    at MessagePort.<anonymous> (node:internal/main/worker_thread:191:9)
    at [nodejs.internal.kHybridDispatch] (node:internal/event_target:869:20)
    at MessagePort.<anonymous> (node:internal/per_context/messageport:23:28)

Node.js v24.22.1
import java.util.List;
public class Main {
public static void main(String[] args) throws InterruptedException {
var names = List.of("a", "b", "c");
var t = new Thread(() -> {
System.out.println(names.size());
});
t.start();
t.join();
}
}
$ javac Main.java && java Main✓ runs
3
string[] names = ["a", "b", "c"];
var t = new Thread(() =>
{
Console.WriteLine(names.Length);
});
t.Start();
t.Join();
$ dotnet run✓ runs
3
#include <pthread.h>
#include <stdio.h>
void *worker(void *arg) {
const char **names = arg; // points at main's local array
size_t len = 0;
while (names[len] != NULL) len++;
printf("%zu\n", len);
return NULL;
}
int main(void) {
const char *names[] = {"a", "b", "c", NULL};
pthread_t t;
pthread_create(&t, NULL, worker, names);
pthread_join(t, NULL);
return 0;
}
$ gcc -Wall -Wextra main.c -o main && ./main✓ runs
3
#include <iostream>
#include <string>
#include <thread>
#include <vector>
int main() {
std::vector<std::string> names{"a", "b", "c"};
std::thread t([&] {
std::cout << names.size() << "\n";
});
t.join();
}
$ g++ -std=c++20 -Wall -Wextra main.cpp -o main && ./main✓ runs
3
What does the compiler say? Guess first, then open.
$ cargo buildWhat is E0373?
error[E0373]: closure may outlive the current function, but it borrows `names`, which is owned by the current function
 --> src/main.rs:5:32
  |
5 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `names`
6 |         println!("{}", names.len());
  |                        ----- `names` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:5:18
  |
5 |       let handle = thread::spawn(|| {
  |  __________________^
6 | |         println!("{}", names.len());
7 | |     });
  | |______^
help: to force the closure to take ownership of `names` (and any other referenced variables), use the `move` keyword
  |
5 |     let handle = thread::spawn(move || {
  |                                ++++
What happens in Go

Go runs this and prints 3. The goroutine reads names from main directly, and the garbage collector keeps the slice alive.

What happens in Kotlin

Kotlin runs this and prints 3. The thread reads names directly, and the garbage collector keeps the list alive.

What happens in Python

Python prints 3. The thread reads names from the main program directly, and the garbage collector keeps the list alive.

What happens in TypeScript

Compiles, then the worker throws ReferenceError: names is not defined. A worker runs in its own separate JavaScript world and can't see the main thread's variables.

What happens in Java

The lambda captures a reference to the list, and the garbage collector keeps it alive, so this prints 3. Java only asks that the captured variable is never reassigned.

What happens in C#

The lambda captures names, and the garbage collector keeps it alive while the thread runs, so this prints 3.

What happens in C

C hands the thread a pointer to names on main's stack. It works because main waits with pthread_join, but nothing checks that it does.

What happens in C++

Capturing names by reference compiles and prints 3. It is only safe because join() runs before names goes away; detach() would leave a dangling reference.

What Rust sees

Without move, the closure only borrows names. names is freed when main ends. And spawn makes no promise that the thread finishes before then. It could run forever.

Why Rust says no

You call join here, so it would be fine. But the compiler judges spawn by its signature, not by what you do afterwards. Remove the join and you would have a thread reading freed memory. So Rust asks the thread to take ownership with move.

The fix

Compiles and runs.

Rust, fixedsrc/main.rsRun ↗
use std::thread;
fn main() {
let names = vec!["a", "b", "c"];
let handle = thread::spawn(move || {
println!("{}", names.len());
});
handle.join().unwrap();
}
$ cargo run
3

The toolkit

Outputs come from Linux on 64-bit ARM with Rust 1.99.1, debug mode. The operating system schedules threads, so every program here joins its threads before it prints, and prints the same lines each run unless the text says otherwise.

Start a thread, wait for it

thread::spawn takes a closure, runs it on a new thread and returns a JoinHandle<T>, where T is what the closure returns. join() waits for the thread and gives that value back inside a Result:

Rustsrc/main.rsRun ↗
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("in the thread");
6 * 7
});
// join waits for the thread, then hands back what the closure returned
let result = handle.join();
println!("join gave: {:?}", result);
}
$ cargo run
in the thread
join gave: Ok(42)

Dropping a JoinHandle does not stop the thread: the docs say it detaches it, and nothing can wait for it afterwards. Reading signatures explains spawn’s bounds.

Many threads, results in order

Start every thread first, keep the handles in a Vec, then join them in order. The results come out in input order whichever thread finished first:

Rustsrc/main.rsRun ↗
use std::thread;
fn main() {
let inputs = vec![5u64, 3, 8, 1];
// start every thread first, then join them in the order they were started
let handles: Vec<_> = inputs
.iter()
.map(|&n| thread::spawn(move || (1..=n).product::<u64>()))
.collect();
let results: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
println!("factorials: {:?}", results);
assert_eq!(results, vec![120, 6, 40320, 1]);
}
$ cargo run
factorials: [120, 6, 40320, 1]

Joining each handle right after spawning it would run the threads one after another.

move: the thread owns what it uses

A thread may outlive the function that started it, so its closure cannot borrow that function’s variables (Closures explains move). That is E0373, even for a loop counter:

Rustsrc/main.rs✗ won't compile
use std::thread;
fn main() {
let mut handles = Vec::new();
for i in 0..3 {
handles.push(thread::spawn(|| println!("thread {i}")));
}
for h in handles {
h.join().unwrap();
}
}
$ cargo buildWhat is E0373?
error[E0373]: closure may outlive the current function, but it borrows `i`, which is owned by the current function
 --> src/main.rs:6:36
  |
6 |         handles.push(thread::spawn(|| println!("thread {i}")));
  |                                    ^^                   - `i` is borrowed here
  |                                    |
  |                                    may outlive borrowed value `i`
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:22
  |
6 |         handles.push(thread::spawn(|| println!("thread {i}")));
  |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `i` (and any other referenced variables), use the `move` keyword
  |
6 |         handles.push(thread::spawn(move || println!("thread {i}")));
  |                                    ++++

move makes the closure own what it uses. A number is copied in. A Vec is moved, and then main cannot use it, which is E0382:

Rustsrc/main.rs✗ won't compile
use std::thread;
fn main() {
let names = vec![String::from("a"), String::from("b")];
let handle = thread::spawn(move || names.len());
println!("main still has {} names", names.len());
handle.join().unwrap();
}
$ cargo buildWhat is E0382?
error[E0382]: borrow of moved value: `names`
 --> src/main.rs:6:41
  |
4 |     let names = vec![String::from("a"), String::from("b")];
  |         ----- move occurs because `names` has type `Vec<String>`, which does not implement the `Copy` trait
5 |     let handle = thread::spawn(move || names.len());
  |                                ------- ----- variable moved due to use in closure
  |                                |
  |                                value moved into closure here
6 |     println!("main still has {} names", names.len());
  |                                         ^^^^^ value borrowed here after move
  |
help: consider cloning the value before moving it into the closure
  |
5 ~     let value = names.clone();
6 ~     let handle = thread::spawn(move || value.len());

To keep using a value, give the thread a clone, share it properly (Shared state), or lend it with thread::scope below:

Rustsrc/main.rsRun ↗
use std::thread;
fn main() {
let names = vec![String::from("a"), String::from("b")];
// the thread gets its own copy; main keeps the original
let for_thread = names.clone();
let handle = thread::spawn(move || for_thread.len());
println!("main still has {} names", names.len());
println!("the thread counted {}", handle.join().unwrap());
}
$ cargo run
main still has 2 names
the thread counted 2

Hand data in, get it back

A move closure takes ownership, and its return value hands it back. A Vec can go to a thread, be changed there, and return, with no copy of the elements:

Rustsrc/main.rsRun ↗
use std::thread;
fn main() {
let numbers = vec![42, 7, 19, 3];
let before = numbers.as_ptr();
// move the Vec in, work on it, return it: ownership goes there and back
let handle = thread::spawn(move || {
let mut numbers = numbers;
numbers.sort();
numbers
});
let sorted = handle.join().unwrap();
println!("sorted: {:?}", sorted);
println!("same buffer, nothing copied: {}", sorted.as_ptr() == before);
}
$ cargo run
sorted: [3, 7, 19, 42]
same buffer, nothing copied: true

One thread owns the Vec at a time, so no lock is needed. That is the idea behind Channels too.

A thread that panics

By default a panic ends the thread, not the program (a build with panic = "abort" ends the process, as Go does: Cargo profiles). join() returns Err holding the panic’s payload, boxed as Any, so a handler asks for the type it expects. It is a &str for fixed text (the compiler folds even panic!("boom {}", 1)) and a String for a message built from a run-time value, so a handler tries both:

Rustsrc/main.rsRun ↗
use std::thread;
// Runs one failing thread to the end and reports what join gave back.
fn report(name: &str, handle: thread::JoinHandle<i32>) {
match handle.join() {
Ok(n) => println!("{name}: returned {n}"),
Err(payload) => {
let as_str = payload.downcast_ref::<&str>().is_some();
let as_string = payload.downcast_ref::<String>().is_some();
println!("{name}: panicked; payload is &str: {as_str}, String: {as_string}");
}
}
}
fn main() {
// fixed text is a &str; so is a "formatted" message the compiler can fold into fixed text
report("literal", thread::spawn(|| panic!("worker failed")));
report("folded", thread::spawn(|| panic!("boom {}", 1)));
// a message built from a value only known at run time is a String
report(
"built",
thread::spawn(|| {
let v: Vec<i32> = Vec::new();
v[0]
}),
);
println!("main carries on");
}
$ cargo run
thread '<unnamed>' (1) panicked at src/main.rs:17:40:
worker failed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
literal: panicked; payload is &str: true, String: false

thread '<unnamed>' (1) panicked at src/main.rs:18:39:
boom 1
folded: panicked; payload is &str: true, String: false

thread '<unnamed>' (1) panicked at src/main.rs:24:14:
index out of bounds: the len is 0 but the index is 0
built: panicked; payload is &str: false, String: true
main carries on

The messages go to the error stream when the panics happen. .unwrap() on the Err starts a new panic in the joining thread, with a message about the Err; let _ = handle.join(); discards the value and the news that the thread panicked:

Rustsrc/main.rs! panics at runtimeRun ↗
use std::thread;
fn main() {
let handle = thread::spawn(|| -> i32 { panic!("worker failed") });
let n = handle.join().unwrap();
println!("never reached: {n}");
}
$ cargo run
thread '<unnamed>' (1) panicked at src/main.rs:4:44:
worker failed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'main' (1) panicked at src/main.rs:5:27:
called `Result::unwrap()` on an `Err` value: Any { .. }

If the work itself can fail (Errors), join() adds a layer: the outer Result says whether the thread panicked, the inner one is the job’s own. ? flattens them, once the outer error has a message:

Rustsrc/main.rsRun ↗
use std::thread;
fn parse(text: &'static str) -> thread::JoinHandle<Result<u32, std::num::ParseIntError>> {
thread::spawn(move || text.parse::<u32>())
}
fn main() {
// two layers: join says whether the thread panicked, the inner Result is the job's own
let good = parse("17").join();
let bad = parse("seventeen").join();
println!("good: {:?}", good);
println!("bad: {:?}", bad);
// flatten both layers with ? in a function that returns a Result
fn total() -> Result<u32, Box<dyn std::error::Error>> {
let a = parse("20").join().map_err(|_| "thread panicked")??;
let b = parse("22").join().map_err(|_| "thread panicked")??;
Ok(a + b)
}
println!("total: {:?}", total().unwrap());
}
$ cargo run
good: Ok(Ok(17))
bad: Ok(Err(ParseIntError { kind: InvalidDigit }))
total: 42

Borrowing without move: thread::scope

thread::scope runs a closure with a handle s that spawns threads which may borrow local variables. The scope joins them all before it returns, so no borrow outlives the variable. No move, no Arc:

Rustsrc/main.rsRun ↗
use std::thread;
fn main() {
let words = vec!["alpha", "beta", "gamma", "delta"];
let mut longest = 0;
// no move, no Arc: the scope joins every thread before it returns
let total = thread::scope(|s| {
let counting = s.spawn(|| words.iter().map(|w| w.len()).sum::<usize>());
s.spawn(|| longest = words.iter().map(|w| w.len()).max().unwrap_or(0));
counting.join().unwrap()
});
println!("total letters: {total}");
println!("longest word: {longest}");
println!("words is still ours: {}", words.len());
}
$ cargo run
total letters: 19
longest word: 5
words is still ours: 4

spawn insists that a closure owns what it uses, or borrows only data that lives for the whole program ('static, Lifetimes). A scope lifts that, because it joins. The closure must still be Send, which marks the types that may move to another thread (Send and Sync).

The borrowing rules still hold inside a scope. Two threads that both add to one counter are the classic data race, which C, Go and Java compile. Rust refuses it:

Rustsrc/main.rs✗ won't compile
use std::thread;
fn main() {
let mut count = 0;
thread::scope(|s| {
s.spawn(|| count += 1);
s.spawn(|| count += 1);
});
println!("count: {count}");
}
$ cargo buildWhat is E0499?
error[E0499]: cannot borrow `count` 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(|| count += 1);
  |         ----------------------
  |         |       |  |
  |         |       |  first borrow occurs due to use of `count` in closure
  |         |       first mutable borrow occurs here
  |         argument requires that `count` is borrowed for `'1`
7 |         s.spawn(|| count += 1);
  |                 ^^ ----- second borrow occurs due to use of `count` in closure
  |                 |
  |                 second mutable borrow occurs here
  ... (notes pointing into the standard library trimmed)

Safe Rust has no data races (Nomicon); races in a program’s logic are still yours (Shared state). A scoped thread that panics and was not joined by hand makes the scope panic, while the other scoped threads still run to their end; join it inside the scope to see the Err (scope docs; check-scope-panic.rs runs both).

Stopping a thread

A JoinHandle has join, thread and is_finished, and nothing that stops the thread; std has no kill and no interrupt. A thread stops when its closure returns, so you ask it to: share a flag it checks between pieces of work (an AtomicBool is a bool threads may share, Shared state):

Rustsrc/main.rsRun ↗
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
fn main() {
let stop = AtomicBool::new(false);
// the worker checks the flag; nothing else can make it stop
let result = thread::scope(|s| {
let worker = s.spawn(|| {
while !stop.load(Ordering::Relaxed) {
thread::yield_now(); // a real worker does a bit of work here
}
"stopped because the flag was set"
});
stop.store(true, Ordering::Relaxed);
worker.join().unwrap()
});
println!("worker: {result}");
}
$ cargo run
worker: stopped because the flag was set

Or close the channel it reads from (Channels): the loop for job in rx ends when every sender is dropped.

Rustsrc/main.rsRun ↗
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
// the worker's loop ends when every sender is gone: the channel is closed
let worker = thread::spawn(move || {
let mut sum = 0;
for job in rx {
sum += job;
}
sum
});
for job in 1..=3 {
tx.send(job).unwrap();
}
drop(tx); // closing the channel is the stop signal
println!(
"worker added up {} before it stopped",
worker.join().unwrap()
);
}
$ cargo run
worker added up 6 before it stopped

Names and stack sizes: Builder

thread::Builder sets options before the thread starts. Its spawn returns an io::Result, because the operating system can refuse a thread:

Rustsrc/main.rsRun ↗
use std::thread::{self, Builder};
fn main() {
println!("main thread name: {:?}", thread::current().name());
let handle = Builder::new()
.name("indexer".to_string())
.stack_size(256 * 1024)
.spawn(|| {
// a thread can ask for its own name
let me = thread::current();
format!("hello from {:?}", me.name())
})
.expect("the OS refused to create the thread");
println!("{}", handle.join().unwrap());
let unnamed = thread::spawn(|| thread::current().name().map(String::from));
println!("a plain spawn is named: {:?}", unnamed.join().unwrap());
}
$ cargo run
main thread name: Some("main")
hello from Some("indexer")
a plain spawn is named: None

The name shows in panic messages, which makes a failing worker easy to find:

Rustsrc/main.rs! panics at runtimeRun ↗
use std::thread::Builder;
fn main() {
let handle = Builder::new()
.name("indexer".to_string())
.spawn(|| panic!("disk full"))
.unwrap();
// join's Err is a value; unwrap turns it into a panic in main
handle.join().unwrap();
}
$ cargo run
thread 'indexer' (1) panicked at src/main.rs:6:19:
disk full
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'main' (1) panicked at src/main.rs:9:19:
called `Result::unwrap()` on an `Err` value: Any { .. }

stack_size is in bytes; the thread docs give a platform-dependent default, currently 2 MiB on Tier 1 platforms. The module also has thread_local! (a variable each thread has its own copy of), park and unpark (sleep until woken), yield_now and unique thread::current().id()s; check-others.rs runs each.

How many threads?

available_parallelism() estimates how many threads can run at once. The docs say VMs and containers may restrict it, it can change while the program runs, and on Linux it can overcount when the system will not say what limits apply (a sandbox, for example). So the program asserts only a bound:

Rustsrc/main.rsRun ↗
use std::thread;
fn main() {
let n = thread::available_parallelism().expect("the OS would not say");
// the number belongs to the machine running this page: only a bound is stable
println!("at least one: {}", n.get() >= 1);
}
$ cargo run
at least one: true

What happens when main returns

The thread docs say that when the main thread ends, the program shuts down even if other threads run, and destructors on their stacks may not run. This child holds a value whose Drop prints, parks forever, and has no handle. The program ends anyway, and this run printed nothing from the child:

Rustsrc/main.rsRun ↗
use std::sync::mpsc;
use std::thread;
struct Noisy;
impl Drop for Noisy {
fn drop(&mut self) {
println!("child: Noisy dropped");
}
}
fn main() {
let (started_tx, started_rx) = mpsc::channel();
// the handle is dropped at once: dropping a JoinHandle detaches the thread
thread::spawn(move || {
let _guard = Noisy;
started_tx.send(()).unwrap();
loop {
thread::park(); // sleeps until unparked; nobody ever unparks it
}
});
// the channel is only here to know the child is running before main ends
started_rx.recv().unwrap();
println!("main: the child is running, and main returns now");
}
$ cargo run
main: the child is running, and main returns now

The channel only tells main the child is running. If the work matters, join it.

Going deeper

Can I start 100,000 threads?

The Book says the standard library uses a 1:1 model: one operating-system thread per Rust thread (chapter 16.1). This program starts 10,000, each adding 1 to the total. A Barrier stops every thread that calls wait() until the agreed number have arrived, here the 10,000 workers and main, so when it opens all of them were alive at the same moment:

Rustsrc/main.rsRun ↗
use std::sync::Barrier;
use std::thread;
const N: usize = 10_000;
fn main() {
// wait() returns only once N + 1 threads are waiting: N workers and main
let barrier = Barrier::new(N + 1);
let total: usize = thread::scope(|s| {
let handles: Vec<_> = (0..N)
.map(|_| {
s.spawn(|| {
barrier.wait();
1
})
})
.collect();
barrier.wait(); // so all N threads were alive at the same time
handles.into_iter().map(|h| h.join().unwrap()).sum()
});
println!("threads alive at once: {N}, each added 1, total: {total}");
assert_eq!(total, N);
}
$ cargo run
threads alive at once: 10000, each added 1, total: 10000

That is a count, not a cost: nothing here measures time or memory, and it says nothing about 100,000. Each thread has a stack of its own, and the operating system can refuse another thread. std has no thread pool; crates provide them. Programs that wait on huge numbers of connections usually use async tasks (Async Basics).

Thread, scope, channel, lock or async?

Five tools, with no claim about speed:

The job Reach for
run something, then use its result spawn and join()
several threads read or change local data, all finished before the function returns thread::scope
a thread produces values that others consume a channel (Channels)
several threads read or change one value that outlives the function Arc<Mutex<T>> or an atomic (Shared state)
many tasks that mostly wait for I/O async tasks (Async Basics)

Translating from what you know

Practice

Try it

Replace the todo!() calls and run until each prints all checks passed. The checks record which thread ran each piece, and where it sits in memory, in a static Mutex (the next lesson; ignore it for now), so doing the work on the main thread fails, and so does copying the data. They cannot tell parallel from one-after-another without a clock, and they use none.

One thread per input

Write run_all: call work on a new thread per input and give back one entry per input, in input order: Ok(result), or Err(message) if that thread panicked. Two inputs panic, with two kinds of payload; the others must still be answered.

Starterexercises/divide-all.rs
#![forbid(unsafe_code)]
use std::sync::Mutex;
use std::thread::{self, ThreadId};
// The checks use this to record which thread ran each call. Ignore it for now.
static SEEN: Mutex<Vec<ThreadId>> = Mutex::new(Vec::new());
/// The work for one input. It records which thread ran it. It panics on 0 (with fixed text)
/// and on 7 (with a message built from the input).
fn work(n: i32) -> i32 {
SEEN.lock().unwrap().push(thread::current().id());
assert!(n != 7, "unlucky input {n}");
100 / n
}
/// Calls `work` for every input, each call on its own new thread. Gives back one entry per
/// input, in the order of `inputs`: `Ok(result)`, or `Err(message)` if that call panicked.
fn run_all(inputs: Vec<i32>) -> Vec<Result<i32, String>> {
todo!(
"keep the JoinHandles, then join them in order; a failed join holds the panic's payload, which you can ask for a &str or a String (see 'A thread that panics')"
)
}
fn main() {
let got = run_all(vec![5, 0, 7, 20]);
assert_eq!(
got,
vec![
Ok(20),
Err("attempt to divide by zero".to_string()),
Err("unlucky input 7".to_string()),
Ok(5)
],
"one entry per input, in input order; a panic must not stop the others"
);
// every call ran on a thread of its own, and not on main's
{
let seen = SEEN.lock().unwrap();
assert_eq!(seen.len(), 4);
let mut ids: Vec<_> = seen.iter().map(|id| format!("{id:?}")).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 4, "each input needs its own thread");
assert!(!seen.contains(&thread::current().id()), "not on main");
}
assert_eq!(run_all(vec![]), Vec::<Result<i32, String>>::new());
assert_eq!(run_all(vec![4]), vec![Ok(25)]);
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/divide-all.solution.rsRun ↗
#![forbid(unsafe_code)]
use std::sync::Mutex;
use std::thread::{self, ThreadId};
// The checks use this to record which thread ran each call. Ignore it for now.
static SEEN: Mutex<Vec<ThreadId>> = Mutex::new(Vec::new());
/// The work for one input. It records which thread ran it. It panics on 0 (with fixed text)
/// and on 7 (with a message built from the input).
fn work(n: i32) -> i32 {
SEEN.lock().unwrap().push(thread::current().id());
assert!(n != 7, "unlucky input {n}");
100 / n
}
/// Calls `work` for every input, each call on its own new thread. Gives back one entry per
/// input, in the order of `inputs`: `Ok(result)`, or `Err(message)` if that call panicked.
fn run_all(inputs: Vec<i32>) -> Vec<Result<i32, String>> {
let handles: Vec<_> = inputs
.into_iter()
.map(|n| thread::spawn(move || work(n)))
.collect();
handles
.into_iter()
.map(|h| {
h.join().map_err(|payload| {
if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
}
})
})
.collect()
}
fn main() {
let got = run_all(vec![5, 0, 7, 20]);
assert_eq!(
got,
vec![
Ok(20),
Err("attempt to divide by zero".to_string()),
Err("unlucky input 7".to_string()),
Ok(5)
],
"one entry per input, in input order; a panic must not stop the others"
);
// every call ran on a thread of its own, and not on main's
{
let seen = SEEN.lock().unwrap();
assert_eq!(seen.len(), 4);
let mut ids: Vec<_> = seen.iter().map(|id| format!("{id:?}")).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 4, "each input needs its own thread");
assert!(!seen.contains(&thread::current().id()), "not on main");
}
assert_eq!(run_all(vec![]), Vec::<Result<i32, String>>::new());
assert_eq!(run_all(vec![4]), vec![Ok(25)]);
println!("all checks passed");
}
$ cargo run
thread '<unnamed>' (1) panicked at src/main.rs:14:5:
attempt to divide by zero
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread '<unnamed>' (1) panicked at src/main.rs:13:5:
unlucky input 7
all checks passed

Borrow the chunks

Write chunk_results: cut a borrowed slice into chunks, run f (a plain fn, no captures) on each chunk on its own thread, and give back the results in chunk order. The checks compare chunk addresses with data’s, so a copy fails. Empty input gives no chunks.

Starterexercises/chunk-sums.rs
#![forbid(unsafe_code)]
use std::sync::Mutex;
use std::thread::{self, ThreadId};
// The checks use this to record which thread ran each chunk, and where the chunk starts in
// memory. Ignore it for now.
static SEEN: Mutex<Vec<(ThreadId, usize)>> = Mutex::new(Vec::new());
/// The work for one chunk. It records which thread ran it and the chunk's address.
fn total(chunk: &[u64]) -> u64 {
SEEN.lock()
.unwrap()
.push((thread::current().id(), chunk.as_ptr() as usize));
chunk.iter().sum()
}
/// Cuts `data` into chunks of `ceil(len / parts)` elements (so at most `parts` chunks, and
/// none when `data` is empty), calls `f` on each chunk on its own thread, and gives back the
/// results in chunk order. `data` is borrowed, never copied. `parts` is at least 1.
/// `f` is a plain function (`fn` pointer): it cannot capture anything, so any thread may call it.
fn chunk_results(data: &[u64], parts: usize, f: fn(&[u64]) -> u64) -> Vec<u64> {
todo!(
"chunks() panics on a chunk size of 0, so handle empty data first; the threads must borrow their chunks from `data`, not copy them"
)
}
fn main() {
let data: Vec<u64> = (1..=10).collect();
assert_eq!(chunk_results(&data, 3, total), vec![10, 26, 19]); // [1..=4] [5..=8] [9, 10]
{
let seen = SEEN.lock().unwrap();
let mut ids: Vec<_> = seen.iter().map(|(id, _)| format!("{id:?}")).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 3, "each chunk needs its own thread");
assert!(
!seen.iter().any(|(id, _)| *id == thread::current().id()),
"not on main"
);
// borrowed, not copied: each chunk starts inside `data`'s own memory,
// 4 elements (32 bytes) after the one before
let start = data.as_ptr() as usize;
let mut offsets: Vec<usize> = seen.iter().map(|(_, p)| p.wrapping_sub(start)).collect();
offsets.sort();
assert_eq!(
offsets,
vec![0, 32, 64],
"the chunks must be slices of `data`"
);
}
assert_eq!(chunk_results(&data, 1, total), vec![55]);
assert_eq!(
chunk_results(&data, 20, total),
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
);
assert_eq!(chunk_results(&[], 4, total), Vec::<u64>::new());
// the data is still ours afterwards, and the order of results is the order of chunks
assert_eq!(data.len(), 10);
let firsts = chunk_results(&data, 5, |c| c[0]);
assert_eq!(firsts, vec![1, 3, 5, 7, 9]);
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/chunk-sums.solution.rsRun ↗
#![forbid(unsafe_code)]
use std::sync::Mutex;
use std::thread::{self, ThreadId};
// The checks use this to record which thread ran each chunk, and where the chunk starts in
// memory. Ignore it for now.
static SEEN: Mutex<Vec<(ThreadId, usize)>> = Mutex::new(Vec::new());
/// The work for one chunk. It records which thread ran it and the chunk's address.
fn total(chunk: &[u64]) -> u64 {
SEEN.lock()
.unwrap()
.push((thread::current().id(), chunk.as_ptr() as usize));
chunk.iter().sum()
}
/// Cuts `data` into chunks of `ceil(len / parts)` elements (so at most `parts` chunks, and
/// none when `data` is empty), calls `f` on each chunk on its own thread, and gives back the
/// results in chunk order. `data` is borrowed, never copied. `parts` is at least 1.
/// `f` is a plain function (`fn` pointer): it cannot capture anything, so any thread may call it.
fn chunk_results(data: &[u64], parts: usize, f: fn(&[u64]) -> u64) -> Vec<u64> {
if data.is_empty() {
return Vec::new();
}
let size = data.len().div_ceil(parts);
thread::scope(|s| {
let handles: Vec<_> = data
.chunks(size)
.map(|chunk| s.spawn(move || f(chunk)))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
})
}
fn main() {
let data: Vec<u64> = (1..=10).collect();
assert_eq!(chunk_results(&data, 3, total), vec![10, 26, 19]); // [1..=4] [5..=8] [9, 10]
{
let seen = SEEN.lock().unwrap();
let mut ids: Vec<_> = seen.iter().map(|(id, _)| format!("{id:?}")).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 3, "each chunk needs its own thread");
assert!(
!seen.iter().any(|(id, _)| *id == thread::current().id()),
"not on main"
);
// borrowed, not copied: each chunk starts inside `data`'s own memory,
// 4 elements (32 bytes) after the one before
let start = data.as_ptr() as usize;
let mut offsets: Vec<usize> = seen.iter().map(|(_, p)| p.wrapping_sub(start)).collect();
offsets.sort();
assert_eq!(
offsets,
vec![0, 32, 64],
"the chunks must be slices of `data`"
);
}
assert_eq!(chunk_results(&data, 1, total), vec![55]);
assert_eq!(
chunk_results(&data, 20, total),
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
);
assert_eq!(chunk_results(&[], 4, total), Vec::<u64>::new());
// the data is still ours afterwards, and the order of results is the order of chunks
assert_eq!(data.len(), 10);
let firsts = chunk_results(&data, 5, |c| c[0]);
assert_eq!(firsts, vec![1, 3, 5, 7, 9]);
println!("all checks passed");
}
$ cargo run
all checks passed

A piece each to change

Write update_parts: the same with &mut, changing the data in place. Two threads cannot both hold &mut data: a first draft that borrows the whole slice fails with E0499.

Starterexercises/update-parts.rs
#![forbid(unsafe_code)]
use std::sync::Mutex;
use std::thread::{self, ThreadId};
// The checks use this to record which thread ran each part, and where the part starts in
// memory. Ignore it for now.
static SEEN: Mutex<Vec<(ThreadId, usize)>> = Mutex::new(Vec::new());
/// The work for one part: add the part's length to each element. It records its thread and
/// the part's address.
fn add_len(part: &mut [i32]) {
SEEN.lock()
.unwrap()
.push((thread::current().id(), part.as_ptr() as usize));
let n = part.len() as i32;
for x in part.iter_mut() {
*x += n;
}
}
/// Cuts `data` into parts of `ceil(len / parts)` elements and calls `f` on each part on its
/// own thread. The parts are slices of `data` itself: nothing is copied, `data` changes in
/// place. `parts` is at least 1.
fn update_parts(data: &mut [i32], parts: usize, f: fn(&mut [i32])) {
todo!(
"two threads cannot hold &mut to the whole slice (E0499): cut it into pieces that do not overlap, and borrow one piece per thread"
)
}
fn main() {
let mut data = vec![0; 7];
update_parts(&mut data, 3, add_len);
assert_eq!(data, vec![3, 3, 3, 3, 3, 3, 1]); // parts of 3, 3 and 1
{
let seen = SEEN.lock().unwrap();
let mut ids: Vec<_> = seen.iter().map(|(id, _)| format!("{id:?}")).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 3, "each part needs its own thread");
assert!(
!seen.iter().any(|(id, _)| *id == thread::current().id()),
"not on main"
);
// changed in place: each part starts inside `data`'s own memory,
// 3 elements (12 bytes) after the one before
let start = data.as_ptr() as usize;
let mut offsets: Vec<usize> = seen.iter().map(|(_, p)| p.wrapping_sub(start)).collect();
offsets.sort();
assert_eq!(
offsets,
vec![0, 12, 24],
"the parts must be slices of `data`"
);
}
let before = data.as_ptr();
update_parts(&mut data, 2, |p| p.reverse());
assert_eq!(data.as_ptr(), before);
assert_eq!(
data,
vec![3, 3, 3, 3, 1, 3, 3],
"each part reversed on its own"
);
let mut empty: Vec<i32> = Vec::new();
update_parts(&mut empty, 4, add_len);
assert!(empty.is_empty());
println!("all checks passed");
}

Open in the Rust Playground ↗Run it first: it stops at todo!() with not yet implemented and a hint, and the compiler warns that the parameter is unused. Both are expected. Replace the todo!() with your code and run it again until it prints all checks passed.

Show a solution
One solutionexercises/update-parts.solution.rsRun ↗
#![forbid(unsafe_code)]
use std::sync::Mutex;
use std::thread::{self, ThreadId};
// The checks use this to record which thread ran each part, and where the part starts in
// memory. Ignore it for now.
static SEEN: Mutex<Vec<(ThreadId, usize)>> = Mutex::new(Vec::new());
/// The work for one part: add the part's length to each element. It records its thread and
/// the part's address.
fn add_len(part: &mut [i32]) {
SEEN.lock()
.unwrap()
.push((thread::current().id(), part.as_ptr() as usize));
let n = part.len() as i32;
for x in part.iter_mut() {
*x += n;
}
}
/// Cuts `data` into parts of `ceil(len / parts)` elements and calls `f` on each part on its
/// own thread. The parts are slices of `data` itself: nothing is copied, `data` changes in
/// place. `parts` is at least 1.
fn update_parts(data: &mut [i32], parts: usize, f: fn(&mut [i32])) {
if data.is_empty() {
return;
}
let size = data.len().div_ceil(parts);
thread::scope(|s| {
for part in data.chunks_mut(size) {
s.spawn(move || f(part));
}
});
}
fn main() {
let mut data = vec![0; 7];
update_parts(&mut data, 3, add_len);
assert_eq!(data, vec![3, 3, 3, 3, 3, 3, 1]); // parts of 3, 3 and 1
{
let seen = SEEN.lock().unwrap();
let mut ids: Vec<_> = seen.iter().map(|(id, _)| format!("{id:?}")).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 3, "each part needs its own thread");
assert!(
!seen.iter().any(|(id, _)| *id == thread::current().id()),
"not on main"
);
// changed in place: each part starts inside `data`'s own memory,
// 3 elements (12 bytes) after the one before
let start = data.as_ptr() as usize;
let mut offsets: Vec<usize> = seen.iter().map(|(_, p)| p.wrapping_sub(start)).collect();
offsets.sort();
assert_eq!(
offsets,
vec![0, 12, 24],
"the parts must be slices of `data`"
);
}
let before = data.as_ptr();
update_parts(&mut data, 2, |p| p.reverse());
assert_eq!(data.as_ptr(), before);
assert_eq!(
data,
vec![3, 3, 3, 3, 1, 3, 3],
"each part reversed on its own"
);
let mut empty: Vec<i32> = Vec::new();
update_parts(&mut empty, 4, add_len);
assert!(empty.is_empty());
println!("all checks passed");
}
$ cargo run
all checks passed

Read more