ZetCode

Rust threads

last modified November 13, 2026

In this article we show how to work with threads in Rust.

A thread is an independent path of execution within a process. Threads of one process share its memory, so they can work on the same data in parallel. This makes programs faster on multi-core processors, but it also brings the classic problem of concurrent programming: a data race, when two threads access the same memory at the same time and at least one of them writes.

Rust prevents data races at compile time. The ownership rules and two marker traits, Send (a value can be moved to another thread) and Sync (a value can be shared between threads by reference), let the compiler reject unsafe sharing. Rust's standard library uses native operating system threads; each Rust thread is one OS thread.

The examples were tested with Rust 1.97 and the 2024 edition. They are run as Cargo projects with cargo run -q. Several examples print output in an order that changes from run to run.

Spawning a thread

The thread::spawn function starts a new thread that runs the given closure.

main.rs
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));
        }
    });

    for i in 1..=3 {
        println!("main: step {i}");
        thread::sleep(Duration::from_millis(10));
    }

    handle.join().unwrap();
    println!("both threads finished");
}

The new thread and the main thread both print three lines.

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

The spawn function returns at once with a JoinHandle. From then on, the two threads run concurrently. The thread::sleep calls pause the current thread and give the other one a chance to run.

handle.join().unwrap();

The join method blocks until the thread finishes. It returns a Result, which is Err if the thread panicked. The operating system scheduler decides when each thread runs, so the lines may come in a different order on another run.

$ cargo run -q
main: step 1
worker: step 1
main: step 2
worker: step 2
worker: step 3
main: step 3
both threads finished
$ cargo run -q
main: step 1
worker: step 1
main: step 2
worker: step 2
main: step 3
worker: step 3
both threads finished

The main thread does not wait

When the main thread ends, the whole process ends, including all threads that are still running.

main.rs
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(10));
        }
    });

    thread::sleep(Duration::from_millis(25));
    println!("main is done");
}

The worker needs about 50 milliseconds for its five steps, but main sleeps for 25 milliseconds only and does not join the thread. The remaining steps never run. The handle returned by spawn is simply dropped, which detaches the thread.

$ cargo run -q
worker: step 1
worker: step 2
worker: step 3
main is done

Returning a value

The closure's return value is passed to the thread that calls join.

main.rs
use std::thread;

fn count_primes(from: u64, to: u64) -> usize {
    (from..to)
        .filter(|&n| n > 1 && (2..).take_while(|d| d * d <= n).all(|d| !n.is_multiple_of(d)))
        .count()
}

fn main() {
    let low = thread::spawn(|| count_primes(0, 50_000));
    let high = thread::spawn(|| count_primes(50_000, 100_000));

    let a = low.join().unwrap();
    let b = high.join().unwrap();

    println!("primes below 50,000: {a}");
    println!("primes from 50,000 to 100,000: {b}");
    println!("total: {}", a + b);
}

Two threads count primes in two ranges.

let low = thread::spawn(|| count_primes(0, 50_000));

The type of low is JoinHandle<usize>, inferred from the closure.

let a = low.join().unwrap();
let b = high.join().unwrap();

The join method returns Ok(usize) with the closure's result. Both threads run in parallel, while the main thread waits for the first one and then for the second.

$ cargo run -q
primes below 50,000: 5133
primes from 50,000 to 100,000: 4459
total: 9592

Borrowing in a thread

A thread created with spawn may run longer than the function that created it. Its closure therefore must not borrow local variables.

main.rs
use std::thread;

fn main() {
    let words = vec!["sky", "cloud", "rain"];

    let handle = thread::spawn(|| {
        println!("{} words", words.len());
    });

    handle.join().unwrap();
}

The closure uses words by reference. The compiler cannot know whether the thread finishes before main drops the vector, so it reports error E0373. The spawn function requires a closure that is 'static, which means it owns all the data it uses. The help text suggests the fix.

$ cargo run -q
error[E0373]: closure may outlive the current function, but it borrows `words`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `words`
7 |         println!("{} words", words.len());
  |                              ----- `words` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("{} words", words.len());
8 | |     });
  | |______^
help: to force the closure to take ownership of `words` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.

Moving data into a thread

The move keyword makes the closure take ownership of the captured variables.

main.rs
use std::thread;

fn main() {
    let words = vec!["sky", "cloud", "rain"];
    let limit = 4;

    let handle = thread::spawn(move || {
        let short: Vec<_> = words.into_iter().filter(|w| w.len() <= limit).collect();
        short
    });

    let short = handle.join().unwrap();
    println!("short words: {short:?}");
    println!("limit is still usable: {limit}");
}

The vector is moved into the thread. After the move, main can no longer use words; the thread owns it and consumes it with into_iter.

let short = handle.join().unwrap();
println!("short words: {short:?}");
println!("limit is still usable: {limit}");

The result is handed back through join. The limit variable is an integer, which implements Copy; the closure moved a copy of it, so the original remains usable.

$ cargo run -q
short words: ["sky", "rain"]
limit is still usable: 4

Many threads

Threads are often spawned in a loop and their handles collected in a vector.

main.rs
use std::thread;

fn main() {
    let handles: Vec<_> = (1..=5)
        .map(|id| {
            thread::spawn(move || {
                let sum: u64 = (1..=id * 1000).sum();
                println!("thread {id} computed {sum}");
                sum
            })
        })
        .collect();

    let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
    println!("total: {total}");
}

Five threads compute sums of different lengths. The move keyword gives each closure its own copy of id.

let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();

We join the threads in the order they were created and add up their results. The total is always the same, but the order of the printed lines is not, as the two runs show.

$ cargo run -q
thread 1 computed 500500
thread 2 computed 2001000
thread 3 computed 4501500
thread 4 computed 8002000
thread 5 computed 12502500
total: 27507500
$ cargo run -q
thread 1 computed 500500
thread 2 computed 2001000
thread 4 computed 8002000
thread 3 computed 4501500
thread 5 computed 12502500
total: 27507500

Thread names and the Builder

The thread::Builder type configures a thread before it is spawned: its name and its stack size.

main.rs
use std::thread;

fn main() {
    let main_thread = thread::current();
    println!("{:?} {:?}", main_thread.name(), main_thread.id());

    let handle = thread::Builder::new()
        .name("loader".to_string())
        .stack_size(64 * 1024)
        .spawn(|| {
            let me = thread::current();
            println!("{:?} {:?}", me.name(), me.id());
        })
        .unwrap();

    handle.join().unwrap();

    let unnamed = thread::spawn(|| thread::current().name().map(String::from));
    println!("unnamed thread: {:?}", unnamed.join().unwrap());

    println!("available parallelism: {}", thread::available_parallelism().unwrap());
}

The thread::current function returns a handle to the running thread. Each thread has a unique ThreadId; the name is optional.

let handle = thread::Builder::new()
    .name("loader".to_string())
    .stack_size(64 * 1024)
    .spawn(|| {

Unlike thread::spawn, the builder's spawn returns a Result, because the operating system may fail to create the thread. Names appear in panic messages and in debuggers. Threads created with thread::spawn have no name.

thread::available_parallelism().unwrap()

The available_parallelism function estimates how many threads can run in parallel, usually the number of logical CPU cores.

$ cargo run -q
Some("main") ThreadId(1)
Some("loader") ThreadId(2)
unnamed thread: None
available parallelism: 8

Panics in threads

A panic in a spawned thread ends only that thread. The parent learns about it from join.

main.rs
use std::thread;

fn parse_all(items: Vec<&'static str>) -> Vec<i32> {
    items.iter().map(|s| s.parse().unwrap()).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(_) => println!("bad: the thread panicked"),
    }

    println!("main continues");
}

The second thread fails to parse "five" and panics. The panic message is printed on standard error and names the thread, here <unnamed>.

match bad.join() {
    Ok(v) => println!("bad: {v:?}"),
    Err(_) => println!("bad: the thread panicked"),
}

The join method returns Err with the panic payload. The main thread handles the error and continues. Calling unwrap on the result would propagate the panic to the main thread instead.

$ cargo run -q

thread '<unnamed>' (120644) panicked at src/main.rs:4:36:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
good: Ok([1, 2, 3])
bad: the thread panicked
main continues

Scoped threads

The thread::scope function creates a scope for threads that are guaranteed to finish before the scope ends. Such threads can borrow local variables, so no move and no Arc is needed.

main.rs
use std::thread;

fn main() {
    let numbers: Vec<u64> = (1..=1_000_000).collect();
    let mut stats = Vec::new();

    thread::scope(|s| {
        let evens = s.spawn(|| numbers.iter().filter(|&&n| n % 2 == 0).count());
        let max = s.spawn(|| numbers.iter().max().copied());

        stats.push(("evens", evens.join().unwrap() as u64));
        stats.push(("max", max.join().unwrap().unwrap_or(0)));
    });

    println!("numbers: {}", numbers.len());
    for (name, value) in &stats {
        println!("{name}: {value}");
    }
}

Two threads read the same vector of a million numbers at the same time.

thread::scope(|s| {
    let evens = s.spawn(|| numbers.iter().filter(|&&n| n % 2 == 0).count());
    let max = s.spawn(|| numbers.iter().max().copied());

Threads are spawned with the scope's spawn method. Both closures borrow numbers by a shared reference, which is allowed for any number of threads. The closures also need no 'static bound.

stats.push(("evens", evens.join().unwrap() as u64));

The main thread can mutate stats inside the scope, because no spawned thread uses it. At the end of the scope, all threads that were not joined are joined automatically, and the borrows end. After the scope, numbers can be used again.

$ cargo run -q
numbers: 1000000
evens: 500000
max: 1000000

Splitting work into chunks

A common pattern is to split a slice into chunks and process each chunk in its own thread.

main.rs
use std::thread;
use std::time::Instant;

fn is_prime(n: u64) -> bool {
    n > 1 && (2..).take_while(|d| d * d <= n).all(|d| !n.is_multiple_of(d))
}

fn count_parallel(numbers: &[u64], threads: usize) -> usize {
    let chunk = numbers.len().div_ceil(threads);

    thread::scope(|s| {
        let handles: Vec<_> = numbers
            .chunks(chunk)
            .map(|part| s.spawn(|| part.iter().filter(|&&n| is_prime(n)).count()))
            .collect();

        handles.into_iter().map(|h| h.join().unwrap()).sum()
    })
}

fn main() {
    let numbers: Vec<u64> = (0..3_000_000).collect();

    for threads in [1, 2, 4, 8] {
        let start = Instant::now();
        let count = count_parallel(&numbers, threads);
        println!("{threads} threads: {count} primes in {:.2?}", start.elapsed());
    }
}

The program counts primes below three million with one, two, four, and eight threads.

let chunk = numbers.len().div_ceil(threads);

The chunk size is rounded up, so the chunks cover the whole slice.

numbers
    .chunks(chunk)
    .map(|part| s.spawn(|| part.iter().filter(|&&n| is_prime(n)).count()))
    .collect();

Each scoped thread receives a sub-slice. The handles are collected first, so that all threads start before the first join; joining inside the same map would run them one after another.

We run the optimized build. The result is the same for every thread count; the time drops with more threads, but the gain shrinks once the threads outnumber the four physical cores of the test machine; its eight hardware threads share those cores. The chunks are also uneven: larger numbers take longer to test, so the thread with the last chunk finishes last.

$ cargo run -q --release
1 threads: 216816 primes in 475.20ms
2 threads: 216816 primes in 302.03ms
4 threads: 216816 primes in 185.17ms
8 threads: 216816 primes in 165.37ms

Shared state with Mutex

To change shared data from several threads, the data is protected by a Mutex (mutual exclusion lock). Only one thread at a time can hold the lock. With thread::spawn, the mutex is shared through an Arc, an atomically reference-counted pointer.

main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..8 {
        let counter = Arc::clone(&counter);

        handles.push(thread::spawn(move || {
            for _ in 0..10_000 {
                *counter.lock().unwrap() += 1;
            }
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("counter: {}", *counter.lock().unwrap());
}

Eight threads increment one counter ten thousand times each.

let counter = Arc::clone(&counter);

Each thread gets its own Arc pointing to the same mutex. The Rc type would not compile here, because its reference count is not thread-safe; see Rust smart pointers.

*counter.lock().unwrap() += 1;

The lock method blocks until the lock is free and returns a MutexGuard, which dereferences to the protected value. The lock is released when the guard is dropped, here at the end of the statement. The data can be reached only through the guard, so it cannot be accessed without locking. Without the mutex, the program would not compile.

$ cargo run -q
counter: 80000

Mutex with scoped threads

With scoped threads, a mutex can be borrowed; no Arc is needed.

main.rs
use std::collections::HashMap;
use std::sync::Mutex;
use std::thread;

fn main() {
    let text = "the sky is blue the grass is green the sun is up";
    let words: Vec<&str> = text.split_whitespace().collect();
    let freq = Mutex::new(HashMap::new());

    thread::scope(|s| {
        for part in words.chunks(4) {
            let freq = &freq;
            s.spawn(move || {
                for word in part {
                    *freq.lock().unwrap().entry(*word).or_insert(0) += 1;
                }
            });
        }
    });

    let freq = freq.into_inner().unwrap();
    let mut pairs: Vec<_> = freq.into_iter().filter(|&(_, n)| n > 1).collect();
    pairs.sort();

    println!("{pairs:?}");
}

Three threads count words in parts of a text and record the counts in a shared map.

let freq = &freq;
s.spawn(move || {

The closure must take part by value, because the chunk variable belongs to the loop iteration, so we use move. To move only a reference to the mutex, not the mutex itself, we shadow freq with a reference before the closure.

let freq = freq.into_inner().unwrap();

After the scope, no thread uses the mutex any more. The into_inner method consumes the mutex and returns the map without locking.

$ cargo run -q
[("is", 3), ("the", 3)]

RwLock

A RwLock (read-write lock) allows any number of readers or one writer at a time. It suits data that is read often and changed rarely.

main.rs
use std::sync::RwLock;
use std::thread;
use std::time::Duration;

fn main() {
    let config = RwLock::new(String::from("v1"));

    thread::scope(|s| {
        for id in 1..=3 {
            let config = &config;
            s.spawn(move || {
                for _ in 0..3 {
                    let value = config.read().unwrap();
                    println!("reader {id} sees {value}");
                    drop(value);
                    thread::sleep(Duration::from_millis(15));
                }
            });
        }

        s.spawn(|| {
            thread::sleep(Duration::from_millis(20));
            *config.write().unwrap() = String::from("v2");
            println!("writer updated config");
        });
    });

    println!("final: {}", config.read().unwrap());
}

Three readers check a configuration value while a writer updates it once.

let value = config.read().unwrap();
println!("reader {id} sees {value}");
drop(value);

The read method returns a read guard. Several readers can hold read guards at once. We drop the guard before sleeping, so the writer is not blocked longer than necessary.

*config.write().unwrap() = String::from("v2");

The write method waits until all read guards are released and then gives exclusive access.

$ cargo run -q
reader 1 sees v1
reader 2 sees v1
reader 3 sees v1
reader 1 sees v1
reader 2 sees v1
reader 3 sees v1
writer updated config
reader 1 sees v2
reader 2 sees v2
reader 3 sees v2
final: v2

Atomic types

For simple counters and flags, atomic types are faster than a mutex. They are updated with special processor instructions, without locking.

main.rs
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;

static REQUESTS: AtomicUsize = AtomicUsize::new(0);

fn main() {
    let found = AtomicBool::new(false);

    thread::scope(|s| {
        for id in 0..4 {
            let found = &found;
            s.spawn(move || {
                for n in (id..1_000).step_by(4) {
                    REQUESTS.fetch_add(1, Ordering::Relaxed);
                    if n == 777 {
                        found.store(true, Ordering::Relaxed);
                    }
                }
            });
        }
    });

    println!("requests: {}", REQUESTS.load(Ordering::Relaxed));
    println!("found 777: {}", found.load(Ordering::Relaxed));
}

Four threads go through the numbers below a thousand, each taking every fourth number. They count the processed numbers and set a flag when they find 777.

static REQUESTS: AtomicUsize = AtomicUsize::new(0);

Atomic types can be modified through a shared reference, so they can be used in a plain static without unsafe.

REQUESTS.fetch_add(1, Ordering::Relaxed);

The fetch_add method adds a value as one indivisible operation. The Ordering argument specifies how the operation is ordered relative to other memory accesses. Relaxed guarantees only the atomicity of the operation itself, which is enough for an independent counter. When an atomic flag signals that other data is ready, Release on the store and Acquire on the load are needed.

$ cargo run -q
requests: 1000
found 777: true

Lock poisoning

If a thread panics while holding a mutex, the data may be left in an inconsistent state. The mutex is then marked as poisoned.

main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let balance = Arc::new(Mutex::new(100));
    let shared = Arc::clone(&balance);

    let result = thread::spawn(move || {
        let mut guard = shared.lock().unwrap();
        *guard -= 150;
        if *guard < 0 {
            panic!("negative balance: {}", *guard);
        }
    })
    .join();

    println!("thread finished with error: {}", result.is_err());

    match balance.lock() {
        Ok(value) => println!("balance: {value}"),
        Err(poisoned) => {
            println!("mutex poisoned, last value: {}", poisoned.into_inner());
        }
    }
}

The thread subtracts from the balance, finds it negative, and panics while the guard is still alive.

match balance.lock() {
    Ok(value) => println!("balance: {value}"),
    Err(poisoned) => {
        println!("mutex poisoned, last value: {}", poisoned.into_inner());
    }
}

Every later lock call returns Err. This is why lock is so often followed by unwrap: by default, a panic in one thread spreads to the others that use the same data. The PoisonError still gives access to the data through into_inner, if the program can repair or ignore the state.

$ cargo run -q

thread '<unnamed>' (121522) panicked at src/main.rs:12:13:
negative balance: -50
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread finished with error: true
mutex poisoned, last value: -50

Thread-local storage

The thread_local! macro declares a static variable of which every thread has its own copy.

main.rs
use std::cell::Cell;
use std::thread;

thread_local! {
    static CALLS: Cell<u32> = const { Cell::new(0) };
}

fn work() {
    CALLS.with(|c| c.set(c.get() + 1));
}

fn main() {
    let handles: Vec<_> = (1..=3)
        .map(|n| {
            thread::spawn(move || {
                for _ in 0..n * 10 {
                    work();
                }
                CALLS.with(|c| c.get())
            })
        })
        .collect();

    for (i, h) in handles.into_iter().enumerate() {
        println!("thread {}: {} calls", i + 1, h.join().unwrap());
    }

    work();
    println!("main: {} calls", CALLS.with(|c| c.get()));
}

The work function counts its calls per thread.

thread_local! {
    static CALLS: Cell<u32> = const { Cell::new(0) };
}

Each thread starts with its own counter set to zero. The value is reached through the with method. Since no other thread can see it, a Cell is enough; no lock or atomic is needed. The const block allows a more efficient initialization.

$ cargo run -q
thread 1: 10 calls
thread 2: 20 calls
thread 3: 30 calls
main: 1 calls

One-time initialization

The OnceLock type holds a value that is initialized at most once, even when several threads try at the same time.

main.rs
use std::sync::OnceLock;
use std::thread;

fn config() -> &'static Vec<String> {
    static CONFIG: OnceLock<Vec<String>> = OnceLock::new();

    CONFIG.get_or_init(|| {
        println!("{:?} loads the config", thread::current().name().unwrap_or("?"));
        vec!["debug=false".to_string(), "port=8080".to_string()]
    })
}

fn main() {
    thread::scope(|s| {
        for name in ["a", "b", "c"] {
            thread::Builder::new()
                .name(name.to_string())
                .spawn_scoped(s, || {
                    let cfg = config();
                    assert_eq!(cfg.len(), 2);
                })
                .unwrap();
        }
    });

    println!("{:?}", config());
}

Three named threads call config at the same time.

CONFIG.get_or_init(|| {
    println!("{:?} loads the config", thread::current().name().unwrap_or("?"));
    vec!["debug=false".to_string(), "port=8080".to_string()]
})

The get_or_init method runs the closure in the first thread that gets there; the others wait and then receive a reference to the same value. The message is printed only once, though which thread loads the configuration varies. The spawn_scoped method of the builder creates a named scoped thread.

$ cargo run -q
"a" loads the config
["debug=false", "port=8080"]

Source

The Rust Programming Language: Fearless Concurrency

std::thread module documentation

std::sync module documentation

In this article we have worked with threads in Rust. Passing messages between threads is covered in Rust channels, closures in Rust closures.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.