</>PatchNote
Back to posts

2024-09-21

HSPACE Rust Lectures #9 — Concurrency

RustHSPACE 2024ConcurrencyThreads

The longest session by far, and the one where everything learned so far clicks into place. This is where you find out what "fearless concurrency" actually means.

The lecture splits concurrency into three approaches:

  1. Fork-join — chop the work up, hand pieces to threads, collect results
  2. Channels — threads pass values to each other, sharing nothing
  3. Shared mutable state — threads use the same data behind a lock

Fork-join parallelism

The simplest form, for work that splits into independent pieces.

use std::thread;

let handle = thread::spawn(move || {
    // runs on another thread
    process(chunk)
});

let result = handle.join().unwrap();   // wait for it and take the result

spawn requires a move closure for the reason the closures session gave: the new thread may outlive the original stack frame, so it must own its captures. The exact bound is F: FnOnce() -> T + Send + 'static.

Errors across threads

join() returns a Result, and a panicking thread produces Err.

match handle.join() {
    Ok(value) => println!("result: {value:?}"),
    Err(_)    => println!("the thread panicked"),
}

One thread panicking doesn't take down the process. Only that thread unwinds and exits, and the parent learns about it at join(). The payload comes back as Box<dyn Any>, which is awkward to work with, so in practice sending errors over a channel is nicer.

Sharing immutable data

Often every thread needs the same read-only data. That's Arc.

use std::sync::Arc;

let data = Arc::new(build_huge_table());

for _ in 0..8 {
    let data = Arc::clone(&data);   // bumps the reference count only
    thread::spawn(move || {
        lookup(&data, key);
    });
}

No copying of the data — just an atomic increment, and release when the last thread finishes. If you only read, you need no Mutex at all, thanks to the rule that a shared reference is immutable.

Rayon

Managing threads by hand is tedious. Rayon parallelizes iterators.

use rayon::prelude::*;

let sum: i64 = numbers.par_iter().map(|&n| expensive(n)).sum();

Swapping iter() for par_iter() runs the work on a work-stealing thread pool. The remarkable part is that the compiler guarantees this is safe — capture something that isn't Send and it won't compile.

Channels

The second approach, in the spirit of Go's "don't communicate by sharing memory; share memory by communicating."

use std::sync::mpsc::channel;

let (sender, receiver) = channel();

thread::spawn(move || {
    for line in read_lines() {
        sender.send(line).unwrap();
    }
    // sender dropped here → the channel closes
});

for line in receiver {          // iterate until the channel closes
    process(line);
}

mpsc is multi-producer, single-consumer: Sender clones, Receiver doesn't.

Sending a value is a move. sender.send(value) transfers ownership, so you can't touch the original afterwards and two threads never observe the same value. What struck me is that channels are safe not because of some new mechanism but because of the ownership rules that were already there.

Closing a channel

When every Sender is dropped the channel closes and the for loop ends naturally — no sentinel "we're done" message needed. Conversely, accidentally holding on to a Sender leaves the consumer waiting forever, which is a mistake people make routinely.

Running a pipeline

The fingertips example (an inverted-index builder) is a good case study. Read documents → build an index → write temp files → merge, wired together with channels.

fn run_pipeline(documents: Vec<PathBuf>, output_dir: PathBuf) -> io::Result<()> {
    // each stage runs on its own thread, connected by channels
    let (texts,   h1) = start_file_reader_thread(documents);
    let (pints,   h2) = start_file_indexing_thread(texts);
    let (gallons, h3) = start_in_memory_merge_thread(pints);
    let (files,   h4) = start_index_writer_thread(gallons, &output_dir);
    let result = merge_index_files(files, &output_dir);

    // join every thread and check for errors
    // ...
}

Each stage consumes the previous stage's output on its own thread, so with enough cores the stages overlap. I liked that channels also give you natural backpressure: with a synchronous channel, a slow consumer automatically stalls the producer.

The repository ships a single-threaded version (run_single_threaded) alongside it, which makes the comparison easy — same logic, different wiring.

Channel performance

Channels aren't magic:

  • send/recv use locks and condition variables, so they aren't free
  • sending tiny values very frequently is dominated by overhead; batching is usually better
  • the default channel is unbounded, so a slow consumer grows memory without limit. sync_channel(n) caps the capacity

Send and Sync

This is the heart of Rust concurrency: two marker traits that define thread safety at the type level.

  • Send — values of this type can be moved to another thread safely
  • Sync — a &T can be observed by several threads at once safely

Precisely: T: Sync ⟺ &T: Send.

Nearly every type implements both automatically (they're auto traits). The exceptions are what matters:

TypeSendSyncWhy
Rc<T>✗✗non-atomic reference count
RefCell<T>✓✗non-atomic borrow flag
Arc<T>✓✓atomic counter (when T: Send + Sync)
Mutex<T>✓✓protected by the lock (when T: Send)
raw pointers✗✗no idea what they point at

Since thread::spawn requires F: Send and T: Send, a closure capturing an Rc simply doesn't compile — the example from session one.

Here's the part I found impressive: the Rust compiler knows nothing about concurrency. It doesn't understand deadlocks or races. It only checks Send/Sync bounds and the ownership rules — and data races disappear anyway. Concurrency safety comes out as a corollary of ordinary type checking.

Shared mutable state

The third approach: several threads reading and writing the same data.

Mutex

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

let counter = Arc::new(Mutex::new(0));

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    thread::spawn(move || {
        let mut n = counter.lock().unwrap();   // acquire
    *n += 1;
    });                                        // end of scope → released
}

The decisive difference from a C mutex is that the mutex owns what it protects.

In C, the mutex and the data are separate variables, and touching the data without the lock is invisible to the compiler. In Rust the data lives inside the Mutex, and reaching it requires lock(), which hands back a MutexGuard that releases the lock when dropped.

You can't forget to lock, and you can't forget to unlock.

mut and Mutex

There's a fun detail:

let counter = Mutex::new(0);       // no mut!
let mut n = counter.lock().unwrap();
*n += 1;                           // yet the contents change

counter isn't mut and yet the value inside changes, because Mutex is also an interior mutability type. RefCell provides that on one thread with a runtime borrow check; Mutex provides it across threads with a lock.

Deadlock

Rust does not prevent deadlocks.

// thread A: lock(a) → lock(b)
// thread B: lock(b) → lock(a)     ← deadlock

Locking the same mutex twice deadlocks too:

let guard1 = m.lock().unwrap();
let guard2 = m.lock().unwrap();   // waits on itself

The borrow checker guarantees memory safety, not freedom from deadlock. A deadlock isn't undefined behavior — it's a stopped program, which counts as safe. Classic discipline like establishing a lock ordering is still on you.

Poisoned mutexes

This is why lock() returns a Result. If a thread panics while holding the lock, the mutex becomes poisoned, because the protected data may be half updated.

Subsequent lock() calls return Err(PoisonError). That's what the ubiquitous .unwrap() is doing: declaring "if another thread died mid-update, I'll die too." Another place where the language takes data integrity seriously.

RwLock

When reads dominate, a mutex is overkill.

use std::sync::RwLock;

let config = RwLock::new(Config::default());

let c = config.read().unwrap();       // many readers at once
// ...
let mut c = config.write().unwrap();  // writers are exclusive

It's the borrow rule — many &T or one &mut T — transplanted into a runtime lock. Seeing the same rule repeated at compile time (references), at runtime on one thread (RefCell), and at runtime across threads (RwLock) is where the consistency of the design really landed for me.

Condition variables

For "wait until a condition holds."

use std::sync::Condvar;

let (lock, cvar) = &*pair;
let mut started = lock.lock().unwrap();

while !*started {
    started = cvar.wait(started).unwrap();   // release, wait, reacquire
}

Note that wait consumes the guard and returns a new one. The protocol — release the lock while waiting, reacquire on wakeup — is encoded in the type signature. And because of spurious wakeups you must loop with while, not if.

Atomics

For a plain counter, even a mutex is too much.

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

static COUNTER: AtomicUsize = AtomicUsize::new(0);

COUNTER.fetch_add(1, Ordering::SeqCst);

No lock — just the CPU's atomic instructions. The Ordering parameter picks the memory ordering, with SeqCst the strongest and slowest. Using Relaxed, Acquire and Release correctly requires knowing the memory model, so the lecture's advice was "if in doubt, SeqCst."

Global variables

static PACKETS_SERVED: AtomicUsize = AtomicUsize::new(0);   // fine

// static mut COUNTER: usize = 0;   // access requires unsafe

static mut permits data races and therefore requires unsafe. An atomic type or a Mutex in a static is the safe alternative; when initialization needs computation, OnceLock or LazyLock.

Assignment: the Mandelbrot set

Parallelize the same program four ways. The comparison is the whole point.

1. Single-threaded — the baseline.

2. Multi-threaded — split the image into horizontal bands, one per thread.

let bands = pixels.chunks_mut(bounds.0).enumerate().collect::<Vec<_>>();
let mut handles = Vec::new();

for (i, band) in bands.into_iter() {
    let mut band = band.to_owned();
    let handle = thread::spawn(move || {
        render(&mut band, band_bounds, band_upper_left, band_lower_right);
        band
    });
    handles.push(handle);
}

for (i, handle) in handles.into_iter().enumerate() {
    let band = handle.join().expect("Thread panicked");
    pixels[start..end].copy_from_slice(&band);
}

The key is that chunks_mut splits the vector into non-overlapping mutable slices. Because they don't overlap, handing each to a different thread is safe — and the compiler knows it.

3. Rayon — the same thing in one line.

use rayon::prelude::*;

let bands: Vec<(usize, &mut [u8])> = pixels.chunks_mut(bounds.0).enumerate().collect();

bands.into_par_iter().for_each(|(i, band)| {
    render(band, band_bounds, band_upper_left, band_lower_right);
});

into_iter() becomes into_par_iter() and that's it — no thread count, no join. It's also faster than version 2, which had to copy each band with to_owned() and copy it back.

4. Lock-free — don't pre-assign bands; let threads grab the next one via an atomic counter.

pub struct AtomicChunksMut<'a, T> {
    slice: ...,
    step: usize,
    next: AtomicUsize,      // index of the next band
}

let bands = AtomicChunksMut::new(&mut pixels, rows_per_band * bounds.0);

crossbeam::scope(|scope| {
    for _ in 0..threads {
        scope.spawn(|_| {
            for (i, band) in &bands {       // atomically take the next band
                render(band, band_bounds, band_upper_left, band_lower_right);
            }
        });
    }
});

Mandelbrot bands have wildly different costs: pixels inside the set run to the iteration limit, pixels outside bail out immediately. Distribute bands evenly up front and some threads idle while others grind. Letting threads pull work dynamically through an atomic counter balances the load by itself.

crossbeam::scope matters too. Standard thread::spawn requires 'static and therefore can't borrow locals, but scoped threads are guaranteed to be joined before the scope ends, so borrowing local data is allowed — which is why no to_owned() copy is needed here.

Running all four shows the performance differences directly, and being able to explain those differences from the code (how work is distributed, whether data is copied) was the real payoff of this assignment.

Takeaways

  • three approaches: fork-join, channels, shared mutable state
  • Send/Sync define thread safety as types; the existing ownership rules do the rest
  • sending over a channel is a move, so no sharing happens in the first place
  • Mutex<T> owns its data, so you can't forget the lock — but deadlock is still possible
  • the borrow rule reappears at runtime as RwLock
  • for uneven workloads, dynamic distribution beats static partitioning

Next: macros — extending the syntax itself with macro_rules!.