TL;DR
Learn safe concurrent programming in Rust with threads, message passing, and shared state
Key concepts
- Rust concurrency
- Rust threads
- Rust message passing
- Rust shared state
- Rust fearless concurrency
Concurrency in Rust
Concurrent programming -- where different parts of a program execute independently -- is essential for building responsive, high-performance software. Rust's ownership system gives it a unique advantage: many concurrency bugs that are runtime errors in other languages become compile-time errors in Rust. This concept is often called fearless concurrency.
What You'll Learn
By the end of this lesson you will build a small pipeline that fans work out to several threads and gathers the answers back — summing chunks in parallel, collecting a return value from each thread, and draining a channel fed by several producers. The part that catches people is that the compiler protects you from exactly one class of mistake here and not from the others. It will refuse a type that cannot safely cross a thread boundary; it will say nothing at all about a program that forgets to wait for its threads, or one that runs its work strictly sequentially while looking parallel, or one that waits forever for a sender nobody dropped.
This lesson is not on the capstone's path — the taskwork CLI you build at the end of the track is single-threaded, and nothing here is a prerequisite for it. What it is for is the point at which Rust's ownership rules stop being about memory and start being about time: the same move, the same borrow checker, now deciding which data two threads are allowed to touch at once. That is the idea that makes the rest of the ecosystem — async, the parallel iterator crates, every web framework — legible instead of magic. You arrive here able to say what move does to a closure's captures (Closures) and why a borrow may not outlive its owner (Borrowing in Depth); what is new is that "outlive" now includes outliving the thread that made it.
Spawning Threads
Rust's standard library provides OS threads via std::thread::spawn. Each thread runs a closure and returns a JoinHandle that you use to wait for the thread to finish:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=5 {
println!(" spawned thread: count {}", i);
thread::sleep(Duration::from_millis(50));
}
42 // threads can return values
});
for i in 1..=3 {
println!("main thread: count {}", i);
thread::sleep(Duration::from_millis(80));
}
// Wait for the thread to finish and get its return value
let result = handle.join().expect("Thread panicked");
println!("Spawned thread returned: {}", result);
}
That .join() at the end is doing far more than collecting a return value, and the easiest way to see it is to take it away. In the program below main spawns a worker and then simply finishes. The worker is given half a second of work to do; main needs microseconds. Decide what the whole program prints before you run it.
Predict
main spawns a worker that sleeps 500ms and then prints two lines, but main never calls .join() — it prints its own line and returns. What does this program print?
use std::thread;
use std::time::Duration;
fn main() {
// The handle is deliberately discarded: no .join() anywhere.
thread::spawn(|| {
thread::sleep(Duration::from_millis(500));
println!("worker: finished the job");
println!("worker: shutting down");
});
println!("main: work has been handed off");
// main returns here.
}Only main: work has been handed off prints. When main returns, the process exits, and that kills every other thread wherever it happens to be — the worker is destroyed mid-sleep, roughly 499.9ms before its first println! would have run. Rust keeps no register of outstanding threads to wait for, and this is not a compile error either: discarding a JoinHandle is legal and, under the default lints, entirely silent. That is what makes it a real trap — the compiler that catches so many concurrency mistakes cannot catch this one, because "main might finish first" is a timing property, not a type property. .join() is the fix: it blocks until the thread finishes and hands back its return value.
Moving Data Into Threads
Because threads can outlive the scope that created them, Rust requires you to move owned data into thread closures using the move keyword. This transfers ownership and prevents data races:
use std::thread;
fn main() {
let names = vec![
String::from("Alice"),
String::from("Bob"),
String::from("Charlie"),
];
// move transfers ownership of names into the thread
let handle = thread::spawn(move || {
println!("Processing {} names in background thread:", names.len());
for name in &names {
println!(" Hello, {}!", name);
}
names.len() // we still own names inside the closure
});
// names is no longer accessible here -- it was moved
// println!("{:?}", names); // This would not compile
let count = handle.join().unwrap();
println!("Processed {} names", count);
}
That move keyword is not new machinery — it is the ownership rule from Ownership & Borrowing applied to a closure. Before reading on, work out precisely what it changes about the closure, and what it would do to a plain i32.
Recall
Without scrolling up: in Ownership & Borrowing you learned that assigning a String moves it while assigning an i32 copies it, because i32 implements Copy. Now apply that to move. Given let total = 5i32; and let label = String::from('batch');, both used inside a thread::spawn(move || ...) closure — what is true after the spawn?
So move is one rule wearing a new hat: it forces the closure to capture by value instead of by reference, and the type then decides what that costs. A String or Vec is moved and the outer name is dead afterwards; an i32 is Copy, so capture-by-value duplicates it and the original stays usable. thread::spawn insists on move because its closure must be 'static — a borrow of a local variable would dangle the instant the spawning function returned, and the borrow checker rejects that at compile time rather than letting you discover it at three in the morning.
Message Passing with Channels
Channels let threads communicate by sending messages. Rust provides mpsc (multiple producer, single consumer) channels. The sender can be cloned to allow multiple threads to send to the same receiver:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
// Clone the sender for a second producer
let tx2 = tx.clone();
// Producer 1: sends numbers
thread::spawn(move || {
let values = vec![1, 2, 3, 4, 5];
for val in values {
tx.send(format!("number: {}", val)).unwrap();
thread::sleep(Duration::from_millis(30));
}
});
// Producer 2: sends letters
thread::spawn(move || {
let values = vec!['a', 'b', 'c'];
for val in values {
tx2.send(format!("letter: {}", val)).unwrap();
thread::sleep(Duration::from_millis(50));
}
});
// Consumer: receive all messages
// The iterator ends when all senders are dropped
let mut count = 0;
for received in rx {
println!("Got: {}", received);
count += 1;
}
println!("Received {} total messages", count);
}
Shared State with Mutex
A Mutex (mutual exclusion) protects data so only one thread can access it at a time. You call .lock() to acquire access, which returns a MutexGuard that automatically releases the lock when dropped:
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
// Lock the mutex to access the data
{
let mut num = counter.lock().unwrap();
*num += 1;
println!("Counter after increment: {}", *num);
} // MutexGuard is dropped here, releasing the lock
// Lock again
{
let mut num = counter.lock().unwrap();
*num += 10;
}
// into_inner consumes the mutex and returns the data
println!("Final counter: {}", counter.into_inner().unwrap());
}
Sharing a Mutex Across Threads with Arc
A Mutex alone cannot be shared across threads because Rc is not thread-safe. Instead, use Arc (Atomically Reference Counted), which is a thread-safe version of Rc:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for i in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
println!("Thread {} incremented counter to {}", i, *num);
});
handles.push(handle);
}
// Wait for all threads to finish
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}
The pattern Arc<Mutex<T>> is the standard way to share mutable data across threads in Rust. Arc provides shared ownership, and Mutex ensures only one thread modifies the data at a time.
Note the two halves of that pattern do two different jobs, and swapping either one for a plausible-looking alternative breaks it. The program below reaches for the smart pointer a learner meets first — the one from single-threaded code — and the compiler stops it cold with an error that names a trait rather than a line of your logic. Commit a hypothesis about which type is wrong and why before you change anything.
Debug
Four threads each add their own subtotal into one shared counter, and main prints the total once they have all finished. It refuses to compile, and the error names a trait rather than anything you wrote. Work out which type is the wrong one and what property it is missing, then fix it so the program prints its total and 'All checks passed.'
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let total = Rc::new(Mutex::new(0u32));
let mut handles = Vec::new();
// Four workers, each adding its own subtotal into the shared counter.
for subtotal in [10u32, 20, 30, 40] {
let total = Rc::clone(&total);
let handle = thread::spawn(move || {
let mut guard = total.lock().unwrap();
*guard += subtotal;
});
handles.push(handle);
}
// Nothing is printed from inside the threads, so the output is
// the same on every run no matter what order they finish in.
for handle in handles {
handle.join().unwrap();
}
let final_total = *total.lock().unwrap();
println!("total = {}", final_total);
assert_eq!(final_total, 100, "all four subtotals should land in the counter");
println!("All checks passed.");
}Expected output: total = 100
All checks passed.
The error is error[E0277]: Rc<Mutex<u32>> cannot be sent between threads safely, with the note that Send is not implemented for it, pointed at thread::spawn. The Mutex was never the problem — the pointer wrapping it was. Rc keeps its reference count in a plain non-atomic integer, so two threads cloning at the same moment can interleave the read-modify-write, lose an increment, and free the value while a pointer to it still exists. Instead of leaving that as a data race you find in production, the standard library declines to implement Send for Rc and the compiler rejects the program. The fix is use std::sync::Arc; with Arc::new and Arc::clone — the same API over an atomic counter, which is Send + Sync and costs slightly more per clone. That extra cost is exactly why Rc still exists and is still the right default in single-threaded code.
Parallel Data Processing
Splitting work across threads is the obvious application, and it is worth arriving at rather than being handed. Three stages, same problem: count the primes below ten thousand. Read each comment for why the previous stage was not enough.
Stage 1 — no threads at all. This is not a warm-up, it is the control:
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 || n % 3 == 0 { return false; }
let mut i = 5;
while i * i <= n {
if n % i == 0 || n % (i + 2) == 0 { return false; }
i += 6;
}
true
}
fn main() {
// Stage 1: no threads at all. Get the answer you are trying to reproduce
// BEFORE introducing concurrency - otherwise a parallel version that is
// wrong and a parallel version that is right look identical.
let primes: Vec<u64> = (2..10_000).filter(|&n| is_prime(n)).collect();
println!("sequential total: {}", primes.len());
}
It prints sequential total: 1229. That number is now the thing every later version has to reproduce, which is the entire reason for writing this stage.
Stage 2 — one thread per range, each returning its own answer. Note what is not here:
use std::thread;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 || n % 3 == 0 { return false; }
let mut i = 5;
while i * i <= n {
if n % i == 0 || n % (i + 2) == 0 { return false; }
i += 6;
}
true
}
fn main() {
let ranges = [(2u64, 2500u64), (2500, 5000), (5000, 7500), (7500, 10_000)];
// Stage 2: one thread per range, each RETURNING its own Vec. No Arc and no
// Mutex, because nothing is shared - each thread owns its range and its
// answer, and join hands the answer back. Reach for shared state only when
// returning a value genuinely will not do.
let mut handles = Vec::new();
for (start, end) in ranges {
handles.push(thread::spawn(move || {
(start..end).filter(|&n| is_prime(n)).collect::<Vec<u64>>()
}));
}
let mut all = Vec::new();
for h in handles {
all.extend(h.join().unwrap());
}
println!("threaded total: {}", all.len());
}
threaded total: 1229 — same answer, four threads, and no Arc and no Mutex anywhere. That is the stage most write-ups skip, and skipping it is how Arc<Mutex<T>> acquires a reputation as the way to do threading rather than as the thing you reach for when returning a value will not do. Here each thread owns its range, computes an answer nobody else touches, and join carries it home. There is nothing shared, so there is nothing to protect.
Stage 3 — shared state, and the reason to accept it. Now suppose the results must land in one collection as they are produced rather than being assembled at the end — a progress display, a bounded buffer, anything that has to see partial results. That is when the threads genuinely share one destination, and sharing one mutable destination is what Arc<Mutex<T>> is for:
use std::sync::{Arc, Mutex};
use std::thread;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 || n % 3 == 0 { return false; }
let mut i = 5;
while i * i <= n {
if n % i == 0 || n % (i + 2) == 0 { return false; }
i += 6;
}
true
}
fn main() {
let ranges: Vec<(u64, u64)> = vec![
(2, 2500),
(2500, 5000),
(5000, 7500),
(7500, 10000),
];
let results = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for (start, end) in ranges {
let results = Arc::clone(&results);
let handle = thread::spawn(move || {
let primes: Vec<u64> = (start..end).filter(|&n| is_prime(n)).collect();
let count = primes.len();
println!("Found {} primes in range {}..{}", count, start, end);
results.lock().unwrap().extend(primes);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let final_results = results.lock().unwrap();
println!("\nTotal primes found below 10000: {}", final_results.len());
println!("First 10: {:?}", &final_results[..10.min(final_results.len())]);
}
Run it twice and compare the output. The total is 1229 both times — but the four Found N primes in range lines do not reliably arrive in the same order, because each thread prints when it happens to finish. The contents of the shared Vec are exposed to the same wobble for the same reason: each range extends it on arrival, so the order of the collection is the order the threads finished in, not the order you wrote them down. It may well come out sorted on a given run, which is worse than if it never did — an ordering that holds by luck is one you will accidentally depend on. Stage 2 had no such exposure at all: join returns each handle's value in the order you stored the handles, so assembling at the end gave a fixed order with nothing extra to write. That is the cost of stage 3 and it is easy to overlook — sharing bought the ability to see partial results, and it charged you determinism plus a lock. Take it when you need what it buys, and reach for stage 2 when you do not.
Now write the fourth stage yourself, and decide the design question first rather than second. Change stage 2 so the four threads report a count rather than a Vec of primes, and the counts are summed. Ask before you type: does anything need to be shared for that? Write down your answer, then write the code and see whether you were right.
Send and Sync Traits
You have already worked out one of these by hand. The debug block above ended with a rule about one specific type: Rc keeps its count in a plain integer, two threads can interleave a read-modify-write on it, and so the standard library declines to let Rc cross a thread boundary. Send and Sync are that reasoning generalised — two marker traits the compiler uses to ask the same question about every type, so you do not have to work each one out by hand.
Send means: it is safe for this value to be moved to another thread. The question Send answers is about transfer of ownership. Rc<T> fails it for the reason you just diagnosed. Almost everything else passes: i32, String, Vec<T> when T is Send, Mutex<T> when T is Send.
Sync means: it is safe for several threads to hold a shared reference to this value at once. The definition you will see written down — T is Sync if &T is Send — sounds circular until you notice it is not a definition of Sync in terms of Sync; it is a reduction of the harder question to the one you already answered. "Can two threads share this?" becomes "can a reference to it be sent to another thread?", and Send already knows how to answer that.
Asking the compiler directly
Both traits are decided at compile time, which means you can interrogate them with a function that has no body at all. A generic function with a T: Send bound will only compile when the type you hand it really is Send — so the compiling is the answer:
use std::cell::RefCell;
use std::sync::{Arc, Mutex};
// Two questions with no runtime behaviour. Each one compiles only if the
// answer is yes, so a failed question is a compile error, not a wrong value.
fn assert_send<T: Send>(_label: &str) {}
fn assert_sync<T: Sync>(_label: &str) {}
fn main() {
assert_send::<i32>("i32");
assert_sync::<i32>("i32");
assert_send::<String>("String");
assert_send::<Vec<String>>("Vec<String>");
// A Mutex is BOTH: it can be moved to a thread, and shared with several.
assert_send::<Mutex<u32>>("Mutex<u32>");
assert_sync::<Mutex<u32>>("Mutex<u32>");
assert_send::<Arc<Mutex<u32>>>("Arc<Mutex<u32>>");
assert_sync::<Arc<Mutex<u32>>>("Arc<Mutex<u32>>");
// And here is the interesting one: RefCell IS Send.
assert_send::<RefCell<u32>>("RefCell<u32>");
println!("every assertion above compiled");
}
That last line is the one worth stopping on, because it separates the two traits in a way the passing examples never can. RefCell<u32> is Send and is not Sync. Handing a RefCell to another thread outright is fine — only one thread owns it, and its borrow counter is only ever touched by that one thread. What is not fine is two threads holding &RefCell at the same time, because then two threads are incrementing that non-atomic borrow counter, which is the Rc problem again in a different costume.
Add assert_sync::<RefCell<u32>>("RefCell<u32>"); to the program above and it stops compiling:
error[E0277]: `RefCell<u32>` cannot be shared between threads safely
--> /tmp/main.rs:24:19
|
24 | assert_sync::<RefCell<u32>>("RefCell<u32>");
| ^^^^^^^^^^^^ `RefCell<u32>` cannot be shared between threads safely
|
= help: the trait `Sync` is not implemented for `RefCell<u32>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
note: required by a bound in `assert_sync`
--> /tmp/main.rs:7:19
|
7 | fn assert_sync<T: Sync>(_label: &str) {}
| ^^^^ required by this bound in `assert_sync`
Note the note: line, which is doing the same job as the trailing note in the Rc error: it names the type you should have reached for instead. RwLock is RefCell's thread-safe counterpart in exactly the way Arc is Rc's.
And if the reduction still feels like word-play, ask the compiler to perform it. Swap that last line for assert_send::<&RefCell<u32>>("&RefCell<u32>"); — is a reference to a RefCell sendable? — and it fails with:
error[E0277]: `&RefCell<u32>` cannot be sent between threads safely
--> /tmp/main.rs:24:19
|
24 | assert_send::<&RefCell<u32>>("&RefCell<u32>");
| ^^^^^^^^^^^^^ `&RefCell<u32>` cannot be sent between threads safely
|
= help: the trait `Sync` is not implemented for `RefCell<u32>`
= note: required for `&RefCell<u32>` to implement `Send`
note: required by a bound in `assert_send`
--> /tmp/main.rs:6:19
|
6 | fn assert_send<T: Send>(_label: &str) {}
| ^^^^ required by this bound in `assert_send`
help: consider removing the leading `&`-reference
|
24 - assert_send::<&RefCell<u32>>("&RefCell<u32>");
24 + assert_send::<RefCell<u32>>("&RefCell<u32>");
|
The compiler spells the definition out in that note:: &T is Send because T is Sync. It is not two facts, it is one fact asked from two directions.
And this is a good place to practise refusing a help:. The suggestion at the bottom — drop the & — would make the program compile, because RefCell<u32> really is Send. It would also silently replace the question you asked with a different one whose answer you already knew. A help: is rustc proposing the smallest edit that stops the error; whether that edit preserves what you were trying to find out is a judgment only you can make.
Deciding for a type you have not seen
The point of a rule is answering the case nobody showed you. Here is the procedure, and it is short because the compiler derives both traits automatically for any type built out of other types:
- A type is
Sendif every field it contains isSend, andSyncif every field isSync. That is the whole default, and it means you almost never reason about the type in front of you — you reason about its parts. - So look for a part that opts out. The standard library's opt-outs all share one shape: a counter or a flag that is mutated without synchronisation.
Rc's reference count andRefCell's borrow flag are both plain integers, which is precisely why neither isSyncand whyRcis not evenSend. Their synchronised counterparts —ArcandMutex/RwLock— pay for an atomic or a lock and get both traits back. - Raw pointers (
*const T,*mut T) are neither, which is how a type that wraps FFI or hand-rolled memory ends up neither, no matter how safe its API looks.
Try it on a type this lesson has not shown you: is Vec<Rc<String>> Send? Work through step 1 — Vec<T> is Send when T is — and step 2 hands you the answer. Is Mutex<Rc<String>> Send? Same procedure, same answer, and it is worth noticing that wrapping something in a Mutex does not rescue it: the Mutex guarantees only one thread is inside at a time, and that says nothing about the non-atomic count Rc keeps outside it.
Using the traits as bounds
Where this shows up in code you write is a generic bound. thread::spawn itself is declared with F: Send + 'static, and any function of yours that hands work to a thread will need the same:
use std::sync::{Arc, Mutex};
use std::thread;
// This function accepts only types that are Send + 'static
fn process_in_background<T: Send + 'static + std::fmt::Debug>(data: T) {
let handle = thread::spawn(move || {
println!("Processing in background: {:?}", data);
});
handle.join().unwrap();
}
fn main() {
// i32 is Send -- this works
process_in_background(42);
// String is Send -- this works
process_in_background(String::from("hello from another thread"));
// Vec is Send if its elements are Send -- this works
process_in_background(vec![1, 2, 3, 4, 5]);
// Arc<Mutex<T>> is Send + Sync -- the standard shared state pattern
let shared = Arc::new(Mutex::new(vec![1, 2, 3]));
let shared_clone = Arc::clone(&shared);
let handle = thread::spawn(move || {
shared_clone.lock().unwrap().push(4);
println!("Modified from thread: {:?}", shared_clone.lock().unwrap());
});
handle.join().unwrap();
println!("Final state: {:?}", shared.lock().unwrap());
}
Pass a Rc<String> to process_in_background and the error you get is the one from the debug block, at the call site rather than inside the function — which is the practical value of writing the bound down: the refusal arrives where the wrong type was chosen, not where it would have been used.
The shutdown sequence, in the only order that works
A channel with several producers has a shutdown rule, and it is the one thing in this lesson that fails silently and forever rather than loudly. for v in rx ends when every sender has been dropped — and the original tx you cloned from is a sender too.
Arrange the code
These five lines fan three values out to three producer threads and drain them back through one channel. The pieces are shuffled. Put them in the order that runs — then answer what the order is really testing: which line introduces the name the line below it consumes, and why the line that drops the original sender cannot be moved either up or down?
let mut drained: Vec<u32> = rx.iter().collect();drained.sort();drop(tx);let (tx, rx) = mpsc::channel::<u32>();let handles: Vec<_> = values.into_iter().map(|v| { let tx = tx.clone(); thread::spawn(move || tx.send(v).unwrap()) }).collect();
Two of those constraints are enforced by the compiler and one is not, and it is worth seeing the third demonstrated rather than asserted. This program never hangs, because it asks the channel a question with a deadline instead of blocking on it:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let values = vec![3u32, 1, 2];
let (tx, rx) = mpsc::channel::<u32>();
let handles: Vec<_> = values
.into_iter()
.map(|v| {
let tx = tx.clone();
thread::spawn(move || tx.send(v).unwrap())
})
.collect();
// Every producer is finished before we read a thing.
for h in handles {
h.join().unwrap();
}
// Drain the three real messages.
let mut got = Vec::new();
for _ in 0..3 {
got.push(rx.recv().unwrap());
}
got.sort();
println!("received: {:?}", got);
// The producers are all gone. Is the channel finished? Only if every
// sender is dropped - and the ORIGINAL tx is still in scope right here.
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(v) => println!("unexpected extra value: {}", v),
Err(mpsc::RecvTimeoutError::Timeout) => {
println!("STILL BLOCKED: the original tx is alive, so the channel is not closed");
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
println!("closed: every sender was dropped");
}
}
// Now drop it and ask again.
drop(tx);
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(v) => println!("unexpected extra value: {}", v),
Err(mpsc::RecvTimeoutError::Timeout) => println!("still blocked (unexpected)"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
println!("AFTER drop(tx): closed - this is what ends a for-loop over rx")
}
}
}
It prints STILL BLOCKED and then, one line later, AFTER drop(tx): closed. Every producer had already been joined before the first question was asked, so the only thing holding the channel open was the tx sitting in scope — and drop(tx) is what closes it. Swapping Timeout for a blocking recv() there is the difference between a diagnosis and a hang.
Try It Yourself
Reading about join, move and channels is not the same as reaching for them under your own name. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one fans work out to threads and sums the results, one collects a value back from each thread, and one drains a channel fed by several producers. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
Notice what none of the three functions does: print from inside a thread. Thread interleaving is not deterministic, so a program whose output depends on which thread reached its println! first has a different answer on every run. Every function here gathers its results first — by joining, or by draining a channel — sorts anything whose order came from the threads, and only then produces output. That is not a testing trick; it is how you write concurrent code whose behaviour you can actually reason about.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. None of these functions may print: all output comes from main, after the threads are finished.
use std::sync::mpsc;
use std::thread;
// TODO 1: sum every number across all chunks, one thread per chunk.
// Spawn a thread for each chunk that sums it, push the JoinHandle into a
// Vec, then join every handle and add up what they return.
// parallel_sum(vec![vec![1, 2], vec![3]]) -> 6
// The closure must be a move closure: it has to own its chunk, because
// the thread may outlive the loop iteration that created it.
fn parallel_sum(chunks: Vec<Vec<u32>>) -> u32 {
let _ = chunks;
0
}
// TODO 2: uppercase each name on its own thread, then return the results
// SORTED. Join the handles to collect each thread's return value, then
// sort the Vec — the threads finish in an unpredictable order, so the
// sort is what makes the answer the same on every run.
// shout_all(vec!["bea".into(), "al".into()]) -> ["AL", "BEA"]
fn shout_all(names: Vec<String>) -> Vec<String> {
let _ = names;
Vec::new()
}
// TODO 3: send every value down a channel from its own producer thread,
// then receive them all and return them SORTED.
// Clone tx once per producer and move the clone into that thread.
// Then DROP the original tx before iterating rx: for v in rx ends only
// when every sender has been dropped, so an original left alive here
// hangs the program forever.
// funnel(vec![3, 1, 2]) -> [1, 2, 3]
fn funnel(values: Vec<u32>) -> Vec<u32> {
let (tx, rx) = mpsc::channel::<u32>();
let _ = tx;
let _ = rx;
let _ = values;
Vec::new()
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let chunks = vec![vec![1, 2, 3], vec![10, 20], vec![100]];
assert_eq!(parallel_sum(chunks), 136, "every chunk's numbers should be added in");
assert_eq!(parallel_sum(vec![]), 0, "no chunks means no work and a total of 0");
let names = vec!["bea".to_string(), "al".to_string(), "cy".to_string()];
assert_eq!(
shout_all(names),
vec!["AL".to_string(), "BEA".to_string(), "CY".to_string()],
"shout_all must sort: threads finish in an unpredictable order"
);
assert_eq!(funnel(vec![3, 1, 2]), vec![1, 2, 3], "every sent value should arrive");
assert_eq!(funnel(vec![]), Vec::<u32>::new(), "no producers means an empty drain, not a hang");
println!("All checks passed.");
println!("Total: {}", parallel_sum(vec![vec![4, 5], vec![6]]));
println!("Shouted: {:?}", shout_all(vec!["zed".to_string(), "ada".to_string()]));
println!("Funnelled: {:?}", funnel(vec![9, 7, 8]));
}Expected output: All checks passed.
Total: 15
Shouted: ["ADA", "ZED"]
Funnelled: [7, 8, 9]
Once it passes, try two variations and predict each before running:
- Join inside the spawn loop. In
parallel_sum, replace the handleVecwith a single accumulator and join immediately:for chunk in chunks { total += thread::spawn(move || chunk.iter().sum::<u32>()).join().unwrap(); }. Predict which check breaks before running. None of them — it still printsAll checks passed., and that is the point.joinblocks, so each chunk now runs to completion before the next thread is even created: you have written sequential code with the overhead of spawning a thread per item and none of the benefit. Correctness and concurrency are separate properties, and no assert in this program can tell them apart. Spawn all the work first, join second. - Remove the
drop(tx). Delete that one line fromfunnel. Predict what happens before running. It does not fail a check and it does not error — it hangs. Thefor v in rxloop ends only when every sender has been dropped, and the originaltxis still sitting in scope holding the channel open, so the receiver waits for a message that can never arrive. The Playground will eventually cut it off; a real service would just stop. Compare that with variation 1: both bugs are invisible to the assert battery, which is why understanding the shutdown rule matters more here than passing the test.
Concurrency Elsewhere
Rust is not the only language that has had to answer "who is allowed to touch this while someone else is looking at it". JavaScript answers it too, and the answer is so different that it is worth naming precisely — especially since a great deal of concurrent code you have read was probably written in it:
Transfer
Rust lets several OS threads run at once and uses the type system to decide what they may share: Send and Sync, Arc, Mutex. JavaScript, in a browser tab or a plain Node process, runs your code on one thread and interleaves work through an event loop. Which statement names what the two genuinely share, rather than a surface resemblance?
Key Takeaways
thread::spawncreates OS threads; use.join()to wait for them to finish- The
movekeyword transfers ownership of data into a thread closure - Channels (
mpsc) provide safe message passing between threads Mutex<T>protects shared data with mutual exclusion;.lock()acquires accessArc<T>is a thread-safe reference-counted pointer for sharing ownership across threads- The
Arc<Mutex<T>>pattern is the standard way to share mutable state between threads SendandSyncare marker traits the compiler uses to enforce thread safety at compile time
Pro Tip: Prefer message passing (channels) over shared state (mutexes) when possible. Channels make data flow explicit and reduce the risk of deadlocks. When you do need shared state, keep the critical section (the code between
.lock()and the guard being dropped) as short as possible.
Next Steps
To unlock more advanced memory patterns, we'll next explore smart pointers — Box, Rc, Arc, and RefCell — which give you fine-grained control over how data is stored and shared.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.