Module 17: Concurrency
Lecture 15: Tuesday, June 23, 2026.
Code examples
Concurrency and Parallelism
So far every program we have written has been sequential: one statement after another, in a single line of control. Now we look at programs that do more than one thing at once. Two terms come up constantly, and they are not the same:
- Concurrency is about structure: the program is made of independent tasks that can make progress separately, perhaps interleaved on a single core (one chef juggling several dishes).
- Parallelism is about execution: tasks literally run at the same instant on different cores (several chefs, each cooking their own dish).
A program can be concurrent without being parallel, but the usual reason we bother is the other direction: modern machines have many cores, and parallelism lets us finish a big computation faster by spreading the work across them.
Our vehicle for both is the thread. A thread is an independent sequence of execution within your program.
Every program starts with one thread (running main), and we can ask the OS to create more; on a
multi-core machine they can then run in parallel. This raises the two questions the rest of the
module is organized around: how do we give a thread the data it needs, and when threads
share data, how do we keep them from stepping on each other? As we will see, Rust’s ownership
system turns many concurrency bugs into compile errors rather than mysterious crashes.
Threads in Rust
Threads live in the std::thread module. The key function is thread::spawn, which takes a
function (or closure) and runs it in a new thread, alongside the thread that spawned it.
Spawning a Thread
Let’s start with two functions that each print a lot of output:
use std::thread; fn function1() { println!("In function 1"); for _ in 0..10000 { println!("ping"); } println!("function 1 done"); } fn function2() { println!("In function 2"); for _ in 0..10000 { println!("pong"); } println!("function 2 done"); } fn main() { let thread1 = thread::spawn(function1); let thread2 = thread::spawn(function2); }
thread::spawn(function1) starts a new thread and returns right away. It does not wait for
function1 to finish. We now have three threads alive: main plus the two we spawned. Because the
spawned threads run independently, their output is interleaved unpredictably: the order of
pings and pongs changes every run. The OS decides when each thread runs, and we do not control
it.
Waiting for a Thread to Finish: join
Run the program above and you may be surprised: it often ends before either function finishes
printing. The rule to remember is that when main finishes, the whole program exits, even
if other threads are mid-work. Our main reaches its end almost immediately and tears everything
down.
To make main wait, we use the value thread::spawn returns: a JoinHandle with a .join()
method that blocks until that thread finishes.
use std::thread; fn function1() { println!("In function 1"); for _ in 0..100 { println!("ping"); } println!("function 1 done"); } fn function2() { println!("In function 2"); for _ in 0..100 { println!("pong"); } println!("function 2 done"); } fn main() { let thread1 = thread::spawn(function1); let thread2 = thread::spawn(function2); thread1.join().unwrap(); thread2.join().unwrap(); }
Now main waits at each join until that thread is done, so both function 1 done and
function 2 done are guaranteed to print.
Why
.unwrap()? A thread can panic, so.join()returns aResult. Weunwrap()to say “we expect success, crash otherwise.”
A cruder alternative is to make main sleep:
use std::thread; use std::thread::sleep; use std::time::Duration; fn function1() { println!("In function 1"); for _ in 0..100 { println!("ping"); } println!("function 1 done"); } fn main() { let thread1 = thread::spawn(function1); // Give the spawned thread some time to finish before main exits. sleep(Duration::from_secs(1)); }
This only looks correct: it is a guess. If the thread needs longer than a second we cut it off,
and if it finishes sooner we wasted the wait. join waits exactly as long as needed; sleep
only pretends to. In 1-basic.rs you can toggle JOIN and SLEEP to watch the difference.
Passing Data into a Thread
So far our threads took no arguments. Normally a thread needs input (maybe a number, a vector, or some
chunk of data). We cannot write thread::spawn(function1(count)), because that calls function1
now and passes its result (i.e. equivalent to let tmp = function1(count); and then thread::spawn(tmp)).
We need to give spawn something it can call later that already knows the data. That something is a closure.
Closures and Capturing by Move
A closure is an anonymous function written |params| { body }. Unlike a plain function, it can
capture variables from the surrounding scope and use them as if they were its own. (See the
closures chapter of the Rust Book.) Here we
run function1(count) on a new thread, where count lives in main:
use std::thread; fn function1(count: usize) { println!("In function 1"); for _ in 0..count { println!("ping"); } println!("function 1 done"); } fn main() { let count = 10; // A closure taking 0 parameters. It captures `count` from main's scope. // `move` forces the closure to take ownership of what it captures. let f1 = move || { println!("In closure f1"); function1(count); }; let thread1 = thread::spawn(f1); thread1.join().unwrap(); }
The closure f1 takes no parameters but its body uses count, so it carries (or captures) count.
The crucial keyword is move: it makes the closure take ownership of what it
captures rather than borrowing. This is mandatory because the spawned thread may outlive the scope
where count was defined; a mere borrow could become a dangling reference, so without move the
code does not compile.
A common shorthand writes the closure inline:
let thread1 = thread::spawn(move || {
function1(count);
});
The two forms are equivalent; the inline version is just more compact.
Parallelism: Splitting Work Across Threads
Now the payoff: if a computation is large but splits into independent pieces, we can run those pieces on different cores at the same time and finish sooner. Our running example sums a giant vector:
#![allow(unused)] fn main() { fn sum(v: &Vec<u64>, start: usize, end: usize) -> u64 { let mut sum = 0; for i in start..end { sum += v[i]; } return sum; } }
The plan: split the vector into ranges, sum each range on its own thread, and add the partial
sums. But, how to get the data to each thread?
Each thread needs to read the vector, but a move closure takes
ownership, and only one thread can own it. If thread1 moves v in, thread2 cannot. We need
a way to share. Two approaches follow.
Sharing Data by Cloning
The simplest fix is a separate copy per thread:
use std::thread; fn sum(v: &Vec<u64>, start: usize, end: usize) -> u64 { let mut sum = 0; for i in start..end { sum += v[i]; } return sum; } fn main() { let v: Vec<u64> = (0..2_000_000).map(|x| x % 10).collect(); let timer = std::time::Instant::now(); // Each thread gets its OWN copy of the vector. let v2 = v.clone(); let first_half = move || sum(&v, 0, v.len() / 2); let second_half = move || sum(&v2, v2.len() / 2, v2.len()); let thread1 = thread::spawn(first_half); let thread2 = thread::spawn(second_half); let total = thread1.join().unwrap() + thread2.join().unwrap(); println!("Clone (2 threads): sum is {}", total); println!("Clone (2 threads): took {:?}", timer.elapsed()); }
v.clone() makes an independent vector v2, so each closure owns its own copy — safe, and it
works. But the cost is glaring: we copy 2 million elements just to read them, and more threads
would mean more copies. Two threads that only read should be able to share one copy.
Sharing Data with Arc
This is exactly what reference counting solves, and you have seen it: Rc<T> lets multiple owners
share one heap allocation, freeing it when the count hits zero. We would love to wrap the vector in
an Rc and hand each thread a cheap clone of the pointer, not the data.
The catch: Rc cannot cross threads. Its reference count uses plain, non-atomic operations, so
two threads updating it at once could clobber each other (exactly the race in the next section) and
corrupt the count. Rust refuses to compile such a program.
The fix is Arc<T> (Atomically Reference Counted pointer).
Arc is the thread-safe sibling of Rc.
It behaves identically but updates its count with atomic operations safe across
threads. (See the
Arc documentation.)
Wherever you would use Rc single-threaded, use Arc with threads.
use std::sync::Arc; use std::thread; fn sum(v: &Vec<u64>, start: usize, end: usize) -> u64 { println!("address of vector sum: {:p}", v); let mut sum = 0; for i in start..end { sum += v[i]; } return sum; } fn main() { let v: Vec<u64> = (0..2_000_000).map(|x| x % 10).collect(); let timer = std::time::Instant::now(); // Wrap the vector in an Arc. The vector itself is NOT copied. let v: Arc<Vec<u64>> = Arc::new(v); let v2: Arc<Vec<u64>> = Arc::clone(&v); // Both Arcs point to the SAME vector: these two addresses are identical. println!("address of vector inside 1st Arc: {:p}", &*v); println!("address of vector inside 2nd Arc: {:p}", &*v2); // Moving the Arcs into the closures moves the POINTERS, not the vector. let first_half = move || sum(&v, 0, v.len() / 2); let second_half = move || sum(&v2, v2.len() / 2, v2.len()); let thread1 = thread::spawn(first_half); let thread2 = thread::spawn(second_half); let total = thread1.join().unwrap() + thread2.join().unwrap(); println!("Arc (2 threads): sum is {}", total); println!("Arc (2 threads): took {:?}", timer.elapsed()); }
Arc::clone(&v) does not copy the 2 million elements. It just makes another pointer to the same
vector and bumps the count, which is why the printed addresses match. We move one Arc into each
closure; the pointers move, but the shared data stays put. No giant copy, still completely safe.
Scaling to Many Threads
Two threads is just the start. To split across many threads (say, one per core), we clone the
cheap Arc in a loop:
use std::sync::Arc; use std::thread; fn sum(v: &Vec<u64>, start: usize, end: usize) -> u64 { let mut sum = 0; for i in start..end { sum += v[i]; } return sum; } fn main() { let v: Vec<u64> = (0..2_000_000).map(|x| x % 10).collect(); let thread_count = 4; let timer = std::time::Instant::now(); let v: Arc<Vec<u64>> = Arc::new(v); let mut threads = Vec::with_capacity(thread_count); for i in 0..thread_count { // Clone the Arc once per thread, then move that clone in. let v2 = v.clone(); let f = move || { let slice = v2.len() / thread_count; let start = i * slice; let end = (i + 1) * slice; return sum(&v2, start, end); }; threads.push(thread::spawn(f)); } // Collect and add up every partial sum. let mut total = 0; for thread in threads { total += thread.join().unwrap(); } println!("Arc ({} threads): sum is {}", thread_count, total); println!("Arc ({} threads): took {:?}", thread_count, timer.elapsed()); }
Each iteration clones the Arc, computes this thread’s slice, and spawns it; we keep every
JoinHandle so we can join it and accumulate its partial sum. Note we capture the loop variable
i so each thread knows its slice. Running all four versions back-to-back (3-passing_data2.rs),
the multi-threaded ones finish meaningfully faster on a multi-core machine.
So far every thread only read the shared vector, and all was safe and easy. It gets more dangerous the moment threads need to modify shared data.
Sharing Mutable State and Data Races
Reading shared data is harmless as nobody changes it. We know this from looking into the borrowing rules and the permissions system in Rust earlier: we are allowed to read the same data multiple times using concurrently active borrows!
Writing is where trouble starts. Suppose five threads share one counter and each adds to it. They all need to have some way of mutating the same counter. I.e., they all need to have the write permission at the same time, which we know is not allowed by Rust’s borrowing rules.
To understand why this is disallowed, consider what counter = counter + 1 really does. It is three steps, not one:
- Read the current value from memory.
- Add one.
- Write the new value back.
Now picture two threads doing this with the counter at 41. A reads 41; before A writes, B also
reads 41; A writes 42; B writes 42 too. Two increments happened but the counter rose by one and
an update was silently lost. This is a data race: multiple threads touch the same memory at
once, at least one writing, with no coordination. The result depends on the unpredictable
interleaving, so the bug is intermittent and hard to track down.
A Shared Counter Without Synchronization
We cannot write a data race in safe Rust: the ownership and permissions rules forbid two threads holding a mutable reference to the same data.
We will intentionally use unsafe and raw pointers to demonstrate a data race for illustration reasons.
use std::thread; use std::time::Instant; fn main() { // Shared counter between all threads. let mut counter = 0; let address_of_counter = &mut counter as *mut i32 as usize; let timer = Instant::now(); // Spawn 5 threads, each adding 10 million to the same counter. let mut threads = Vec::with_capacity(5); for i in 0..5 { let f = move || { // Reconstruct a raw pointer to the shared counter and bump it. let counter: *mut i32 = address_of_counter as *mut i32; for _ in 0..1_000_000 { unsafe { *counter = *counter + 1; } } }; threads.push(thread::spawn(f)); } for thread in threads { thread.join().unwrap(); } println!("no synchronization: time taken is {:?}", timer.elapsed()); println!("no synchronization: counter now is {}", counter); println!("no synchronization: counter should be {}", 1_000_000 * 5); assert_ne!(counter, 1_000_000 * 5); }
The counter should reach 5,000,000, but every run gives a different, smaller number: thousands of increments vanish into the read–add–write race.
Protecting Shared State with a Mutex
The fix is to make read–add–write indivisible: once a thread starts updating, no other can touch the counter until it finishes. We can achieve this using a mutex (mutual exclusion).
A mutex acts like a key: a thread must lock it to access the data, and only one thread holds the lock at a time. If another thread tries to hold the lock, it must wait until the previous thread is finished with it.
Rust’s Mutex<T> wraps the data it protects: you cannot reach the value without locking first.
Since the mutex must also be shared across threads, we wrap it in an Arc, giving the common
Arc<Mutex<T>> pattern.
use std::sync::{Arc, Mutex}; use std::thread; use std::time::Instant; fn main() { // Shared counter: Arc lets threads share it, Mutex guards mutation. let counter = Arc::new(Mutex::new(0)); let timer = Instant::now(); let mut threads = Vec::with_capacity(5); for i in 0..5 { let counter2 = counter.clone(); let f = move || { for _ in 0..1_000_000 { // lock() blocks until we hold the lock, then gives us // mutable access to the value inside. let mut lock = counter2.lock().unwrap(); *lock = *lock + 1; // The lock is released automatically when `lock` goes // out of scope at the end of this iteration. } }; threads.push(thread::spawn(f)); } for thread in threads { thread.join().unwrap(); } println!("with mutex: time taken is {:?}", timer.elapsed()); println!("with mutex: counter now is {}", *counter.lock().unwrap()); println!("with mutex: counter should be {}", 1_000_000 * 5); assert_eq!(*counter.lock().unwrap(), 1_000_000 * 5); }
counter2.lock() blocks until it acquires the lock (it returns a Result, hence .unwrap()) and
hands back a guard giving read/write access. Read, add, and write now happen as one uninterruptible
unit, since no other thread can lock in the meantime. The lock releases automatically when the
guard goes out of scope, so we cannot forget to unlock. The counter now reliably reaches
5,000,000.
That correctness has a price: this version is noticeably slower, because only one thread holds the lock at a time and the increments are effectively serialized. This is the fundamental tension in concurrent programming: correctness requires coordination, and coordination costs performance.