Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 22: Concurrency with Operating System Threads

Concurrency enables software to handle multiple tasks by allowing them to make progress independently, often improving responsiveness and throughput. This is crucial for modern applications, such as servers managing multiple client connections or computational tools utilizing multi-core processors for faster results. However, traditional languages like C and C++ present significant challenges in concurrent programming, primarily due to the risks of data races and deadlocks. These issues often manifest as difficult-to-reproduce runtime errors or undefined behavior, demanding meticulous programmer discipline and extensive debugging.

Rust confronts these challenges head-on through its ownership and type system, enabling what the community often calls fearless concurrency. By enforcing strict rules about data access at compile time, Rust eliminates data races—a major category of concurrency bugs—in safe code. This chapter delves into Rust’s approach to concurrency using operating system (OS) threads. We will cover thread creation and management, synchronization primitives (Mutex, RwLock, Condvar, atomics), strategies for sharing data between threads (Arc, scoped threads), message passing via channels, data parallelism facilitated by the Rayon library, and a brief introduction to SIMD for instruction-level parallelism. The discussion of async tasks, another concurrency model in Rust suited for I/O-bound workloads, will be deferred to a subsequent chapter. Throughout this chapter, we will draw comparisons to C and C++ concurrency models to highlight Rust’s safety mechanisms and how they differ.


22.1 Concurrency Fundamentals: Concepts, Processes, and Threads

Before diving into Rust’s concurrency primitives, it’s important to understand the core concepts of concurrency itself and how operating systems use processes and threads to structure concurrent execution.

22.1.1 Understanding Concurrency

Concurrency is the concept of structuring a program as multiple independent tasks that can execute overlapping in time. On systems with a single CPU core, this overlap is achieved by the operating system rapidly switching between tasks (interleaving), creating the illusion of simultaneous execution. On multi-core systems, concurrency can lead to parallelism, where tasks truly execute simultaneously on different cores, potentially reducing overall execution time.

Writing correct concurrent programs requires careful management of shared resources to prevent common problems:

  • Race Conditions: Occur when the program’s outcome depends on the unpredictable sequence or timing of operations (particularly reads and writes) performed by different threads on shared data. A specific type, the data race, involves concurrent, unsynchronized access to the same memory location where at least one access is a write.
  • Deadlocks: Occur when two or more threads are blocked indefinitely, each waiting for a resource that is held by another thread within the same cycle of dependencies.

In C and C++, preventing, detecting, and fixing these issues often relies heavily on programmer discipline, code reviews, and runtime analysis tools, as the compiler offers limited assistance. Data races, in particular, lead to undefined behavior. Rust fundamentally changes this dynamic. Its ownership and borrowing rules, enforced at compile time, guarantee that data races cannot occur in safe Rust code. Any code attempting unsynchronized access that could lead to a data race will simply fail to compile.

22.1.2 Processes vs. Threads

Two primary abstractions for concurrent execution provided by operating systems are processes and threads:

  • Processes: An instance of a running program. Each process typically has its own independent virtual address space, file descriptors, and other system resources allocated by the OS. Communication between processes (Inter-Process Communication or IPC) is mediated by the OS using mechanisms like pipes, sockets, or shared memory segments. This isolation provides safety but incurs overhead for context switching and communication.
  • Threads (specifically, OS threads or kernel threads): Represent independent execution paths within a single process. Threads belonging to the same process share the same virtual address space (including code, heap, and global variables) and resources like file descriptors. This shared environment facilitates easy data exchange but significantly increases the risk of data races if mutable data is accessed without proper synchronization. Thread context switching is generally less expensive than process context switching.

Rust’s standard library focuses on thread-based concurrency, providing primitives that integrate with the language’s safety features. Types like Mutex<T>, RwLock<T>, and Arc<T> leverage the type system to enforce safe access patterns to shared data, preventing data races at compile time – a stark contrast to the manual synchronization required in C/C++ where mistakes easily lead to runtime errors.


22.2 Concurrency vs. Parallelism in Rust

While often used interchangeably, concurrency and parallelism are distinct concepts:

  • Concurrency: Is about dealing with multiple tasks by allowing them to make progress independently, managing potentially overlapping execution. It’s primarily about program structure.
  • Parallelism: Is about executing multiple tasks simultaneously, typically leveraging multiple CPU cores to achieve speedup. It’s primarily about execution performance.

A program can be concurrent without being parallel. For instance, a web server on a single-core CPU can concurrently handle multiple clients using task switching, but only one task executes at any given instant. Parallelism requires hardware with multiple processing units.

Rust supports concurrency mainly through two distinct models:

  1. OS Threads (std::thread): These map closely to the native threads provided by the operating system. They are scheduled preemptively by the OS. This model is generally well-suited for CPU-bound tasks where true parallel execution across multiple cores can yield significant performance benefits. This is the focus of this chapter.
  2. Async Tasks (async/.await): These are lightweight tasks scheduled cooperatively by an async runtime library (like Tokio, async-std). They are particularly effective for I/O-bound workloads, where many tasks spend time waiting for external events (e.g., network responses, file I/O). Async tasks allow a small number of OS threads to manage a very large number of concurrent operations efficiently. This model will be covered in a later chapter.

Additionally, libraries like Rayon build upon OS threads to provide higher-level abstractions specifically for data parallelism, simplifying the task of parallelizing computations over collections.


22.3 Choosing the Right Model: Threads vs. Async for I/O-Bound vs. CPU-Bound Tasks

The choice between using OS threads (std::thread) and async tasks often depends on whether the concurrent tasks are primarily I/O-bound or CPU-bound.

22.3.1 OS Threads (std::thread)

Native OS threads, as managed by std::thread, are preemptively scheduled by the operating system kernel.

  • Best Suited For: CPU-bound tasks. Computationally intensive work (e.g., complex calculations, data processing, simulations) can run in parallel on different cores, potentially leading to substantial speedups on multi-core hardware. If one OS thread blocks (e.g., waiting for synchronous I/O or a lock), the OS can schedule other threads to run.
  • Drawbacks: Creating and managing OS threads incurs overhead. Each thread requires its own stack (consuming memory), and context switching between threads involves the OS scheduler, which has a performance cost. Spawning a very large number of threads (thousands or more) can become inefficient or hit OS limits. For workloads involving many short-lived tasks or tasks that mostly wait, OS threads might not scale well. A common pattern to mitigate this is using a thread pool, which maintains a fixed number of reusable worker threads.

Note: In Rust, if a thread created with std::thread::spawn panics, it terminates only that specific thread. The main thread or other threads can detect this panic if they call join() on the panicked thread’s JoinHandle; join() will return an Err value containing the panic payload. This allows for more controlled error handling compared to C/C++ where an unhandled exception or signal in one thread might terminate the entire process depending on the context and platform.

22.3.2 Async Tasks (async/.await) (Brief Overview)

Async tasks use cooperative scheduling, managed by a user-space runtime library.

  • Best Suited For: I/O-bound tasks. When an async task needs to wait for an external event (like network data arrival or a timer), it yields control using .await, allowing the runtime to schedule another task on the same OS thread. This enables a small pool of OS threads to handle potentially thousands or millions of concurrent operations efficiently, as threads don’t remain idle while waiting. Context switching between async tasks within the same OS thread is significantly cheaper than switching OS threads.
  • Drawbacks: If an async task performs a long, CPU-intensive computation without yielding (i.e., without reaching an .await point), it can “starve” other tasks scheduled on the same OS thread, preventing them from making progress. This is often referred to as “blocking the executor.” CPU-bound work within an async context is usually best delegated to a dedicated thread pool (e.g., using functions like tokio::task::spawn_blocking or integrating with Rayon).

22.3.3 Matching Concurrency Model to Workload

  • I/O-Bound Tasks (e.g., network servers/clients, database interactions, file system operations): Often spend most of their time waiting. Async tasks generally offer better scalability and resource efficiency.
  • CPU-Bound Tasks (e.g., scientific computing, image/video processing, cryptography, complex algorithms): Spend most of their time performing calculations. OS threads (managed directly, via thread pools, or through libraries like Rayon) are typically preferred to leverage true hardware parallelism across multiple cores.

Many real-world applications involve a mix. For example, a web server might use async tasks for handling network connections and I/O, but use a thread pool (like Rayon’s) to execute CPU-intensive parts of request processing. Rust’s safety guarantees apply regardless of the chosen model when dealing with shared data.


22.4 Creating and Managing OS Threads

Rust’s standard library module std::thread provides the API for working with OS threads. Conceptually, it’s similar to POSIX threads (pthreads) in C or std::thread in C++, but Rust’s ownership and lifetime rules provide stronger compile-time safety guarantees.

22.4.1 Spawning Threads with std::thread::spawn

The core function for creating a new thread is std::thread::spawn. It accepts a closure (or function pointer) containing the code the new thread will execute. The closure must have a 'static lifetime, meaning it cannot capture references to local variables in the spawning thread’s stack frame unless those variables are guaranteed to live for the entire duration of the program—such as string literals or heap-allocated values that have been intentionally leaked (i.e., never deallocated). This restriction is crucial for preventing use-after-free errors if the spawning thread finishes before the spawned thread. To transfer ownership of data from the spawning thread to the new thread, use a move closure.

spawn returns a JoinHandle<T>, where T is the return type of the closure. The JoinHandle allows the creating thread to wait for the spawned thread to complete and retrieve its result.

use std::thread;
use std::time::Duration;

fn main() {
    // Spawn a new thread
    let handle: thread::JoinHandle<()> = thread::spawn(|| {
        for i in 1..5 {
            println!("Hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
        // No return value, so JoinHandle<()>
    });

    // Code in the main thread runs concurrently
    for i in 1..3 {
        println!("Hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    // Wait for the spawned thread to finish.
    // join() blocks the current thread until the spawned thread terminates.
    // It returns Result<T, Box<dyn Any + Send + 'static>>.
    // Ok(T) contains the return value of the thread's closure.
    // Err contains the panic payload if the thread panicked.
    // We use expect() here for simplicity, assuming success.
    handle.join().expect("Spawned thread panicked");
    println!("Spawned thread finished.");
}

Key Points:

  • The closure passed to spawn runs concurrently with the calling thread (main).
  • thread::sleep pauses the current thread, allowing the OS to schedule others.
  • handle.join() blocks the calling thread until the spawned thread completes. It’s analogous to pthread_join in C or thread::join in C++. The Result return type provides integrated panic handling.

To pass data to a thread or return data from it, use move closures and return values:

use std::thread;

fn main() {
    let data = vec![1, 2, 3];

    // The 'move' keyword transfers ownership of 'data' into the closure.
    // The closure now owns 'data'.
    let handle = thread::spawn(move || {
        // This closure requires 'static lifetime because spawn creates
        // a thread that can outlive the main function scope without join().
        // 'move' ensures captured variables (like data) are owned,
        // satisfying the 'static requirement for owned types.
        let sum: i32 = data.iter().sum();
        println!("Spawned thread processing data (length {})...", data.len());
        sum // Return the sum
    });

    // Accessing 'data' here in main thread is a compile-time error
    // because ownership was moved to the spawned thread's closure.
    // # println!("{:?}", data); // Uncommenting causes compile error

    match handle.join() {
        Ok(result) => {
            println!("Sum calculated by spawned thread: {}", result);
        }
        Err(e) => {
            // The error 'e' is Box<dyn Any + Send>, representing the panic value.
            eprintln!("Spawned thread panicked!");
            // You could try to downcast 'e' to a specific type if needed.
        }
    }
}

The 'static lifetime requirement for spawn sometimes necessitates using techniques like Arc (discussed later) to share data that needs to be accessed by both the parent and child threads, or using scoped threads (also discussed later) if borrowing is sufficient and the child thread is guaranteed to finish before the data goes out of scope.

Tip: Directly spawning OS threads can be resource-intensive. For managing many small, independent tasks, consider using a thread pool. Crates like rayon (covered later) provide an implicit global thread pool, while others like threadpool allow explicit pool creation and management.

22.4.2 Configuring Threads with Builder

The std::thread::Builder allows customizing thread properties like name and stack size before spawning.

use std::thread;
use std::time::Duration;

fn main() {
    let builder = thread::Builder::new()
        .name("worker-alpha".into()) // Set a descriptive thread name
        .stack_size(32 * 1024);
        // Request a 32 KiB stack (OS may enforce minimum/adjust)

    // Use builder.spawn instead of thread::spawn
    let handle = builder.spawn(|| {
        let current_thread = thread::current();
        println!("Thread {:?} starting work.", current_thread.name());
        // Perform work...
        thread::sleep(Duration::from_millis(100));
        println!("Thread {:?} finished.", current_thread.name());
        42 // Return a value
    }).expect("Failed to spawn thread");
    // Builder::spawn can fail (e.g., stack size too small)

    let result = handle.join().expect("Worker thread panicked");
    println!("Worker thread returned: {}", result);
}

Setting thread names is very helpful for debugging and monitoring concurrent applications, as tools like htop, debuggers (GDB, LLDB), and profilers can display these names. Adjusting stack size is less common but might be needed for threads with deep recursion or large stack-allocated data structures. Use custom stack sizes judiciously, as the default is usually adequate and overallocating wastes memory.


22.5 Sharing Data Safely Between Threads

A primary challenge in threaded programming is safely managing access to data shared between threads. Rust’s type system and standard library provide several primitives that guarantee data race freedom in safe code.

22.5.1 Shared Ownership: Arc<T>

When multiple threads need to own or have long-term access to the same piece of data on the heap, Arc<T> (Atomically Reference Counted) is the tool of choice. It’s a thread-safe version of Rc<T>. Arc<T> provides shared ownership of a value of type T by maintaining a reference count that is updated using atomic operations, making it safe to clone and share across threads.

  • Arc<T> can be cloned (Arc::clone(&my_arc)). Cloning increments the atomic reference count and returns a new Arc<T> pointer to the same allocation.
  • When an Arc<T> pointer is dropped, the reference count is atomically decremented.
  • The inner value T is dropped only when the reference count reaches zero.
  • For Arc<T> to be sendable between threads (Send) or accessible from multiple threads (Sync), the inner type T must itself be Send + Sync.

Arc<T> provides shared immutable access by default. To allow mutation of the shared data, Arc is typically combined with interior mutability types that provide synchronization, such as Mutex or RwLock.

22.5.2 Mutual Exclusion: Mutex<T>

A Mutex<T> (Mutual Exclusion) ensures that only one thread can access the data T it protects at any given time. To access the data, a thread must first acquire the mutex’s lock. Acquiring the lock provides an exclusive reference to the inner data T.

  • lock(): Attempts to acquire the lock. If the lock is already held by another thread, the current thread will block until the lock becomes available. It returns a Result<MutexGuard<T>, PoisonError<MutexGuard<T>>>.
    • A Mutex becomes “poisoned” if a thread panics while holding the lock. Subsequent calls to lock() on a poisoned mutex will return an Err(PoisonError). Using unwrap() on the result will propagate the panic, which is often the desired behavior to avoid operating on potentially inconsistent state. You can also handle the PoisonError explicitly if needed.
  • MutexGuard<T>: A smart pointer returned by a successful lock() call. It implements Deref and DerefMut, allowing exclusive access to the protected data T. Crucially, it also implements Drop. When the MutexGuard goes out of scope, its Drop implementation automatically releases the lock. This RAII (Resource Acquisition Is Initialization) pattern prevents accidentally forgetting to release the lock, a common bug in C/C++.

The standard pattern for sharing mutable state across threads is Arc<Mutex<T>>: Arc handles the shared ownership, and Mutex handles the synchronized exclusive access for mutation.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Wrap the counter in Mutex for synchronized exclusive access,
    // and Arc for shared ownership across threads.
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for i in 0..10 {
        // Clone the Arc pointer. This increases the reference count.
        // The new Arc points to the same Mutex in memory.
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            // Acquire the lock. Blocks if another thread holds it.
            // unwrap() panics if the mutex was poisoned.
            // This returns a MutexGuard, which gives exclusive access to the inner i32
            let mut num: std::sync::MutexGuard<i32> = counter_clone.lock().unwrap();

            // Access the data via the MutexGuard (dereferences to &mut i32).
            *num += 1;
            println!("Thread {} incremented count to {}", i, *num);

            // The lock is automatically released when 'num' (the MutexGuard)
            // goes out of scope at the end of this block (RAII).
        });
        handles.push(handle);
    }

    // Wait for all threads to complete their work.
    for handle in handles {
        handle.join().unwrap();
    }

    // Lock the mutex in the main thread to read the final value.
    // Need .lock() even for reading, as Mutex provides exclusive access.
    println!("Final count: {}", *counter.lock().unwrap()); // Should be 10
}

22.5.3 Read-Write Locks: RwLock<T>

An RwLock<T> (Read-Write Lock) offers more flexible locking than a Mutex. It allows multiple threads to hold shared read locks concurrently or allows a single thread to hold an exclusive write lock. This can improve performance for data structures that are read much more often than they are written, as readers do not block each other.

  • read(): Acquires a shared read lock. Blocks if an exclusive write lock is currently held. Returns Result<RwLockReadGuard<T>, PoisonError<...>>. Multiple threads can hold read locks simultaneously.
  • write(): Acquires an exclusive write lock. Blocks if any read locks or an exclusive write lock are currently held. Returns Result<RwLockWriteGuard<T>, PoisonError<...>>. Only one thread can hold the write lock.
  • RwLockReadGuard<T> / RwLockWriteGuard<T>: RAII guards similar to MutexGuard. They provide access (Deref for read, DerefMut for write) and automatically release the lock when dropped. Poisoning works similarly to Mutex.
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;

fn main() {
    let config = Arc::new(RwLock::new(String::from("Initial Config")));
    let mut handles = vec![];

    // Spawn reader threads
    for i in 0..3 {
        let config_clone = Arc::clone(&config);
        let handle = thread::spawn(move || {
            // Acquire a read lock, granting shared access.
            let cfg: std::sync::RwLockReadGuard<String> = config_clone
            .read().unwrap();
            println!("Reader {}: Config is '{}'", i, *cfg);
            thread::sleep(Duration::from_millis(50)); // Simulate work
            // Read lock released when 'cfg' drops.
        });
        handles.push(handle);
    }

    // Wait briefly to ensure readers likely acquire locks first
    thread::sleep(Duration::from_millis(10));

    // Spawn a writer thread
    let config_clone_w = Arc::clone(&config);
    let writer_handle = thread::spawn(move || {
        println!("Writer: Attempting to acquire write lock...");
        // Acquire a write lock. Blocks until all readers release.
        let mut cfg: std::sync::RwLockWriteGuard<String> = config_clone_w
        .write().unwrap();
        *cfg = String::from("Updated Config");
        println!("Writer: Config updated.");
        // Write lock released when 'cfg' drops.
    });
    handles.push(writer_handle);

    // Wait for all threads
    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final config: {}", *config.read().unwrap());
}

Caution: RwLock can suffer from “writer starvation” on some platforms if there is a continuous stream of readers, potentially preventing a writer from ever acquiring the lock. Behavior can be platform-dependent.

22.5.4 Condition Variables: Condvar

A Condvar (Condition Variable) allows threads to wait efficiently for a specific condition to become true. Condition variables are almost always used together with a Mutex to protect the shared state representing the condition.

The typical pattern is:

  1. A waiting thread acquires the Mutex.
  2. It checks the condition based on the shared state protected by the Mutex.
  3. If the condition is false, it calls condvar.wait(guard) passing the MutexGuard. This atomically releases the mutex lock and puts the thread to sleep.
  4. When the thread is woken up (by another thread calling notify_one or notify_all), wait() automatically re-acquires the mutex lock (granting exclusive access) before returning the new MutexGuard.
  5. The waiting thread must re-check the condition in a loop (a while loop is idiomatic) because wakeups can be “spurious” (occurring without a notification) or the condition might have changed again between the notification and the lock re-acquisition.
  6. A notifying thread acquires the same Mutex.
  7. It modifies the shared state, making the condition true.
  8. It calls condvar.notify_one() (wakes up one waiting thread) or condvar.notify_all() (wakes up all waiting threads).
  9. It releases the Mutex (typically via RAII when its guard goes out of scope).

This pattern closely mirrors the usage of pthread_cond_t and pthread_mutex_t in C, but Rust’s type system ensures the mutex is correctly held and released.

use std::sync::{Arc, Mutex, Condvar};
use std::thread;
use std::time::Duration;

fn main() {
    // Shared state: a boolean flag protected by a Mutex, paired with a Condvar.
    let pair = Arc::new((Mutex::new(false), Condvar::new()));
    let pair_clone = Arc::clone(&pair);

    // Waiter thread
    let waiter_handle = thread::spawn(move || {
        let (lock, cvar) = &*pair_clone; // Destructure the tuple inside the Arc
        println!("Waiter: Waiting for notification...");

        // 1. Acquire the lock (gain exclusive access to the shared boolean)
        let mut started_guard = lock.lock().unwrap();

        // 2. Check condition in a loop & 3. Wait if false
        while !*started_guard {
            println!("Waiter: Condition false, waiting...");
            // wait() atomically releases the lock and waits.
            // Re-acquires lock (exclusive access) before returning.
            started_guard = cvar.wait(started_guard).unwrap();
            println!("Waiter: Woken up, re-checking condition...");
        }

        // 5. Condition is now true
        println!("Waiter: Condition met! Proceeding.");
        // Lock automatically released when started_guard drops here.
    });

    // Notifier thread (main thread)
    println!("Notifier: Doing some work...");
    thread::sleep(Duration::from_secs(1)); // Simulate work before notifying

    let (lock, cvar) = &*pair; // Destructure the original pair

    // 6. Acquire the lock (gain exclusive access)
    { // Scope for the lock guard
        let mut started_guard = lock.lock().unwrap();
        // 7. Modify shared state
        *started_guard = true;
        println!("Notifier: Set condition to true.");
        // 8. Notify one waiting thread
        cvar.notify_one();
        println!("Notifier: Notified waiter.");
        // 9. Lock released here when started_guard drops.
    } // End of scope for lock guard

    waiter_handle.join().unwrap();
    println!("Notifier: Waiter thread finished.");
}

22.5.5 Atomic Types

For simple primitive types (bool, integers, pointers), Rust provides atomic types in std::sync::atomic (e.g., AtomicBool, AtomicUsize, AtomicIsize, AtomicPtr). These types guarantee that operations performed on them are atomic—they complete indivisibly without interruption from other threads, even without using explicit locks like Mutex. They enable lock-free shared access to these primitive values.

Atomic operations include:

  • load(): Atomically read the value.
  • store(): Atomically write the value.
  • swap(): Atomically write a new value and return the previous value.
  • compare_exchange(current, new, ...): Atomically compare the current value with current, and if they match, write new. Returns the previous value. Useful for implementing lock-free algorithms.
  • fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor(): Atomically perform the operation (e.g., add) and return the previous value.

These operations require specifying a memory ordering (Ordering), such as Relaxed, Acquire, Release, AcqRel, or SeqCst (Sequentially Consistent). Memory ordering controls how atomic operations synchronize memory visibility between threads, preventing unexpected behavior due to compiler or CPU reordering of instructions. Understanding memory ordering is complex and crucial for correctness in lock-free programming, similar to std::memory_order in C++. For simple counters or flags, Relaxed (least strict) or SeqCst (most strict, default, easiest to reason about but potentially slower) are often sufficient starting points.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    // Use Arc to share the atomic counter among threads.
    let shared_counter = Arc::new(AtomicUsize::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter_clone = Arc::clone(&shared_counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                // Atomically increment the counter.
                // Ordering::Relaxed is sufficient here because we only care
                // about the final count, not the order of increments relative
                // to other memory operations.
                counter_clone.fetch_add(1, Ordering::Relaxed);
            }
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }

    // Atomically load the final value.
    // Ordering::SeqCst provides the strongest guarantees, ensuring all previous
    // writes (from any thread) are visible before this load.
    let final_count = shared_counter.load(Ordering::SeqCst);
    println!("Atomic counter final value: {}", final_count); // Should be 10000
}

Atomics are more efficient than mutexes for simple operations but are limited to primitive types and require careful handling of memory ordering for complex interactions.

22.5.6 Scoped Threads for Borrowing (Rust 1.63+)

As mentioned earlier, std::thread::spawn requires closures with a 'static lifetime, preventing them from directly borrowing local data from the parent thread’s stack unless that data is itself 'static. This often forces the use of Arc even when true shared ownership isn’t strictly necessary.

Scoped threads, introduced via std::thread::scope, provide a solution. This function creates a scope, and any threads spawned within that scope using the provided scope object (s in the example below) are guaranteed by the compiler to finish before the scope function returns. This guarantee allows threads spawned within the scope to safely borrow data from the parent stack frame that outlives the scope.

use std::thread;

fn main() {
    let mut numbers = vec![1, 2, 3];
    let mut message = String::from("Hello"); // Mutable data

    println!("Before scope: message = '{}'", message);

    // Create a scope for threads that can borrow local data.
    thread::scope(|s| {
        // Spawn a thread that takes a shared borrow of 'numbers'.
        s.spawn(|| {
            // 'numbers' is sharedly borrowed here.
            println!("Scoped thread 1 sees numbers: {:?}", numbers);
            // The shared borrow ends when this thread finishes.
        });

        // Spawn another thread that takes an exclusive borrow of 'message'.
        s.spawn(|| {
            // 'message' is exclusively borrowed here.
            message.push_str(" from scoped thread 2!");
            println!("Scoped thread 2 modified message.");
            // The exclusive borrow ends when this thread finishes.
        });

        // Note: Rust's borrowing rules still apply *within* the scope.
        // You couldn't, for example, spawn two threads that both try to
        // exclusively borrow 'message' simultaneously, or one exclusively
        // and another sharedly. The compiler prevents this.

        println!("Main thread inside scope, after spawning.");
        // The 'scope' function implicitly waits here for all threads
        // spawned via 's' to complete before it returns.
    }); // <- All threads guaranteed joined here.

    // Scoped threads have finished, borrows have ended.
    // We can safely access 'numbers' and 'message' again.
    numbers.push(4);
    println!("After scope: message = '{}'", message); // Shows modification
    println!("After scope: numbers = {:?}", numbers);
}

Scoped threads make many common concurrent patterns, especially those involving partitioning work over borrowed data, significantly more ergonomic than using Arc or other complex lifetime management techniques. The compiler statically verifies that the borrowed data will live long enough.


22.6 Message Passing with Channels

An alternative paradigm to shared-memory concurrency (using locks and atomics) is message passing. Instead of threads accessing shared data directly, they communicate by sending messages (containing data) to each other through channels. This often aligns with philosophies like the Actor model or Communicating Sequential Processes (CSP), where components interact solely via messages, potentially simplifying reasoning about concurrency by avoiding shared mutable state. Rust’s ownership system is particularly well-suited to message passing, as sending a value typically transfers ownership, preventing the sender from accidentally accessing it later.

22.6.1 std::sync::mpsc Channels

Rust’s standard library provides basic asynchronous channels in the std::sync::mpsc module. The name mpsc stands for “multiple producer, single consumer,” meaning multiple threads can send messages, but only one thread can receive them.

Calling mpsc::channel() creates a connected pair: a Sender<T> (transmitter) and a Receiver<T>.

use std::sync::mpsc; // multiple producer, single consumer
use std::thread;
use std::time::Duration;

fn main() {
    // Create a channel for sending String messages.
    let (tx, rx): (mpsc::Sender<String>, mpsc::Receiver<String>) = mpsc::channel();

    // Spawn a producer thread. Move the Sender 'tx' into the thread.
    thread::spawn(move || {
        let messages = vec![
            String::from("Greetings"),
            String::from("from"),
            String::from("the"),
            String::from("producer!"),
        ];
        for msg in messages {
            println!("Producer: Sending '{}'...", msg);
            // send() takes ownership of the message 'msg'.
            // If the receiver 'rx' has been dropped, send() returns Err.
            if tx.send(msg).is_err() {
                println!("Producer: Receiver disconnected, stopping.");
                break;
            }
            // msg cannot be used here anymore after sending, as ownership was transf.
            thread::sleep(Duration::from_millis(200));
        }
        println!("Producer: Finished sending. Sender 'tx' will be dropped.");
        // Dropping the last Sender closes the channel.
    });

    // The main thread acts as the consumer, using the Receiver 'rx'.
    println!("Consumer: Waiting for messages...");

    // The Receiver can be treated as an iterator.
    // This loop blocks until a message arrives or the channel closes.
    // It receives ownership of each message.
    for received_msg in rx {
        println!("Consumer: Received '{}'", received_msg);
    }

    // The loop terminates when the channel is closed (all Senders dropped)
    // and the channel buffer is empty.
    println!("Consumer: Channel closed, finished receiving.");
}
  • tx.send(value): Sends value through the channel, transferring ownership of value. This call may block if the channel uses a bounded buffer that is full (though std::sync::mpsc channels are effectively unbounded). Returns Err if the Receiver has been dropped, indicating the channel is closed from the receiving end.
  • rx (Receiver<T>): Implements Iterator, so it can be used directly in a for loop. The iteration blocks waiting for the next message. When the last Sender associated with the channel is dropped, the channel becomes closed, and the iterator will eventually end after consuming any remaining buffered messages.

22.6.2 Multiple Producers

The Sender can be cloned (tx.clone()) to create multiple handles that can send messages to the same single Receiver. Cloning is cheap (likely involves bumping an atomic reference count).

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();
    let mut handles = vec![];

    for i in 0..3 {
        // Clone the sender for each producer thread.
        let tx_clone = tx.clone();
        let handle = thread::spawn(move || {
            let message = format!("Message from producer {}", i);
            tx_clone.send(message).unwrap();
            // tx_clone dropped here
        });
        handles.push(handle);
    }

    // Drop the original 'tx' in the main thread.
    // The channel only closes when *all* Sender clones (including the original)
    // are dropped. If we don't drop this 'tx', the receiver loop below
    // would block indefinitely waiting for more messages.
    drop(tx);

    println!("Receiving messages...");
    // Receive messages from all producers
    for msg in rx {
        println!("Received: {}", msg);
    }
    println!("All producers finished and channel closed.");

    // Join handles (optional here as main waits on rx)
    // for handle in handles { handle.join().unwrap(); }
}

22.6.3 Receiving Methods: Blocking vs. Non-Blocking

Besides iteration, the Receiver provides specific methods for receiving:

  • recv(): Blocks the current thread until a message is received or the channel is closed. Returns Result<T, RecvError>. RecvError indicates the channel is closed and empty.
  • try_recv(): Attempts to receive a message immediately without blocking. Returns Result<T, TryRecvError>. TryRecvError::Empty means no message is available right now. TryRecvError::Disconnected means the channel is closed and empty.
  • recv_timeout(duration): Blocks for at most the specified Duration waiting for a message. Returns Result<T, RecvTimeoutError>. RecvTimeoutError::Timeout means the duration elapsed without a message. RecvTimeoutError::Disconnected means the channel closed.
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        thread::sleep(Duration::from_millis(800));
        tx.send("Delayed Data!").unwrap();
    });

    println!("Attempting non-blocking receive...");
    let start_time = std::time::Instant::now();
    loop {
        match rx.try_recv() {
            Ok(msg) => {
                println!("Got message via try_recv: '{}'", msg);
                break; // Exit loop after receiving
            }
            Err(TryRecvError::Empty) => {
                println!("No message yet, performing other work...");
                // Simulate doing something else while waiting
                thread::sleep(Duration::from_millis(100));
                if start_time.elapsed() > Duration::from_secs(2) {
                   println!("Timeout waiting for message.");
                   break;
                }
            }
            Err(TryRecvError::Disconnected) => {
                println!("Channel closed unexpectedly!");
                break;
            }
        }
    }
}

22.6.4 Advanced Channel Patterns and Crates

While std::sync::mpsc covers basic use cases, it has limitations (single consumer, unbounded buffer which can lead to high memory usage if producers are much faster than the consumer). For more demanding scenarios, the Rust ecosystem offers powerful alternatives:

  • crossbeam-channel: Provides highly optimized, feature-rich channels. Supports:
    • Multiple Producers and Multiple Consumers (MPMC).
    • Bounded channels (blocking or failing send when full).
    • Unbounded channels (similar to std::sync::mpsc but often faster).
    • select! macro for waiting on multiple channels simultaneously.
  • tokio::sync::mpsc / async_std::channel: Provide asynchronous channels specifically designed for use within async code (async/await), integrating with the respective async runtimes. They allow tasks to wait for messages without blocking OS threads.

These external crates are often preferred in performance-sensitive applications or when MPMC or bounded capacity semantics are required.


22.7 Data Parallelism with Rayon

Manually spawning and coordinating threads to parallelize computations across data collections (like vectors or arrays) can be tedious and error-prone. Issues like correctly partitioning the data, load balancing, and managing synchronization are complex. The Rayon crate provides a high-level framework for data parallelism that abstracts away much of this complexity. It leverages a work-stealing thread pool to efficiently distribute computations across available CPU cores.

22.7.1 Using Parallel Iterators

Rayon’s most prominent feature is its parallel iterators. Often, converting sequential iterator-based code to run in parallel requires minimal changes.

First, add Rayon as a dependency in your Cargo.toml:

[dependencies]
rayon = "1.8" # Check for the latest version

Then, bring the parallel iterator traits into scope:

use rayon::prelude::*;

You can then replace standard iterator methods like .iter(), .iter_mut(), or .into_iter() with their parallel counterparts: .par_iter(), .par_iter_mut(), or .into_par_iter(). Most standard iterator adaptors (like map, filter, fold, sum, for_each) have parallel equivalents provided by Rayon.

use rayon::prelude::*; // Import the parallel iterator traits

fn main() {
    let mut data: Vec<u64> = (0..1_000_000).collect();

    // Sequential computation (example: modify in place)
    // data.iter_mut().for_each(|x| *x = (*x * *x) % 1000);

    // Parallel computation using Rayon
    println!("Starting parallel computation...");
    data.par_iter_mut() // Get a parallel exclusive iterator
        .enumerate()    // Get index along with element
        .for_each(|(i, x)| {
            // This closure potentially runs in parallel for different chunks of data.
            // Perform some computation (e.g., simulate work based on index)
            let computed_value = (i as u64 * i as u64) % 1000;
            *x = computed_value;
        });
    println!("Parallel modification finished.");

    // Example: Parallel sum after modification
    let sum: u64 = data.par_iter() // Parallel shared iterator
        .map(|&x| x * 2) // Map operation runs in parallel (takes shared references)
        .sum(); // Reduction (sum) is performed efficiently in parallel

    println!("Parallel sum of doubled values: {}", sum);

    // Verify a few values (optional, computation is deterministic)
    println!("Data[0]={}, Data[1]={}, Data[last]={}",
    data[0], data[1], data[data.len()-1]);
}

Rayon automatically manages a global thread pool (sized based on the number of logical CPU cores by default). It intelligently splits the data (data vector in the example) into smaller chunks and assigns them to worker threads. If one thread finishes its chunk early, it can “steal” work from another, busier thread, ensuring good load balancing.

22.7.2 The rayon::join Function

For parallelizing distinct, independent tasks that don’t naturally fit the iterator model, Rayon provides rayon::join. It takes two closures and executes them, potentially in parallel on different threads from the pool, returning only when both closures have completed.

fn compute_task_a() -> String {
    // Simulate some independent work
    println!("Task A starting on thread {:?}", std::thread::current().id());
    std::thread::sleep(std::time::Duration::from_millis(150));
    println!("Task A finished.");
    String::from("Result A")
}

fn compute_task_b() -> String {
    // Simulate other independent work
    println!("Task B starting on thread {:?}", std::thread::current().id());
    std::thread::sleep(std::time::Duration::from_millis(100));
    println!("Task B finished.");
    String::from("Result B")
}

fn main() {
    println!("Starting rayon::join...");
    let (result_a, result_b) = rayon::join(
        compute_task_a, // Closure 1
        compute_task_b  // Closure 2
    );
    // rayon::join blocks until both compute_task_a and compute_task_b return.
    // They may run sequentially or in parallel depending on thread availability.
    println!("rayon::join completed.");

    println!("Joined results: A='{}', B='{}'", result_a, result_b);
}

22.7.3 Performance Considerations

Rayon makes parallelism easy, but it’s not a magic bullet for performance.

  • Overhead: There is overhead associated with coordinating threads, splitting work, and potentially stealing tasks. For very small datasets or extremely simple computations per element, this overhead might outweigh the benefits of parallel execution, potentially making the parallel version slower than the sequential one.
  • Amdahl’s Law: The maximum speedup achievable through parallelism is limited by the portion of the code that must remain sequential.
  • Work Granularity: The amount of work done per parallel task matters. If tasks are too small, overhead dominates. If too large, load balancing might be poor. Rayon’s work stealing helps, but performance can still depend on the nature of the computation.

Always benchmark and profile your code (e.g., using cargo bench and profiling tools like perf on Linux or Instruments on macOS) to verify that using Rayon provides a tangible performance improvement for your specific workload and target hardware.


22.8 Introduction to SIMD (Single Instruction, Multiple Data)

While threading and libraries like Rayon provide task-level or data parallelism across CPU cores, SIMD (Single Instruction, Multiple Data) offers parallelism within a single core. Modern CPUs include special registers (e.g., 128-bit SSE registers, 256-bit AVX registers, 512-bit AVX-512 registers) and instructions that can perform the same operation (like addition, multiplication, comparison) on multiple data elements simultaneously. For example, a single SIMD instruction might add four pairs of 32-bit floating-point numbers at once. This can dramatically accelerate code that performs repetitive operations on arrays or vectors of numerical data, common in scientific computing, multimedia processing, and cryptography.

22.8.1 Automatic vs. Explicit SIMD in Rust

  • Auto-vectorization: The Rust compiler, leveraging LLVM, can sometimes automatically convert sequential loops operating on slices or arrays into equivalent SIMD instructions. This typically requires optimizations to be enabled (e.g., opt-level=2 or 3 in Cargo.toml) and may benefit from specifying the target CPU features (e.g., -C target-cpu=native). However, auto-vectorization is heuristic; it depends heavily on the code structure (simple loops, no complex control flow, aligned data access) and isn’t guaranteed to occur or produce optimal results.
  • Explicit SIMD: When auto-vectorization is insufficient or more control is needed, developers can use explicit SIMD instructions. Rust provides mechanisms for this:
    • std::arch: Contains platform-specific intrinsic functions that map directly to CPU instructions (e.g., _mm_add_ps for SSE float addition on x86/x86_64). This provides maximum control and performance but requires unsafe blocks, is highly platform-dependent (non-portable), and necessitates careful handling of CPU feature detection at runtime to avoid crashes on unsupported hardware. It’s analogous to using intrinsics headers like <immintrin.h> in C/C++.
    • std::simd (Portable SIMD - currently requires Nightly Rust): A safer, higher-level abstraction aiming for portability. It provides types representing vectors of data (e.g., f32x4 for four f32 values) and overloads standard operators (+, -, *, /) to work element-wise on these vectors. The compiler translates these operations into appropriate SIMD instructions for the target platform where possible. This module is still experimental and requires enabling a feature flag (#![feature(portable_simd)]) on the nightly compiler channel.

22.8.2 Example using std::simd (Nightly Feature)

Using the experimental std::simd module offers a taste of safer, more portable SIMD:

// This code requires a nightly Rust compiler toolchain
// and enabling the feature gate at the crate root (e.g., in main.rs or lib.rs):
// #![feature(portable_simd)]

#![feature(portable_simd)]
use std::simd::{f32x4}; // Using the type alias f32x4 = Simd<f32, 4>
use std::simd::num::SimdFloat;

fn main() {
    // Create SIMD vectors containing 4 f32 values each.
    let v_a = f32x4::from_array([1.0, 2.0, 3.0, 4.0]);
    let v_b = f32x4::from_array([10.0, 20.0, 30.0, 40.0]);
    let v_c = f32x4::splat(0.5); // Creates [0.5, 0.5, 0.5, 0.5]

    // Perform element-wise SIMD operations.
    // These map to single instructions on capable hardware.
    let sum: f32x4 = v_a + v_b;    // [11.0, 22.0, 33.0, 44.0]
    let product: f32x4 = sum * v_c; // [5.5, 11.0, 16.5, 22.0]

    // Access the results as an array.
    println!("SIMD Vector A: {:?}", v_a.as_array());
    println!("SIMD Vector B: {:?}", v_b.as_array());
    println!("SIMD Sum (A + B): {:?}", sum.as_array());
    println!("SIMD Product ((A+B)*0.5): {:?}", product.as_array());

    // Horizontal operations: sum elements within a vector.
    let horizontal_sum: f32 = product.reduce_sum();
    println!("Sum of elements in the final product vector: {}", horizontal_sum); // 55
}

You can run this example in the Rust Playground by selecting the nightly compiler version.

Writing effective SIMD code often involves structuring algorithms to process data in chunks matching the SIMD vector width (e.g., 4 elements for f32x4), handling remainder elements (when the data size isn’t a multiple of the vector width), and ensuring proper data alignment for optimal performance. While potentially offering significant speedups for suitable problems, explicit SIMD programming adds considerable complexity compared to higher-level parallelism approaches like Rayon.

For detailed usage, refer to the Rust std::simd module documentation and the Portable SIMD Project User Guide.


22.9 Comparing Rust Concurrency with C and C++

C and C++ programmers typically rely on a combination of language features and libraries for concurrency:

  • C: Primarily POSIX threads (pthreads) providing pthread_create, pthread_join, pthread_mutex_t, pthread_cond_t, sem_t, etc. Alternatively, platform-specific APIs (like Windows threads) or libraries like OpenMP for data parallelism might be used. Manual memory management interacts hazardously with concurrency, requiring extreme care.
  • C++: The standard library (<thread>, <mutex>, <condition_variable>, <atomic>, <future>) provides core primitives (std::thread, std::mutex, etc.) built upon platform capabilities. RAII helps manage lock lifetimes (std::lock_guard, std::unique_lock). Libraries like OpenMP or Intel TBB offer higher-level parallelism constructs.

While these C/C++ tools are powerful, they fundamentally place the burden of ensuring thread safety—particularly the absence of data races—on the programmer. Mistakes are easy to make and often lead to:

  • Data Races: Concurrent, unsynchronized access to shared mutable data, resulting in undefined behavior. These are notoriously hard to debug as they may only manifest intermittently under specific timing conditions.
  • Deadlocks: Resulting from incorrect lock acquisition sequences.
  • Incorrect Synchronization: Leading to race conditions (logical errors based on timing, even without data races) or performance issues.

Rust’s approach significantly reduces these risks, especially concerning data races, by leveraging its core language features:

  1. Ownership and Borrowing: The compiler enforces rules at compile time: data can have multiple shared references (&T) or exactly one exclusive reference (&mut T). This inherently prevents unsynchronized concurrent writes or concurrent write/read access to the same data in safe code.
  2. Send and Sync Traits: These marker traits (discussed next) are used by the compiler to statically check whether a type can be safely transferred across thread boundaries (Send) or safely shared via references across threads (Sync). Types that don’t meet these criteria cannot be used in ways that would violate thread safety without unsafe code.
  3. Safe Abstractions: Standard library concurrency primitives like Mutex<T>, RwLock<T>, Arc<T>, and channels are designed to integrate with the ownership and type system. For instance, accessing the data inside a Mutex requires acquiring a lock, which returns an RAII guard (MutexGuard). This guard provides temporary, synchronized exclusive access and automatically releases the lock when it goes out of scope, preventing common errors like forgetting to unlock. Similarly, RwLock provides shared access for readers and exclusive access for writers.

This combination shifts the detection of data races from runtime testing and debugging (where they are hard to find) to compile-time analysis (where they are reported as errors). While deadlocks and logical race conditions are still possible in Rust (as they depend on program logic), the elimination of data races in safe code removes a major source of undefined behavior and instability common in C/C++ concurrent programs. Libraries like Rayon provide high-level parallelism comparable to OpenMP but benefit from Rust’s underlying safety guarantees. Using unsafe Rust allows bypassing these guarantees for low-level optimizations or FFI, but explicitly marks these potentially hazardous sections.


22.10 The Send and Sync Marker Traits

Two crucial marker traits underpin Rust’s compile-time concurrency safety: Send and Sync. They don’t define any methods; their purpose is to “mark” types with specific properties related to thread safety. The compiler automatically implements (or doesn’t implement) these traits for user-defined types based on their composition.

  • Send: A type T is Send if a value of type T can be safely transferred (moved) to another thread.

    • Most primitive types (i32, bool, f64, etc.) are Send.
    • Owned container types like String, Vec<T>, Box<T> are Send if their contained type T is also Send.
    • Arc<T> is Send if T is Send + Sync (shared ownership requires the inner type to be sharable too).
    • Mutex<T> and RwLock<T> are Send if T is Send.
    • Types that are not inherently Send:
      • Rc<T>: Its reference counting is non-atomic, making it unsafe to transfer ownership across threads where counts could be updated concurrently.
      • Raw pointers (*const T, *mut T): They don’t have safety guarantees, so they are not Send by default. Types containing raw pointers need careful consideration, often requiring unsafe impl Send.
  • Sync: A type T is Sync if a shared reference &T can be safely shared across multiple threads concurrently.

    • Technically, T is Sync if and only if &T (a shared reference to T) is Send.
    • Most primitive types are Sync.
    • Immutable types composed of Sync types are typically Sync.
    • Arc<T> is Sync if T is Send + Sync.
    • Mutex<T> is Sync if T is Send. Even though the Mutex allows mutation of T, it synchronizes access, making it safe to share &Mutex<T> across threads. Access to the inner T is controlled via the lock, which provides exclusive access.
    • RwLock<T> is Sync if T is Send + Sync (for readers) and T is Send (for writers). A shared reference &RwLock<T> allows multiple threads to acquire shared read locks, but only one thread to acquire an exclusive write lock.
    • Types that are not inherently Sync:
      • Cell<T>, RefCell<T>: These provide interior mutability without thread synchronization, making it unsafe to share &Cell<T> or &RefCell<T> across threads, as concurrent mutations could lead to data races.
      • Rc<T>: Non-atomic reference counting makes sharing &Rc<T> unsafe.
      • Raw pointers (*const T, *mut T): Not Sync by default.

The compiler uses these traits implicitly when checking thread-related operations:

  • The closure passed to std::thread::spawn must be Send because it might be moved to a new thread. Any captured variables must also be Send.
  • Data shared using Arc<T> requires T: Send + Sync because multiple threads might access it concurrently via shared references derived from the Arc.
  • Attempting to use a non-Send type across threads (e.g., putting an Rc<T> inside an Arc and sending it to another thread) will result in a compile-time error.
  • Attempting to share a non-Sync type (e.g., Arc<RefCell<T>>) across threads where multiple threads could potentially access it concurrently will also result in a compile-time error.

Understanding Send and Sync helps clarify why the Rust compiler allows certain concurrent patterns while forbidding others, forming the foundation of its “fearless concurrency” guarantee against data races in safe code.


22.11 Summary

Rust offers robust and safe mechanisms for concurrent programming using OS threads, leveraging its ownership and type system to prevent data races at compile time—a significant advantage compared to C and C++. This chapter covered:

  1. Core Concepts: Differentiated concurrency (structure) from parallelism (execution), and processes (isolated) from threads (shared memory). Highlighted risks like race conditions and deadlocks.
  2. Compile-Time Safety: Explained how Rust’s ownership, borrowing (specifically shared and exclusive references), and the Send/Sync marker traits prevent data races in safe code by enforcing strict access rules.
  3. OS Threads (std::thread): Introduced thread::spawn for creating threads, JoinHandle for managing them (joining, getting results, panic handling), move closures for transferring ownership, and Builder for configuration (name, stack size). Noted the 'static lifetime requirement for spawn.
  4. Data Sharing Primitives: Detailed mechanisms for safe shared access:
    • Arc<T>: For thread-safe shared ownership (atomic reference counting), providing shared immutable access by default.
    • Mutex<T>: For synchronized, exclusive mutable access (RAII guards providing exclusive references).
    • RwLock<T>: For allowing concurrent shared readers or a single exclusive writer (RAII guards).
    • Condvar: For thread synchronization based on conditions, used with Mutex.
    • Atomic Types (std::sync::atomic): For lock-free atomic operations on primitives, enabling concurrent shared access to simple values, requiring careful memory ordering.
  5. Scoped Threads (std::thread::scope): Showcased how scoped threads lift the 'static requirement, allowing threads to safely borrow (both shared and exclusive references) data from their parent stack frame.
  6. Message Passing (std::sync::mpsc): Presented channels (Sender/Receiver) as an alternative model based on transferring ownership of messages, avoiding direct shared state. Mentioned advanced channel crates (crossbeam-channel).
  7. Data Parallelism (rayon): Demonstrated how Rayon simplifies parallelizing computations over collections using parallel iterators (par_iter, par_iter_mut) and functions like rayon::join, managing a work-stealing thread pool automatically.
  8. SIMD (std::arch, std::simd): Introduced SIMD as instruction-level parallelism for numerical tasks, covering auto-vectorization and explicit intrinsics (platform-specific std::arch vs. safer, experimental, portable std::simd).
  9. C/C++ Comparison: Explicitly contrasted Rust’s compile-time data race prevention with the runtime risks and debugging challenges in C/C++.

Choosing the right concurrency model (OS threads for CPU-bound work, async tasks for I/O-bound work) depends on the application’s needs. Regardless of the model, Rust’s focus on safety aims to make concurrent programming more reliable and less error-prone than in traditional systems languages.