Concurrency
Rust's ownership system prevents data races at compile time -- a feature called fearless concurrency. This chapter covers threads, message passing, shared state, and a brief introduction to async programming.
Spawning threads
Use std::thread::spawn to create a new thread:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=5 {
println!(" spawned thread: {i}");
thread::sleep(Duration::from_millis(100));
}
});
for i in 1..=3 {
println!("main thread: {i}");
thread::sleep(Duration::from_millis(150));
}
handle.join().unwrap();
println!("Both threads finished.");
}
thread::spawn takes a closure and returns a JoinHandle. Call .join() to wait for the thread to finish.
move closures for threads
Threads must own their data. Use move to transfer ownership into the closure:
use std::thread;
fn main() {
let message = String::from("Hello from main");
let handle = thread::spawn(move || {
println!("{message}");
});
// println!("{message}"); // Error: message was moved
handle.join().unwrap();
}
Without move, the compiler would reject this -- the spawned thread might outlive message. move ensures the thread
owns everything it needs.
Message passing with channels
Channels let threads communicate by sending messages. Rust's standard library provides mpsc (multiple producer,
single consumer):
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let messages = vec!["hello", "from", "the", "thread"];
for msg in messages {
tx.send(msg).unwrap();
}
});
for received in rx {
println!("Got: {received}");
}
}
Got: hello
Got: from
Got: the
Got: thread
mpsc::channel()creates a sender (tx) and receiver (rx)tx.send(value)sends a value (moves it into the channel)- The
for received in rxloop receives messages until the sender is dropped - The sender is moved into the spawned thread with
move
Multiple producers
Clone the sender for multiple threads:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for id in 0..3 {
let tx_clone = tx.clone();
thread::spawn(move || {
tx_clone.send(format!("Message from thread {id}")).unwrap();
});
}
drop(tx); // Drop the original sender so the receiver knows when all senders are done
for msg in rx {
println!("{msg}");
}
}
Shared state with Arc and Mutex
When multiple threads need to access the same data, use Arc<Mutex<T>>:
Mutex<T>-- mutual exclusion, ensures only one thread accesses the data at a timeArc<T>-- atomically reference-counted smart pointer, enables shared ownership across threads
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap()); // 10
}
How it works:
Arc::new(Mutex::new(0))creates a shared, thread-safe counterArc::clone(&counter)creates a new reference (not a deep copy -- just increments a reference count)counter.lock().unwrap()acquires the lock, returning aMutexGuardthat auto-unlocks when dropped- Each thread increments the counter while holding the lock
Warning:
Mutexcan deadlock if you acquire multiple locks in different orders. Keep critical sections short and prefer channels when possible.
When to use channels vs shared state
| Approach | Use when |
|---|---|
| Channels | Threads produce data for another thread to consume |
| Arc | Multiple threads read/write the same data |
Channels are generally simpler and less error-prone. Use shared state only when channels are not a good fit.
Send and Sync traits
Rust uses two marker traits to enforce thread safety:
| Trait | Meaning |
|---|---|
Send | The type can be transferred to another thread |
Sync | The type can be referenced from multiple threads |
Most types are Send and Sync automatically. Notable exceptions:
Rc<T>is notSend(useArc<T>instead)Cell<T>andRefCell<T>are notSync(useMutex<T>instead)- Raw pointers are neither
SendnorSync
You rarely implement these traits yourself. The compiler tells you when a type is not Send or Sync:
error[E0277]: `Rc<i32>` cannot be sent between threads safely
The fix is almost always: replace Rc with Arc, or RefCell with Mutex.
Introduction to async/await
Threads are great for CPU-bound work, but for I/O-bound work (network requests, file I/O, database queries), async/await is more efficient. Instead of one OS thread per task, async lets you run thousands of tasks on a small thread pool.
Rust's async support requires a runtime. The most popular is tokio:
[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
async fn fetch_data(id: u32) -> String {
sleep(Duration::from_millis(100)).await;
format!("Data from source {id}")
}
#[tokio::main]
async fn main() {
let (a, b, c) = tokio::join!(
fetch_data(1),
fetch_data(2),
fetch_data(3),
);
println!("{a}");
println!("{b}");
println!("{c}");
}
Key concepts:
async fndefines an asynchronous function that returns aFuture.awaitpauses execution until the future completes, releasing the thread for other worktokio::join!runs multiple futures concurrently#[tokio::main]sets up the tokio runtime and makesmainasync
When to use threads vs async
| Approach | Best for |
|---|---|
| Threads | CPU-heavy work (computation, compression) |
| Async | I/O-heavy work (HTTP, databases, files) |
Note: Async Rust has a steeper learning curve. It is covered briefly here so you know it exists. For a full treatment, see the Async Book.
A complete example -- parallel word counter
Count words in multiple files concurrently:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let word = word.to_lowercase();
*counts.entry(word).or_insert(0) += 1;
}
counts
}
fn merge_counts(
target: &mut HashMap<String, usize>,
source: HashMap<String, usize>,
) {
for (word, count) in source {
*target.entry(word).or_insert(0) += count;
}
}
fn main() {
let texts = vec![
"the quick brown fox jumps over the lazy dog",
"the fox and the dog are friends",
"quick quick quick fox fox",
];
let results = Arc::new(Mutex::new(HashMap::new()));
let mut handles = vec![];
for text in texts {
let results = Arc::clone(&results);
let handle = thread::spawn(move || {
let local_counts = count_words(text);
let mut global = results.lock().unwrap();
merge_counts(&mut global, local_counts);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let final_counts = results.lock().unwrap();
let mut sorted: Vec<_> = final_counts.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
for (word, count) in sorted.iter().take(5) {
println!("{word}: {count}");
}
}
Summary
thread::spawncreates OS threads; usemoveto transfer data ownership.join()waits for a thread to finish- Channels (
mpsc) pass messages between threads -- sender moves data, receiver gets it Arc<Mutex<T>>enables shared mutable state across threadsMutexprovides mutual exclusion;Arcprovides shared ownershipSendmeans a type can be moved to another thread;Syncmeans it can be shared by reference- async/await with tokio is efficient for I/O-heavy work
- Rust prevents data races at compile time -- the type system enforces thread safety
Next up: REST API -- building a web API with Actix Web, routes, handlers, JSON, and a SQLite database.