Concurrency & Threads
Reviewed & published by Brayan K
Rust is a systems programming language focused on speed, memory safety, and fearless concurrency — that last phrase means its type system stops data races at compile time, so multithreaded code is safe by construction.
Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll spawn threads, return values from them, pass messages with channels, and share state safely with Arc and Mutex.
What You'll Learn in This Lesson
- Spawning threads with thread::spawn
- Waiting on threads with join
- Why thread closures need move
- Passing messages with channels (mpsc)
- Sharing ownership across threads with Arc
- Coordinating mutation with Mutex
1️⃣ Spawning Threads and Joining
thread::spawn runs a closure on a new thread and returns a JoinHandle. Call .join() to wait for it to finish and collect its return value. When a thread needs to use outside data, the closure must take ownership of it with move, since the thread may outlive the current scope.
use std::thread;
fn main() {
// thread::spawn starts a new thread running the given closure.
// It returns a JoinHandle you can wait on.
let handle = thread::spawn(|| {
let mut sum = 0;
for i in 1..=5 {
sum += i;
}
sum // the closure returns a value out of the thread
});
// Meanwhile the main thread does its own work.
println!("main thread is working...");
// .join() waits for the thread to finish and returns its result.
// It gives a Result; unwrap() gets the value (or panics if the thread did).
let result = handle.join().unwrap();
println!("thread computed: {}", result); // -> thread computed: 15
// "move" lets a thread take ownership of captured data — required because
// the thread may outlive the current scope.
let data = vec![10, 20, 30];
let h2 = thread::spawn(move || {
let total: i32 = data.iter().sum(); // data is owned by this thread now
total
});
println!("sum from thread: {}", h2.join().unwrap()); // -> 60
}The second thread used move to take ownership of data — that's how the compiler guarantees the vector outlives the thread that reads it.
2️⃣ Channels and Shared State
There are two main ways threads coordinate. Channels (mpsc) pass messages: a sender hands values to a receiver. For shared mutable state, wrap the data in an Arc<Mutex<T>> — Arc shares ownership across threads, and Mutex ensures only one thread accesses it at a time.
use std::thread;
use std::sync::mpsc; // multiple producer, single consumer channel
use std::sync::{Arc, Mutex};
fn main() {
// CHANNELS pass messages between threads. tx sends, rx receives.
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for word in ["hi", "from", "the", "thread"] {
tx.send(word).unwrap(); // send each message
}
});
// Receiving iterates until the sender is dropped.
for received in rx {
print!("[{}]", received); // -> [hi][from][the][thread]
}
println!();
// SHARED STATE across threads: Arc (atomic Rc) + Mutex (safe locking).
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = c.lock().unwrap(); // lock to access the data
*num += 1; // unlocked when num drops
}));
}
for h in handles { h.join().unwrap(); }
println!("counter = {}", *counter.lock().unwrap()); // -> counter = 5
}Five threads each incremented the same counter, and the result was reliably 5 — no data race — because the Mutex serialized access. Try writing that safely in another language and you'll appreciate "fearless".
Your turn. Fill in the blanks marked ___, then run it.
// 🎯 YOUR TURN — finish the threading code, then run it.
use std::thread;
fn main() {
// 1) Spawn a thread that computes the product of 1..=4 (which is 24).
let handle = thread::___(|| { // 👉 the function that starts a thread
let mut product = 1;
for i in 1..=4 {
product *= i;
}
product
});
// 2) Wait for the thread and get its result.
let result = handle.___().unwrap(); // 👉 the method that waits for it
println!("product = {}", result); // expected: product = 24
}
// ✅ Expected output:
// product = 24Mini-Challenge: Parallel Sum
Use Arc<Mutex<i32>> and four threads to accumulate a shared total. Run it with cargo run.
// 🎯 MINI-CHALLENGE: parallel sum with shared state.
//
// 1. use std::thread; use std::sync::{Arc, Mutex};
// 2. Create let total = Arc::new(Mutex::new(0));
// 3. Spawn 4 threads. Each one locks total and adds 10 to it.
// (Clone the Arc into each thread with Arc::clone, and use move.)
// 4. Join all 4 threads.
// 5. Print the final total (should be 40).
//
// ✅ Example output:
// Total: 40
fn main() {
// your code here
}Common Errors (and the fix)
- "closure may outlive the current function" — Add move so the thread owns the data it captures.
- "`Rc<T>` cannot be sent between threads safely" — Use Arc instead of Rc across threads.
- Program ends before a thread finishes — Hold onto the JoinHandle and call .join() so main waits.
- Deadlock from holding a lock too long — Keep the locked scope small; the lock releases when the guard from .lock() is dropped.
Pro Tips
- 💡 Prefer channels when you can: message passing often beats shared state for clarity and safety.
- 💡 Arc<Mutex<T>> is the shared-state pattern: Arc to share, Mutex to mutate.
- 💡 Always join your handles: joining ensures threads complete and surfaces any panics.
📋 Quick Reference — Concurrency
| Goal | Rust Syntax |
|---|---|
| Spawn a thread | thread::spawn(|| { }) |
| Wait for it | handle.join().unwrap() |
| Own captures | thread::spawn(move || { }) |
| Channel | let (tx, rx) = mpsc::channel(); |
| Share ownership | Arc::new(x) |
| Lock for mutation | m.lock().unwrap() |
🎉 Lesson Complete!
- ✅ thread::spawn runs a closure on a new thread; join waits for it
- ✅ Thread closures use move to own captured data
- ✅ Channels (mpsc) pass messages between threads
- ✅ Arc<Mutex<T>> shares mutable state safely; data races are caught at compile time
Practice quiz
What does thread::spawn return?
- The thread's result directly
- Nothing
- A JoinHandle you can wait on
- A Mutex
Answer: A JoinHandle you can wait on. spawn returns a JoinHandle; calling .join() waits for the thread and yields its result.
What does handle.join() do?
- Waits for the thread to finish and returns its result (a Result)
- Starts a new thread
- Kills the thread immediately
- Locks shared data
Answer: Waits for the thread to finish and returns its result (a Result). join blocks until the thread completes and returns a Result containing its value.
Why do thread closures usually need the move keyword?
- For speed
- To avoid imports
- Because closures cannot borrow at all
- Because the thread may outlive the current scope, so it must own the data it uses
Answer: Because the thread may outlive the current scope, so it must own the data it uses. move makes the closure own its captures so the data lives as long as the thread.
What does 'fearless concurrency' refer to in Rust?
- Threads never crash
- The type system prevents data races at compile time
- Threads are always faster
- There is no need to join
Answer: The type system prevents data races at compile time. Ownership and borrowing rules turn would-be data races into compile errors.
What is the difference between Rc and Arc?
- Rc is single-threaded; Arc is thread-safe (atomic) for sharing across threads
- No difference
- Arc is single-threaded; Rc is for threads
- Arc cannot be cloned
Answer: Rc is single-threaded; Arc is thread-safe (atomic) for sharing across threads. Arc is the atomically reference-counted, thread-safe equivalent of Rc.
Why wrap shared data in a Mutex when sharing across threads?
- To clone it faster
- To sort it
- To ensure only one thread accesses the data at a time
- To avoid using Arc
Answer: To ensure only one thread accesses the data at a time. A Mutex serializes access so only the lock holder can read or write the data.
What is the idiomatic pattern for shared MUTABLE state across threads?
- Rc<RefCell<T>>
- Arc<Mutex<T>>
- Box<T>
- Vec<T>
Answer: Arc<Mutex<T>>. Arc shares ownership across threads and Mutex coordinates safe mutation.
What does mpsc::channel() give you?
- A single value
- Two threads
- A locked Mutex
- A transmitter (tx) and a receiver (rx) for passing messages
Answer: A transmitter (tx) and a receiver (rx) for passing messages. It returns a (tx, rx) pair; senders push values to tx and a receiver reads from rx.
When does iterating over a channel receiver (for x in rx) stop?
- After one message
- When all senders are dropped
- After 60 seconds
- Never
Answer: When all senders are dropped. Receiving iterates until every sender has been dropped, then the loop ends.
When is a Mutex lock released?
- Never automatically
- Only by calling unlock()
- When the guard returned by .lock() is dropped (goes out of scope)
- When the program exits
Answer: When the guard returned by .lock() is dropped (goes out of scope). The lock is held by the guard and released automatically when that guard is dropped.
Continue this course
- Previous: Smart Pointers (Box, Rc, RefCell)
- Next: Pattern Matching (match, if let, while let) — Exhaustive match, ranges, guards, bindings, and let-else
- Quick reference: Rust cheat sheet
Frequently asked questions
What does 'fearless concurrency' actually mean?
It means Rust's ownership and type system prevent data races at compile time, so you can write multithreaded code with confidence that whole classes of concurrency bugs simply cannot occur. A data race — two threads accessing the same data at once with at least one writing — is undefined behaviour in many languages and notoriously hard to debug. In Rust, the same borrow-checking rules that ensure memory safety also ensure that shared data is accessed safely across threads, turning would-be runtime races into compile errors.
Why do thread closures usually need move?
A spawned thread may keep running after the function that created it returns, so any data the thread's closure uses must outlive that function. The move keyword forces the closure to take ownership of the variables it captures rather than borrowing them, guaranteeing the data lives as long as the thread does. Without move, the closure would hold references that could dangle, and the compiler rejects it. This is why almost every thread::spawn closure that uses outside data is written as move || { ... }.
What is the difference between Rc and Arc, and Mutex's role?
Rc is a single-threaded reference-counted pointer; Arc is its thread-safe equivalent (Atomically Reference Counted) that can be shared across threads. But sharing ownership is not enough to mutate safely — for that you wrap the data in a Mutex, which ensures only one thread holds the lock and can access the data at a time. The common pattern for shared mutable state across threads is therefore Arc<Mutex<T>>: Arc to share ownership, Mutex to coordinate access.
What are channels and when should I use them?
Channels let threads communicate by sending messages rather than sharing memory directly. You create a channel with mpsc::channel(), which gives a transmitter (tx) and a receiver (rx); one or more threads send values through tx and another thread receives them through rx. This message-passing style — summed up by the slogan 'do not communicate by sharing memory; share memory by communicating' — often leads to simpler, safer designs than locking shared state, and is a great default when threads need to hand work or results to each other.