| Contents |

22 Fearless concurrency

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:

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:

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:

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.

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.

22.3.3 Matching Concurrency Model to Workload

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:

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> 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.

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.

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:

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.");
}

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:

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:

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.

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

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:

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:

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.

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

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:
  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.



| Contents |