Concurrency in Rust blocks data races—which were once called 'fearsome triggers'—at compile time. The secret is that the ownership and borrowing rules from chapters 6 to 9 apply directly to concurrency as well — since two threads cannot have mutable references to the same data simultaneously, data races are inherently prevented. In this chapter, we will learn how to create threads, use channels, and handle shared mutable state using only the standard library.
Learning Objectives
- Create a new thread with
std::thread::spawnand wait withjoin. - Send and receive messages safely between threads using
mpsc::channel. - Handle shared mutable state with the
Arc<Mutex<T>>pattern. - Understand how
moveclosures affect captures.
Key Concepts
1) thread::spawn and JoinHandle
use std::thread;
let handle = thread::spawn(|| {
//New thread body
});
handle.join().expect("Child thread panic");
spawnimmediately starts a new thread and returnsJoinHandle<T>.JoinHandle::join()waits for the thread to finish and returns the thread's return value (or panic payload).
2) move Closures
If a closure uses outer variables, it is borrowed by default. However, a thread can live longer than its parent, so a borrowed reference can become unsafe. Therefore, we use the move keyword to move ownership to the closure.
let s = String::from("hi");
thread::spawn(move || { println!("{s}") }); //Ownership of s to the closure
3) mpsc::channel — Message Passing
"Multi-producer, single-consumer (mpsc)"
let (tx, rx) = mpsc::channel();
tx.send(value).expect("Receiver closed");
for msg in rx { /* ... */ }
The sender tx can be cloned to clone() and distributed across multiple threads, and when all senders are dropped, the receiver's iteration naturally ends.
4) Arc<Mutex<T>> — Shared Mutable State
| Type | Meaning |
|---|---|
Arc<T> |
Atomic reference counting — multiple threads share the same value |
Mutex<T> |
Lock that allows only one thread to access the data at a time |
Arc<Mutex<T>> |
Combination of both — shared mutable |
let counter = Arc::new(Mutex::new(0));
let c = Arc::clone(&counter);
thread::spawn(move || {
let mut num = c.lock().unwrap();
*num += 1;
});
lock() returns a LockResult<MutexGuard<T>>, and when MutexGuard goes out of scope, the lock is released (RAII).
5) Send and Sync
Two marker traits from the standard library express concurrency safety.
T: Send— ownership can be transferred to another thread.T: Sync— multiple threads can hold&Tat the same time.
Arc and Mutex satisfy these, so they can be safely shared. Rc is not Send, so compilation fails in multi-threading.
Examples
Example 1 — ex01_thread.rs: Thread + join
//Example 21-1: Creating and joining threads with std::thread
//
//thread::spawn starts a new thread and returns a JoinHandle.
//With JoinHandle::join(), you can wait for the thread to finish and receive the result.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("[Child] Count {i}");
thread::sleep(Duration::from_millis(20));
}
//The last expression becomes the join result
"Child finished"
});
for i in 1..=3 {
println!("[Main] Count {i}");
thread::sleep(Duration::from_millis(20));
}
let result = handle.join().expect("Child thread panic");
println!("Child join result: {result}");
}
Example 2 — ex02_channel.rs: Channel
//Example 2 in Chapter 21: Sending messages between threads with mpsc::channel
//
//This is a 'multi-producer, single-consumer' channel.
//Tx can be cloned, there is only one Rx.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<String>();
//Two producers
let tx2 = tx.clone();
thread::spawn(move || {
for word in ["a1", "a2", "a3"] {
tx.send(format!("[A] {word}")).expect("Channel closed");
}
});
thread::spawn(move || {
for word in ["b1", "b2", "b3"] {
tx2.send(format!("[B] {word}")).expect("Channel closed");
}
});
//Consumer — ends naturally when both senders are dropped
for msg in rx {
println!("Received: {msg}");
}
}
Example 3 — ex03_arc_mutex.rs: Arc<Mutex>
//Example 21-3: Shared Mutable State with Arc<Mutex<T>>
//
//Arc — Atomically Reference Counted, allows multiple threads to share the same data.
//Mutex — Only one thread can acquire the lock and access the data at a time.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
//Arc::clone only increases the reference count (cheap)
let c = Arc::clone(&counter);
let h = thread::spawn(move || {
//lock() returns LockResult — safe if no other thread has panicked
let mut num = c.lock().expect("Mutex lock failed");
*num += 1;
});
handles.push(h);
}
for h in handles {
h.join().expect("Child thread panic");
}
println!("counter = {}", counter.lock().expect("Mutex lock failed"));
}
Common Mistakes
Q. If you remove the move keyword, it will not compile.
A. The closure borrows an outer value, and the compiler prevents it because the borrowing may live longer than the parent scope. It can be resolved by transferring ownership to the closure with move || { ... }.
Q. Can't I use Rc<RefCell<T>>?
A. It is possible in a single thread, but in multithreaded contexts the compiler prevents it because it's not Send/Sync. For multithreaded sharing, use Arc<Mutex<T>> (or RwLock).
Q. I heard lock().unwrap() can cause a panic?
A. If another thread panics while holding a lock, the mutex becomes "poisoned." Subsequent lock() calls return Err(PoisonError). At the beginner level, .expect() or .unwrap() is usually sufficient.
Q. How do I close a channel?
A. When all tx (senders) are dropped, the channel automatically closes and the receiver iteration ends. There is no method to close it explicitly.
Summary
- The default pattern is
thread::spawn+JoinHandle::join. - To move values to threads, use
moveclosure. - Pass messages with
mpsc::channel, share mutable state withArc<Mutex<T>>. - Borrow rules +
Send/Syncprevent data races at compile time.
Try it yourself
Solve the assignment below. The reference solution is shown below.