advanced90 minutesLesson 1 of 10

Threading — spawn, join, and Send + Sync

Master OS threads in Rust: spawn, join, move closures, scoped threads, and the Send + Sync traits for data-race freedom

Threading — spawn, join, and Send + Sync

Rust provides OS threads through std::thread. Combined with the ownership system, it guarantees data-race freedom at compile time — no other language offers this without a runtime or garbage collector.

Basic Threading

rust
use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { for i in 1..10 { println!("child: {i}"); thread::sleep(Duration::from_millis(1)); } }); for i in 1..5 { println!("main: {i}"); thread::sleep(Duration::from_millis(1)); } handle.join().unwrap(); // Wait for child to finish }
ℹ️Note

When the main thread exits, all child threads are terminated regardless of their state. Always join() threads if you need them to complete.

Thread Handle API

rust
use std::thread; fn main() { let handle: thread::JoinHandle<i32> = thread::spawn(|| { // Do work 42 // Return value from thread }); // Block until thread finishes, get the result match handle.join() { Ok(result) => println!("Thread returned: {result}"), Err(e) => eprintln!("Thread panicked: {:?}", e), } // Non-blocking check (thread is detached if dropped) let handle = thread::spawn(|| {}); let is_finished = handle.is_finished(); // Check without blocking }

Move Closures with Threads

Threads can outlive their spawning scope. The move keyword transfers ownership:

rust
use std::thread; fn main() { let v = vec![1, 2, 3]; // ERROR: closure may outlive the enclosing function // thread::spawn(|| { // println!("{:?}", v); // }); // FIX: move ownership to the thread let handle = thread::spawn(move || { println!("{:?}", v); }); handle.join().unwrap(); // println!("{:?}", v); // ERROR: v was moved }
⚠️Warning

move forces the closure to take ownership of all captured variables. If you need to share access (not move), use Arc (covered in shared state).

Partial Move with Threads

rust
use std::thread; fn main() { let name = String::from("worker"); let data = vec![1, 2, 3]; // Can't selectively move — all captured variables are moved let handle = thread::spawn(move || { println!("{name} processing: {:?}", data); }); handle.join().unwrap(); }

Thread Builder — Customizing Threads

rust
use std::thread; fn main() { let builder = thread::Builder::new() .name("worker-1".into()) .stack_size(1024 * 1024); // 1 MB stack let handle = builder.spawn(|| { println!("Running in: {:?}", thread::current().name()); 42 }).unwrap(); println!("Result: {}", handle.join().unwrap()); }
Builder MethodPurpose
.name(name)Set thread name (useful for debugging)
.stack_size(size)Set stack size (default: 2MB)
.spawn(f)Create the thread

Scoped Threads

thread::scope allows borrowing data without move:

rust
use std::thread; fn main() { let v = vec![1, 2, 3]; let mut results = vec![]; // Scoped threads can borrow from the parent thread::scope(|s| { s.spawn(|| { // Can borrow v without move results.push(v.iter().sum::<i32>()); }); s.spawn(|| { results.push(v.iter().product::<i32>()); }); }); // All threads join here println!("{results:?}"); // [6, 6] println!("{v:?}"); // v still accessible }
Success
| Feature | `thread::spawn` | `thread::scope` | |---------|-----------------|-----------------| | Captures | Must be `'static` | Can borrow locally | | Auto-join | No | Yes (at scope end) | | Return values | Via JoinHandle | Via spawned handle |

Thread Communication — Basic Patterns

rust
use std::thread; fn main() { // Compute in parallel, collect results let mut handles = vec![]; for i in 0..5 { let handle = thread::spawn(move || { i * i }); handles.push(handle); } let results: Vec<i32> = handles .into_iter() .map(|h| h.join().unwrap()) .collect(); println!("{results:?}"); // [0, 1, 4, 9, 16] }

Send and Sync Traits

These marker traits are at the heart of Rust's thread safety:

TraitMeaningAuto-implemented?
SendOwnership can be transferred between threadsYes (unless Rc, raw pointers, etc.)
SyncShared reference &T can be sent between threadsYes (unless Cell, RefCell, etc.)
rust
use std::thread; use std::rc::Rc; fn main() { // Rc is NOT Send — can't move between threads // let rc = Rc::new(5); // thread::spawn(move || { println!("{rc}"); }); // Arc IS Send — can be moved between threads let arc = std::sync::Arc::new(5); let arc_clone = arc.clone(); thread::spawn(move || { println!("{}", arc_clone); }).join().unwrap(); }

How the Compiler Enforces Thread Safety

rust
use std::cell::Cell; use std::thread; struct NotSync { cell: Cell<i32>, } // NotSync is !Sync because Cell is !Sync fn main() { let ns = NotSync { cell: Cell::new(42) }; // ERROR: NotSync cannot be shared between threads safely // thread::scope(|s| { // s.spawn(|| { // println!("{}", ns.cell.get()); // }); // }); }
⚠️Warning
| Type | Send? | Sync? | Reason | |------|-------|-------|--------| | Rc<T> | No | No | Non-atomic ref counting | | Arc<T> | Yes | Yes | Atomic ref counting | | Cell<T> | Yes | No | Interior mutability, no sync | | RefCell<T> | Yes | No | Interior mutability, no sync | | Mutex<T> | Yes | Yes | Provides Sync | | `AtomicBool` | Yes | Yes | Atomic operations |

Thread-Local Storage

rust
use std::cell::RefCell; thread_local! { static COUNTER: RefCell<u32> = RefCell::new(0); } fn main() { // Each thread gets its own COUNTER let h1 = std::thread::spawn(|| { COUNTER.with(|c| { *c.borrow_mut() = 42; println!("Thread 1: {}", c.borrow()); }); }); let h2 = std::thread::spawn(|| { COUNTER.with(|c| { println!("Thread 2: {}", c.borrow()); // 0, not 42 }); }); h1.join().unwrap(); h2.join().unwrap(); }

Real-World: Parallel Image Processing

rust
use std::thread; fn process_pixel(pixel: u8) -> u8 { // Simulate expensive computation pixel.saturating_mul(2) } fn process_image_parallel(image: Vec<u8>, num_threads: usize) -> Vec<u8> { let chunk_size = (image.len() + num_threads - 1) / num_threads; let mut handles = vec![]; for chunk in image.chunks(chunk_size) { let chunk = chunk.to_vec(); handles.push(thread::spawn(move || { chunk.into_iter().map(process_pixel).collect::<Vec<_>>() })); } let mut result = Vec::with_capacity(image.len()); for handle in handles { result.extend(handle.join().unwrap()); } result } fn main() { let image: Vec<u8> = (0..100).collect(); let processed = process_image_parallel(image.clone(), 4); println!("Original: {:?}", &image[..10]); println!("Processed: {:?}", &processed[..10]); }

Practice Questions

  1. How do you create a thread and wait for it to finish?
  2. Why must closures passed to thread::spawn use move?
  3. What's the difference between thread::spawn and scoped threads?
  4. What does handle.join() return?
  5. What are the Send and Sync traits?
  6. Which standard types are NOT Send? Why?
  7. What is scoped threading (thread::scope) and when is it useful?
  8. How do you customize a thread's name and stack size?
  9. How does the compiler prevent data races in threaded code?
  10. What is thread-local storage and how is it used?
Progress10%