Concurrency · about 40 min

Threads: Running Code at the Same Time

Why this matters

Modern CPUs have many cores, and a program that uses only one leaves most of the machine idle. Threads let one program do several things at once: split a computation across cores, keep a user interface responsive while working, handle many network connections. Concurrency is also notoriously error-prone; data races and their heisenbugs have cost the industry dearly. Rust's ownership rules extend to threads, so the most common concurrency bugs become compile errors: this is what people call "fearless concurrency".

Processes, threads and the scheduler

A process is a running program with its own memory. A thread is a sequence of execution inside a process; all threads of a process share the same memory (the heap and global data), and each has its own stack. The operating system scheduler decides which threads run on which cores, switching between them many times per second. You do not control the interleaving, and it can differ on every run.

 process memory (shared): code | static data | heap
 thread 1: own stack, own registers   -- runs on core 0
 thread 2: own stack, own registers   -- runs on core 1, or interleaved with thread 1

Spawning and joining

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        let mut sum = 0u64;
        for i in 1..=1_000 {
            sum += i;
        }
        sum
    });
    println!("main keeps working while the thread runs");
    let result = handle.join().unwrap();
    println!("thread computed {result}");
}
main keeps working while the thread runs
thread computed 500500

Order is not guaranteed

use std::thread;

fn main() {
    let mut handles = Vec::new();
    for id in 1..=3 {
        handles.push(thread::spawn(move || id * 100));
    }
    let results: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    println!("{:?}", results);
}
[100, 200, 300]

If each thread printed directly, the lines could come out in any order. Here the threads only compute; main joins them in spawn order and prints once, so the output is deterministic. This is a good general habit: let threads compute and let one place produce output.

move closures and ownership

A spawned thread can outlive the function that created it, so the compiler will not let its closure borrow local variables:

use std::thread;

fn main() {
    let data = vec![1, 2, 3];
    let handle = thread::spawn(|| {
        println!("{:?}", data);
    });
    handle.join().unwrap();
}
error[E0373]: closure may outlive the current function, but it borrows `data`, which is owned by the current function
 --> main.rs:5:32
  |
5 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `data`
6 |         println!("{:?}", data);
  |                          ---- `data` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> main.rs:5:18
  |
5 |       let handle = thread::spawn(|| {
  |  __________________^
6 | |         println!("{:?}", data);
7 | |     });
  | |______^
help: to force the closure to take ownership of `data` (and any other referenced variables), use the `move` keyword
  |
5 |     let handle = thread::spawn(move || {
  |                                ++++

E0373 (from module 11) again, for the same reason: data might be dropped while the thread still uses it. move transfers ownership into the thread:

use std::thread;

fn main() {
    let data = vec![1, 2, 3];
    let handle = thread::spawn(move || {
        let total: i32 = data.iter().sum();
        println!("thread owns {:?}, sum {total}", data);
    });
    handle.join().unwrap();
}
thread owns [1, 2, 3], sum 6

After the move, main can no longer use data; the ownership rules make it impossible for two threads to touch the same Vec unsynchronized. thread::spawn requires its closure to be 'static (own all its data, module 10) and Send (lesson 4).

Splitting work

A typical pattern: divide data into chunks, give each thread its own chunk, combine the results.

use std::thread;

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

fn main() {
    let limit = 200_000u64;
    let workers = 4;
    let step = limit / workers;
    let mut handles = Vec::new();
    for w in 0..workers {
        let start = w * step;
        let end = if w == workers - 1 { limit } else { start + step };
        handles.push(thread::spawn(move || (start..end).filter(|&n| is_prime(n)).count()));
    }
    let counts: Vec<usize> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    println!("per thread: {:?}", counts);
    println!("primes below {limit}: {}", counts.iter().sum::<usize>());
}
per thread: [5133, 4459, 4256, 4136]
primes below 200000: 17984

Each thread gets plain numbers (start, end, which are Copy and moved into the closure) and returns a count. No data is shared, so nothing needs synchronizing. On a 4-core machine this runs close to four times faster than one thread. (Thread creation costs on the order of tens of microseconds, so splitting tiny jobs is not worth it.)

Common mistakes

Recap

Going further: call std::thread::available_parallelism() to see how many cores the sandbox reports, and use it to choose the number of workers. The Rust Book chapter 16.1 covers the same ground.

Exercises

Exercise 1 · write code

Split a sum across threads

Read integers from stdin (one per line) into a Vec<i64>. Split it into 4 roughly equal chunks, sum each chunk in its own spawned thread (move an owned Vec<i64> chunk into each thread), collect the partial sums by joining the threads in order, and print them and the total:

partial sums: [3, 7, 11, 15]
total: 36

for the numbers 1 to 8. Use chunks((len + 3) / 4) to split (with fewer than 4 numbers there are fewer chunks; for empty input print partial sums: [] and total: 0).

File: main.rs

Starting point

use std::io;
use std::thread;

fn main() {
    let nums: Vec<i64> = io::stdin().lines().map(|l| l.unwrap().trim().parse().unwrap()).collect();
}

Example

input

1
2
3
4
5
6
7
8

expected output

partial sums: [3, 7, 11, 15]
total: 36

Example

input

10
20
30
40
50

expected output

partial sums: [30, 70, 50]
total: 150
Hint 1

chunks(0) panics, so make the chunk size at least 1: ((len + 3) / 4).max(1).

Hint 2

For each chunk: let part = chunk.to_vec(); handles.push(thread::spawn(move || part.iter().sum::<i64>()));

Hint 3

Join in order: handles.into_iter().map(|h| h.join().unwrap()).collect::<Vec<i64>>().

Reference solution
use std::io;
use std::thread;

fn main() {
    let nums: Vec<i64> = io::stdin().lines().map(|l| l.unwrap().trim().parse().unwrap()).collect();
    let size = ((nums.len() + 3) / 4).max(1);
    let mut handles = Vec::new();
    for chunk in nums.chunks(size) {
        let part = chunk.to_vec();
        handles.push(thread::spawn(move || part.iter().sum::<i64>()));
    }
    let partials: Vec<i64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    let total: i64 = partials.iter().sum();
    println!("partial sums: {:?}", partials);
    println!("total: {total}");
}

Exercise 2 · write code

Fix the thread closure

This program fails with E0373: the spawned closure borrows name, but the thread might outlive main's use of it. Fix it so it compiles and prints:

hello from the thread, Ada
thread returned 3

File: main.rs

Starting point

use std::thread;

fn main() {
    let name = String::from("Ada");
    let handle = thread::spawn(|| {
        println!("hello from the thread, {name}");
        name.len()
    });
    let n = handle.join().unwrap();
    println!("thread returned {n}");
}

Example

expected output

hello from the thread, Ada
thread returned 3
Hint 1

Add move before the closure so the thread owns name.

Reference solution
use std::thread;

fn main() {
    let name = String::from("Ada");
    let handle = thread::spawn(move || {
        println!("hello from the thread, {name}");
        name.len()
    });
    let n = handle.join().unwrap();
    println!("thread returned {n}");
}

Exercise 3 · quiz

In which order?

main spawns three threads that each print their number (1, 2, 3), then joins all three. In what order are the lines printed?

Answer
  • Any order; it depends on how the operating system schedules the threads, and can differ between runs

Threads run concurrently and the OS scheduler decides who runs when. The order of output from different threads is not defined. When order matters, collect the results and print them in order after joining (as in the first exercise), or coordinate the threads explicitly.