Rust · lesson 20 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Concurrency in Rust
Write safe concurrent Rust with threads, Arc<Mutex<T>>, mpsc channels, RwLock, atomics, Send/Sync and Rayon, with real run output for each.
By SkillByExample · Updated
Spawning Threads
Rust’s threading model maps directly to OS threads. Each thread is an independent unit of execution with its own stack. The type system enforces that data crossing thread boundaries is safe — you cannot accidentally share a non-thread-safe type across threads. The compiler will reject the code at compile time, not crash at runtime.
use std::thread;
use std::time::Duration;
fn main() {
// thread::spawn takes a closure and runs it on a new OS thread
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("spawned thread: {}", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..=3 {
println!("main thread: {}", i);
thread::sleep(Duration::from_millis(1));
}
// join() waits for the thread to finish and propagates any panic
handle.join().unwrap();
}
$ rustc concurrency_spawning_threads.rs && ./concurrency_spawning_threads
main thread: 1
spawned thread: 1
main thread: 2
spawned thread: 2
main thread: 3
spawned thread: 3
spawned thread: 4
spawned thread: 5
The two threads’ lines interleave, and the exact interleaving is up to the OS scheduler — run it again and main thread: 2 may land before or after spawned thread: 2. What is guaranteed: all five spawned lines appear, because join() makes main wait. Remove the join() and the spawned thread is killed when main returns, usually cutting its output short.
thread::spawn returns a JoinHandle. Call .join() to wait for the thread and propagate panics.
Moving Data into Threads
Threads require owned data. If a thread borrowed a variable, the variable’s owner might drop it while the thread is still running — a use-after-free. The move keyword on a closure transfers ownership of all captured variables into the closure, ensuring the thread owns everything it uses and the data lives long enough.
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
// move transfers ownership of data into the closure
// Without move, the borrow would dangle if main() returned first
let handle = thread::spawn(move || {
let sum: i32 = data.iter().sum();
println!("sum: {}", sum);
sum // threads can return a value via JoinHandle
});
let result = handle.join().unwrap();
println!("thread returned: {}", result);
}
$ rustc concurrency_moving_data_into_threads.rs && ./concurrency_moving_data_into_threads
sum: 15
thread returned: 15
Shared State: Arc<Mutex<T>>
When multiple threads need to read and write the same data, you need two things: shared ownership (Arc) and mutual exclusion (Mutex). Arc<T> (Atomically Reference Counted) is a thread-safe reference-counted pointer — cloning it gives you another handle to the same heap allocation. Mutex<T> ensures only one thread accesses the inner value at a time by blocking others until the lock is released.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc allows multiple threads to share ownership of the Mutex
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
// Clone the Arc to get another handle to the same Mutex
let counter = Arc::clone(&counter);
let h = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // acquire the lock
*num += 1;
// lock is automatically released when `num` goes out of scope (RAII)
});
handles.push(h);
}
for h in handles {
h.join().unwrap();
}
println!("final count: {}", *counter.lock().unwrap());
}
$ rustc concurrency_shared_state_arc_mutex_t.rs && ./concurrency_shared_state_arc_mutex_t
final count: 10
Deadlock Prevention
Rust does not prevent deadlocks at compile time, but good practices keep them rare: keep lock scopes as small as possible, always acquire multiple locks in the same order across all threads, and prefer channels for communication over shared mutable state.
Channels — Message Passing
Channels implement the “share memory by communicating” philosophy. Instead of multiple threads reaching into shared state, one thread produces a value and passes ownership to another via a channel. This eliminates the class of bugs where multiple threads observe inconsistent intermediate state. std::sync::mpsc provides Multiple Producer, Single Consumer channels.
use std::sync::mpsc;
use std::thread;
fn main() {
// tx = transmitter (sender), rx = receiver
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec!["hello", "from", "thread"];
for msg in messages {
tx.send(msg).unwrap(); // ownership of msg transfers through the channel
}
// tx is dropped here — the channel closes, rx will stop blocking
});
// Iterate until the channel is closed (all senders dropped)
for msg in rx {
println!("{}", msg);
}
}
$ rustc concurrency_channels_message_passing.rs && ./concurrency_channels_message_passing
hello
from
thread
Multiple Producers
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 0..3 {
// Clone the sender for each producer thread — all share one receiver
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("message from thread {}", id)).unwrap();
});
}
// Drop the original sender so rx knows when all clones are gone
drop(tx);
for msg in rx {
println!("{}", msg);
}
}
$ rustc concurrency_multiple_producers.rs && ./concurrency_multiple_producers
message from thread 0
message from thread 1
message from thread 2
The three messages arrive in whatever order the threads happened to run. This run happened to come out in spawn order, but that is luck, not a guarantee — an earlier run of the same binary printed 0, 2, 1. Forget the drop(tx) and the program never exits: the original sender is still alive, so rx keeps waiting for a message that will never come.
sync_channel — Bounded Channel
A bounded channel applies backpressure: the sender blocks when the buffer is full, preventing a fast producer from overwhelming a slow consumer. This is the right choice for producer-consumer pipelines where you want to limit memory usage.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::sync_channel(2); // buffer holds at most 2 items
thread::spawn(move || {
for i in 0..5 {
println!("sending {}", i);
tx.send(i).unwrap(); // blocks when buffer is full — provides backpressure
}
});
thread::sleep(std::time::Duration::from_millis(50));
for val in rx {
println!("received {}", val);
}
}
$ rustc concurrency_sync_channel_bounded_channel.rs && ./concurrency_sync_channel_bounded_channel
sending 0
sending 1
sending 2
received 0
received 1
received 2
sending 3
sending 4
received 3
received 4
During the 50 ms sleep nobody is receiving, so the producer prints sending 0, sending 1, sending 2 and then stops: 0 and 1 fill the buffer and the send(2) call blocks. Only when the loop starts receiving does the producer get unblocked. The later lines interleave differently from run to run, but the producer can never get more than two values ahead.
Send and Sync Marker Traits
Send and Sync are the compile-time mechanism that makes Rust’s concurrency guarantees possible. They are automatically derived for most types and cannot be implemented incorrectly — the compiler verifies the rules. Any type that violates the rules (like Rc<T> which uses non-atomic reference counting) is automatically excluded from cross-thread use, and the error appears at the call site rather than as a runtime crash.
Send: Safe to transfer ownership to another thread.Sync: Safe to share a reference across threads (&TisSend).
fn requires_send<T: Send>(_: T) {}
fn requires_sync<T: Sync>(_: T) {}
fn main() {
// String is Send — fine to move to another thread
requires_send(String::from("hello"));
// Mutex<T> is Sync — fine to share by reference
requires_sync(std::sync::Mutex::new(1));
println!("String is Send, Mutex is Sync");
// Rc<T> is NOT Send — it uses non-atomic reference counting
let rc = std::rc::Rc::new(1);
requires_send(rc);
}
$ rustc concurrency_send_and_sync_marker_traits.rs
error[E0277]: `Rc<{integer}>` cannot be sent between threads safely
--> concurrency_send_and_sync_marker_traits.rs:13:19
|
13 | requires_send(rc);
| ------------- ^^ `Rc<{integer}>` cannot be sent between threads safely
| |
| required by a bound introduced by this call
|
= help: the trait `Send` is not implemented for `Rc<{integer}>`
note: required by a bound in `requires_send`
--> concurrency_send_and_sync_marker_traits.rs:1:21
|
1 | fn requires_send<T: Send>(_: T) {}
| ^^^^ required by this bound in `requires_send`
help: consider dereferencing here
|
13 | requires_send(*rc);
| +
The first half compiles; the Rc line is rejected before the program ever runs, and the error names the exact missing trait. Nothing here spawns a thread — the bound alone is enough, which is how thread::spawn’s own F: Send bound catches the same mistake.
Most types are automatically Send + Sync. Notable exceptions:
Rc<T>— notSend(useArc<T>)Cell<T>/RefCell<T>— notSync(useMutex<T>)- Raw pointers
*const T/*mut T— neither
RwLock<T> — Multiple Readers, Single Writer
Mutex grants exclusive access on every lock. If the data is read far more often than written, this is unnecessarily restrictive. RwLock allows any number of concurrent readers while still enforcing that writes are exclusive — a significant throughput improvement for read-heavy workloads like caches and configuration stores.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
// Multiple readers can hold read locks simultaneously
let mut handles = vec![];
for _ in 0..3 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let read = data.read().unwrap(); // shared read lock
println!("read: {:?}", *read);
}));
}
for h in handles { h.join().unwrap(); }
// Write lock is exclusive — waits for all readers to finish
data.write().unwrap().push(4);
println!("after write: {:?}", data.read().unwrap());
}
$ rustc concurrency_rwlock_t_multiple_readers_single_writer.rs && ./concurrency_rwlock_t_multiple_readers_single_writer
read: [1, 2, 3]
read: [1, 2, 3]
read: [1, 2, 3]
after write: [1, 2, 3, 4]
Atomic Types
For simple numeric operations like counters and flags, atomics are faster than a mutex because they use hardware-level atomic CPU instructions rather than OS locks. There is no blocking, no context switching, and no heap allocation. The Ordering parameter controls the memory visibility guarantees — SeqCst is the safest choice when in doubt.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
// fetch_add is a single atomic CPU instruction — no lock needed
c.fetch_add(1, Ordering::SeqCst);
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", counter.load(Ordering::SeqCst));
}
$ rustc concurrency_atomic_types.rs && ./concurrency_atomic_types
10
Ten threads, ten increments, always exactly 10 — unlike the thread interleavings above, this result is deterministic because each fetch_add is indivisible.
Rayon — Data Parallelism
Rayon makes data parallelism easy: swap .iter() for .par_iter() and Rayon automatically divides the work across all available CPU cores using a work-stealing thread pool. No manual thread management, no channels, no mutexes — just a one-word change to your iterator chain.
[dependencies]
rayon = "1"
use rayon::prelude::*;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n == 2 { return true; }
if n % 2 == 0 { return false; }
let limit = (n as f64).sqrt() as u64;
!(3..=limit).step_by(2).any(|i| n % i == 0)
}
fn main() {
// Sequential — single core
let primes_seq: Vec<u64> = (2..100_000)
.filter(|&n| is_prime(n))
.collect();
// Parallel — all cores, identical output, one method change
let primes_par: Vec<u64> = (2u64..100_000)
.into_par_iter() // the only change from the sequential version
.filter(|&n| is_prime(n))
.collect();
assert_eq!(primes_seq, primes_par); // same result, same order
println!("found {} primes", primes_par.len());
// Parallel map-reduce — sum of squares
let sum: u64 = (1u64..=1_000_000)
.into_par_iter()
.map(|x| x * x)
.sum();
println!("sum of squares: {}", sum);
}
$ cargo run -q
found 9592 primes
sum of squares: 333333833333500000
The assert_eq! passes: collect() on a parallel iterator preserves the original order even though chunks were processed on different threads, so the parallel result is identical to the sequential one, not just the same size.
Rayon also provides parallel sort:
use rayon::prelude::*;
fn main() {
let mut data: Vec<i32> = (0..1_000_000).map(|x| 1_000_000 - x).collect();
data.par_sort(); // parallel sort — significantly faster on large collections
println!("sorted: {} .. {}", data[0], data[data.len() - 1]);
}
$ cargo run -q
sorted: 1 .. 1000000
Practice
-
In the
Arc<Mutex<T>>example, drop theArc:let counter = Mutex::new(0);and usecounterdirectly inside themoveclosure. What does the compiler say?Solution
error[E0382]: use of moved value: `counter` ... 8 | let h = thread::spawn(move || { | ^^^^^^^ value moved into closure here, in previous iteration of loopThe first iteration’s closure takes ownership of the
Mutex, leaving nothing for the second.Arc::clonegives each thread its own owning handle to one sharedMutex. -
Try to increment through a plain
Arc<usize>(noMutex, no atomic):*c += 1;inside a spawned thread.Solution
error[E0594]: cannot assign to data in an `Arc` ... = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc<usize>`Arconly hands out shared references. Mutation needs something that is safe to mutate through&— aMutex, anRwLock, or an atomic. -
Sum the two halves of
(1..=100).collect::<Vec<u64>>()on two threads withoutArcormove, usingstd::thread::scope.Solution
let (left, right) = data.split_at(data.len() / 2); let (a, b) = thread::scope(|s| { let h1 = s.spawn(|| left.iter().sum::<u64>()); let h2 = s.spawn(|| right.iter().sum::<u64>()); (h1.join().unwrap(), h2.join().unwrap()) });It prints
1275 + 3775 = 5050, anddatais still usable afterwards. Scoped threads are guaranteed to finish beforescopereturns, so they may borrow local data — the “thread might outlive the data” problem that forcesmovewiththread::spawncannot happen.