Week 14: Multithreaded Applications
Session 1: Threading Fundamentals
Session 2: Thread Communication and Coordination
Arc<Mutex<T>>Modern hardware reality:
Real-world performance impact:
Single-threaded: 10 seconds to process 1000 files
Multi-threaded (8 cores): 1.5 seconds (6.7x faster!)
Where concurrency is essential:
Most languages make concurrency scary:
Why data races are terrible:
Rust’s solution: Make data races impossible at compile time!
The ownership system prevents data races:
Compiler error saves you:
error[E0373]: closure may outlive the current function
Key insight: If code compiles, it’s thread-safe!
No need for:
Create a new thread with thread::spawn:
use std::thread;
use std::time::Duration;
fn main() {
// Spawn a new thread
let handle = thread::spawn(|| {
for i in 1..10 {
println!("Thread: count {}", i);
thread::sleep(Duration::from_millis(100));
}
});
// Main thread continues
for i in 1..5 {
println!("Main: count {}", i);
thread::sleep(Duration::from_millis(100));
}
// Wait for spawned thread to finish
handle.join().unwrap();
}Output (interleaved):
Main: count 1
Thread: count 1
Main: count 2
Thread: count 2
...
join() blocks until thread completes:
use std::thread;
fn main() {
let handle = thread::spawn(|| {
// Do expensive work
let mut sum = 0;
for i in 1..=1000000 {
sum += i;
}
sum // Return value from thread
});
println!("Thread spawned, doing other work...");
// Wait for result
let result = handle.join().unwrap();
println!("Thread result: {}", result);
}Return type: thread::spawn returns JoinHandle<T> where T is the closure’s return type.
Important: If you don’t call join(), the thread may be killed when main() exits!
Threads need ownership of captured variables:
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// ❌ ERROR: closure may outlive `data`
let handle = thread::spawn(|| {
println!("{:?}", data);
});
// ✅ CORRECT: use `move` to transfer ownership
let handle = thread::spawn(move || {
println!("{:?}", data); // Thread now owns `data`
});
// data is no longer available here (moved into thread)
handle.join().unwrap();
}Why move is required:
Compute sum using multiple threads:
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
let chunk_size = data.len() / 4;
// Split data into chunks for each thread
let mut handles = vec![];
for chunk_idx in 0..4 {
let start = chunk_idx * chunk_size;
let end = start + chunk_size;
let chunk: Vec<i32> = data[start..end].to_vec();
let handle = thread::spawn(move || {
chunk.iter().sum::<i32>() // Sum this chunk
});
handles.push(handle);
}
// Collect results from all threads
let total: i32 = handles
.into_iter()
.map(|h| h.join().unwrap())
.sum();
println!("Total: {}", total); // 36
}Challenge: Search for a value in parallel
use std::thread;
fn parallel_search(data: Vec<i32>, target: i32, num_threads: usize) -> bool {
// TODO:
// 1. Split data into chunks
// 2. Spawn threads to search each chunk
// 3. Return true if any thread finds target
// Hint: Use handles and check results with .any()
}
// Test:
let data = (1..=100).collect();
assert!(parallel_search(data, 42, 4));
assert!(!parallel_search(vec![1, 2, 3], 10, 2));Try on Rust Playground
Threading isn’t always faster:
Costs of threading:
When to use threads:
✅ Long-running tasks (> 1ms) ✅ I/O-bound operations (network, disk) ✅ Independent computations ❌ Tiny tasks (< 100μs) ❌ Highly interdependent work
Benchmark single vs multi-threaded:
use std::time::Instant;
fn process_single_threaded(data: &[i32]) -> i32 {
data.iter().map(|&x| expensive_computation(x)).sum()
}
fn process_multi_threaded(data: &[i32], num_threads: usize) -> i32 {
// Split and process in parallel (previous example pattern)
}
fn main() {
let data: Vec<i32> = (0..100_000).collect();
// Single-threaded
let start = Instant::now();
let result1 = process_single_threaded(&data);
let duration1 = start.elapsed();
// Multi-threaded
let start = Instant::now();
let result2 = process_multi_threaded(&data, 8);
let duration2 = start.elapsed();
println!("Single: {:?}, Multi: {:?}", duration1, duration2);
println!("Speedup: {:.2}x", duration1.as_secs_f64() / duration2.as_secs_f64());
}What we covered:
✅ Why concurrency matters (utilizing all cores) ✅ Spawning threads with thread::spawn ✅ Joining threads with JoinHandle ✅ Move semantics for thread ownership ✅ Parallel computation patterns ✅ Performance considerations and measurement
Next session: Thread communication with channels and shared state!
Message Passing (Channels):
Shared State (Arc + Mutex):
Rust supports both! Use whichever fits your problem.
Channels enable thread communication:
use std::sync::mpsc; // Multi-Producer, Single-Consumer
use std::thread;
fn main() {
// Create channel
let (tx, rx) = mpsc::channel();
// Spawn thread that sends messages
thread::spawn(move || {
tx.send("Hello from thread!").unwrap();
tx.send("Another message").unwrap();
});
// Receive messages in main thread
let msg1 = rx.recv().unwrap();
let msg2 = rx.recv().unwrap();
println!("{}", msg1);
println!("{}", msg2);
}Key concepts:
tx (transmitter) sends messagesrx (receiver) receives messagesBlocking receive:
Non-blocking receive:
Iterating over messages:
Multiple senders:
Worker threads processing tasks from queue:
use std::sync::mpsc;
use std::thread;
enum Task {
Process(i32),
Shutdown,
}
fn main() {
let (tx, rx) = mpsc::channel();
// Spawn worker thread
let worker = thread::spawn(move || {
for task in rx {
match task {
Task::Process(n) => {
println!("Processing: {}", n);
thread::sleep(std::time::Duration::from_millis(100));
}
Task::Shutdown => {
println!("Shutting down");
break;
}
}
}
});
// Send tasks
for i in 1..=5 {
tx.send(Task::Process(i)).unwrap();
}
tx.send(Task::Shutdown).unwrap();
worker.join().unwrap();
}Arc<Mutex<T>> enables shared mutable state:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc = Atomic Reference Counting (thread-safe Rc)
// Mutex = Mutual Exclusion lock
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap()); // 10
}Key concepts:
Arc: Multiple owners (thread-safe reference counting)Mutex: One thread at a time can access data.lock(): Acquire lock (blocks if held by another thread)Single-threaded (last week):
Multi-threaded (this week):
Comparison:
| Type | Thread-Safe? | Purpose |
|---|---|---|
Rc<T> |
❌ No | Reference counting (single-thread) |
Arc<T> |
✅ Yes | Atomic reference counting |
RefCell<T> |
❌ No | Runtime borrow checking |
Mutex<T> |
✅ Yes | Mutual exclusion lock |
Performance cost:
Arc: Slightly slower than Rc (atomic operations)Mutex: Lock acquisition overheadLock scope management:
// ❌ BAD: Lock held too long
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let mut guard = data.lock().unwrap();
guard.push(4);
expensive_computation(); // Lock still held!
guard.push(5);
// ✅ GOOD: Minimize lock scope
{
let mut guard = data.lock().unwrap();
guard.push(4);
} // Lock released here
expensive_computation(); // No lock held
{
let mut guard = data.lock().unwrap();
guard.push(5);
}Avoiding deadlocks:
When to use channels (message passing):
✅ Producer-consumer patterns ✅ Task distribution (work queue) ✅ Pipeline architectures ✅ When ownership transfer makes sense ✅ Simpler reasoning about data flow
When to use Arc<Mutex
✅ Multiple threads reading/writing same data ✅ Performance-critical (no message copy overhead) ✅ When channels feel awkward ✅ Familiar from other languages
Hybrid approach:
Use both! Channels for coordination, shared state for critical data.
Rust philosophy: Prefer message passing when possible.
“Do not communicate by sharing memory; instead, share memory by communicating.” — Go proverb (applies to Rust too!)
Challenge: Implement a thread-safe counter
use std::sync::{Arc, Mutex};
use std::thread;
fn parallel_increment(num_threads: usize, increments_per_thread: usize) -> i32 {
// TODO:
// 1. Create Arc<Mutex<i32>> counter starting at 0
// 2. Spawn num_threads threads
// 3. Each thread increments counter increments_per_thread times
// 4. Return final value
// Expected: num_threads * increments_per_thread
}
// Test:
assert_eq!(parallel_increment(10, 1000), 10_000);Try on Rust Playground
Reusing threads is more efficient than spawning for every task:
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
struct ThreadPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Job>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
struct Worker {
id: usize,
thread: thread::JoinHandle<()>,
}
impl ThreadPool {
fn new(size: usize) -> ThreadPool {
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool { workers, sender }
}
fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
self.sender.send(Box::new(f)).unwrap();
}
}Signaling threads to stop:
Approach 1: Shutdown message:
Approach 2: Drop channel:
Combining threads, channels, and shared state:
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use std::fs;
struct SearchResult {
file: String,
line_num: usize,
content: String,
}
fn parallel_grep(pattern: &str, files: Vec<String>, num_workers: usize) -> Vec<SearchResult> {
let (task_tx, task_rx) = mpsc::channel();
let (result_tx, result_rx) = mpsc::channel();
let task_rx = Arc::new(Mutex::new(task_rx));
// Spawn workers
for _ in 0..num_workers {
let task_rx = Arc::clone(&task_rx);
let result_tx = result_tx.clone();
let pattern = pattern.to_string();
thread::spawn(move || {
while let Ok(file) = task_rx.lock().unwrap().recv() {
if let Ok(contents) = fs::read_to_string(&file) {
for (i, line) in contents.lines().enumerate() {
if line.contains(&pattern) {
result_tx.send(SearchResult {
file: file.clone(),
line_num: i + 1,
content: line.to_string(),
}).unwrap();
}
}
}
}
});
}
// Send tasks
for file in files {
task_tx.send(file).unwrap();
}
drop(task_tx); // Close channel
drop(result_tx); // Close results channel after workers
// Collect results
result_rx.iter().collect()
}Challenge: Concurrency bugs are non-deterministic!
Strategies:
1. Test for correctness:
2. Stress testing:
3. Test synchronization primitives:
Rust prevents at compile-time:
✅ Data races (prevented by borrow checker) ✅ Use-after-free (prevented by ownership) ✅ Double-free (prevented by Drop semantics)
Rust doesn’t prevent:
❌ Deadlocks (circular lock dependencies) ❌ Livelocks (threads spinning without progress) ❌ Starvation (threads never getting locks) ❌ Logic errors (incorrect synchronization)
Best practices:
cargo-tsan (ThreadSanitizer)Reduce lock contention:
Batch operations:
Use atomic types for counters:
Build a parallel file processor (parallel grep):
Features you’ll implement:
Key skills applied:
clapIndustries using concurrent Rust:
Systems Programming:
Cloud Infrastructure:
Embedded/IoT:
Interview topics:
Threading fundamentals:
✅ Spawning threads and join handles ✅ Move semantics for thread ownership ✅ Measuring performance gains
Thread communication:
✅ Message passing with channels ✅ Shared state with Arc<Mutex<T>> ✅ Thread pools and graceful shutdown ✅ Real-world patterns and testing
Key takeaways:
Congratulations on completing the Rust portion of the course!
Official Documentation:
Advanced Topics:
Practice:
Office hours: See syllabus
AI assistance: GitHub Copilot, ChatGPT, Claude, Gemini CLI
Lab 14: Build your parallel file processor - due Sunday at 11:59 PM
Final thoughts:
Great work this semester! 🦀
IS4010: App Development with AI