class: title # Concurrency in Rust ## Scott Rixner and Alan Cox --- layout: true --- ## Concurrency in Rust * Rust promises "fearless concurrency" through compile-time guarantees.. * Rust makes concurrency safer, but not simpler or bug-free. We will focus on three major themes: 1. Thread safety. 2. Fault containment. 3. The high-performance reality. --- ## Safety **Memory Safety = Thread Safety** * Data race: Two threads access the same memory location, at least one is a write, and the operations are unsynchronized. * This is undefined behavior in C and C++. * This is impossible in safe Rust. * The ownership rules that prevent memory errors also prevent data races. * The compiler enforces that you cannot have multiple mutable references to the same data, even across threads. --- ## Data Races vs Race Conditions * Data race: Memory-level conflict (undefined behavior). * Prevented by the compiler. * Race condition: Flaw in timing/ordering of events affecting correctness. * Example: A transaction checks balance, then withdraws. * This is a logic error, not a memory error. * Possible in safe Rust. --- ## Rust Threads ```rust use std::thread; fn main() { let handle = thread::spawn(|| { println!("Thread doing work..."); "finished" }); println!("Main thread doing work..."); let result = handle.join().unwrap_or("thread panicked"); println!("Thread result: {}", result); } ``` * Uses a 1:1 threading model. * `std::thread::spawn` takes a closure that is executed by the new thread. * Call `join()` on the returned thread handle to wait for the thread to finish. --- ## Communicating Data Across Threads * How long does the spawned thread live? * The compiler can't know, so it assumes it might outlive the current function. * Therefore, spawned threads cannot borrow from the stack! --- ## Transferring Ownership: `move` ```rust let v = vec![1, 2, 3]; // v is moved into the closure thread::spawn(move || { println!("Here's a vector: {:?}", v); }); // println!("{:?}", v); // Error: v was moved! ``` * **Move**: Transfer ownership to the new thread. * The spawned thread now owns the data; no other thread can access it. * No shared access = no data races. --- ## Sharing Data: `Arc
>` ```rust use std::sync::{Arc, Mutex}; let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let ctr = Arc::clone(&counter); let handle = thread::spawn(move || { let mut num = ctr.lock().unwrap(); *num += 1; }); handles.push(handle); } ``` * **`Arc
`**: Atomic reference counting for shared ownership. * **`Mutex
`**: Wraps the data and enforces mutual exclusion. * You *cannot* access the data without locking. --- ## Communicating Data: `mpsc::channel` ```rust use std::sync::mpsc; // multiple producer, single consumer let (tx, rx) = mpsc::channel(); thread::spawn(move || { let val = String::from("hi"); tx.send(val).unwrap(); // val is moved! }); let received = rx.recv().unwrap(); // Blocks ``` * Go Proverb: "Don't communicate by sharing memory, share memory by communicating." * **Channels**: Transfer ownership of data between threads. * `send()` moves the data; the sender can no longer use it. --- ## Channel Details: Bounded vs Unbounded * **Rust Channels:** * `mpsc::channel()`: Unbounded (Sender *never* blocks; can cause OOM). * `mpsc::sync_channel(10)`: Bounded (Sender blocks if buffer is full). * `mpsc::sync_channel(0)`: Rendezvous (like Go's unbuffered channel). * **No `select`:** Rust has no built-in way to wait on multiple channel operations. * Go's `select` allows waiting on multiple channel operations. * Coordination across multiple channels is significantly harder in Rust. --- ## `Send` and `Sync` Traits How does the compiler know which types are safe to move or share? * `Send`: Ownership of the type can be transferred between threads. * `Sync`: Safe to access via a shared reference (`&T`) from multiple threads. * `T` is `Sync` if and only if `&T` is `Send`. * Automatically implemented by the compiler for types composed of exclusively `Send`/`Sync` parts. * These are **unsafe** traits: You can implement them for types that are not actually thread-safe, but you must ensure correctness yourself. --- class: middle ## Discussion: Safety Three ways to share data across threads: 1. **Move** ownership to a single thread. 2. **`Arc
>`** for shared mutable state. 3. **Channels** for message passing. How does each approach prevent data races? Which approach makes it easiest to reason about correctness? What does Rust make easier/safer? --- ## Fault Containment **Structured Failure and Isolation** * In many languages, an unhandled exception in one thread might crash the entire process. * Rust threads act as a boundary for panics and failures. * Two key mechanisms: 1. **Panic Isolation**: Thread panics don't crash the program. 2. **Lock Poisoning**: Detect inconsistent state after a panic. --- ## Panic Isolation ```rust let handle = thread::spawn(|| { panic!("Something went wrong!"); }); // join() returns Result
> match handle.join() { Ok(_) => println!("Thread finished successfully"), Err(_) => println!("Thread panicked, but fault was contained."), } ``` * A panic in a spawned thread stops that thread, but the main thread continues. * `join()` returns a `Result`: converts a catastrophic failure into a manageable error. * This enables **recovery**: You can restart the thread or try an alternative strategy. --- ## Lock Poisoning What if a thread panics while holding a lock? The data inside might be partially modified and inconsistent. ```rust use std::sync::Mutex; let m = Mutex::new(5); { let mut num = m.lock().unwrap(); // Panic if poisoned. *num = 6; } ``` * `lock()` returns `Result
`. * If a thread panics while holding a lock, the lock becomes **poisoned**. --- ## Lock Poisoning Semantics * Fault containment in action: * Prevents other threads from observing potentially inconsistent data. * You are forced to handle the fact that a previous operation failed mid-stream. * Usually, you just `unwrap()` to propagate the panic, but you could attempt to recover. * **Example**: If a bank transaction panics while updating an account, the lock is poisoned to prevent further transactions from seeing an inconsistent balance. --- ## Channels and Disconnection ```rust // Non-blocking check match rx.try_recv() { Ok(msg) => println!("Received: {}", msg), Err(TryRecvError::Empty) => println!("Channel empty"), Err(TryRecvError::Disconnected) => println!("Channel closed"), } ``` * When all senders drop, the channel is automatically closed. * Receivers get a `Disconnected` error: another form of fault containment. * You know that no more messages are coming and can act accordingly. --- class: middle ## Discussion: Fault Containment How do `JoinHandle`, lock poisoning, and channel disconnection enable structured failure handling? What are the trade-offs between message passing and shared-memory concurrency when fault containment is your primary goal? Does one make recovery easier than the other? --- ## Performance and Scaling **When Mutexes Limit Scaling** * In systems programming, lock contention on a `Mutex` is often unacceptable. * **Solution:** Lock-free data structures using **Atomics**. * **Atomics** provide hardware-level synchronization. ```rust use std::sync::atomic::{AtomicUsize, Ordering}; static COUNTER: AtomicUsize = AtomicUsize::new(0); fn main() { COUNTER.fetch_add(1, Ordering::SeqCst); } ``` --- ## Atomic Memory Ordering * The `Ordering` enum argument (`Relaxed`, `Acquire`, `Release`, `AcqRel`, `SeqCst`) controls how the CPU/Compiler views memory. * This is extremely subtle. * Incorrect ordering leads to bugs that are hard to reproduce. * If you don't know what you are doing (and you don't), use `SeqCst` (Sequential Consistency) or just use a `Mutex`. --- ## Atomics in Data Structures * Atomics are the building blocks for lock-free data structures: * Lock-free queues, stacks, hash tables, etc. * Higher throughput and lower latency than mutex-based alternatives. * **The Catch:** Extremely difficult to implement correctly. --- ## The Reality: Rust Provides No Help * Rust guarantees you won't have data races (memory safety). * Rust does **not** guarantee your lock-free algorithm is correct. * Problems: * Rust provides no protection against race conditions and logic errors. * `unsafe` code must protect itself from logic errors in safe code. * If your `unsafe` block assumes a certain sequence of calls, but safe code calls them out of order, you might get undefined behavior. --- ## The "Unsafe" Reality Most high-performance lock-free structures in Rust (like `crossbeam` or `dashmap`) rely heavily on `unsafe`. * The programmer must manually verify that the atomic invariants hold across all possible thread interleavings and ensure consistency. * You must reason about: * Linearization points. * Memory ordering semantics (Acquire, Release, SeqCst, Relaxed). * The interaction between your atomic operations and the rest of your code. * ... --- ## Does Rust Help with Lock-Free? **Yes, but it is not "fearless."** 1. **Safety Wrappers:** You can wrap complex, `unsafe` lock-free logic in a **Safe API**. 2. **Type System:** `Send` and `Sync` still apply. You can't accidentally share a non-thread-safe type even if you are using atomics. 3. **Lifetimes:** The borrow checker helps manage the lifetime of data *around* the atomic operations, which is the hardest part of lock-free programming. --- ## Lock-Free Patterns: RCU **Read-Copy-Update (RCU):** * Common in high-performance systems (like Linux). * **Readers:** Never block, just read the current pointer. * **Writers:** Copy data -> Modify copy -> Atomic swap pointer. * **The Grace Period:** You must wait for all old readers to finish before deleting the old data. * Simplified by garbage collection. * Rust's ownership and lifetimes make managing the "Grace Period" much less error-prone than in C. --- class: middle ## Discussion: Performance and Scaling Atomics and lock-free data structures require `unsafe` code and manual reasoning about correctness. If Rust's compiler cannot check the logical invariants of your lock-free code, what value does the type system actually provide? Are you better off than in C? Than in Java/Go? --- ## Summary: A Balanced View | Feature | The Pro (Safe Rust) | The Con (The Reality) | | :--- | :--- | :--- | | **Data Races** | Guaranteed impossible. | Logic races can still happen. | | **Failures** | Contained via `join` and lock poisoning. | Recovery logic is often complex. | | **Lock-Free** | Safe wrappers around atomics. | `unsafe` logic is brittle and hard to test. | | **Threads** | `Send`/`Sync` prevents misuse. | Deadlocks are just as easy as in C. | **The Goal:** Not "fearless" programming, but **principled** programming. Use the type system to mark boundaries and contain complexity.