Frontend Labs
⋯

Part III — Advanced: concurrency, async, unsafe

The ownership rules from Part I matter most in this part. The same rule, shared XOR mutable, that prevents iterator invalidation also makes data races a compile error. This Part also covers async Rust, which is how Rust handles tens of thousands of connections, and unsafe, which is how Rust talks to C and the kernel.


28. Threads, and why data races do not compile

28.1 Spawning and joining

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

fn main() {
    let handles: Vec<thread::JoinHandle<u64>> = (0..4)
        .map(|id| {
            thread::spawn(move || {           // `move`: the thread owns its copy of `id`
                thread::sleep(Duration::from_millis(10 * id));
                (1..=1_000_000u64).filter(|n| n % (id + 2) == 0).count() as u64
            })
        })
        .collect();

    // join() returns Result: Err if the thread panicked.
    let results: Vec<u64> = handles.into_iter().map(|h| h.join().expect("worker panicked")).collect();
    println!("{results:?}");
    println!("cores available: {:?}", thread::available_parallelism()); // respects cgroup CPU limits on Linux
}

std::thread creates one OS thread per spawn (1:1), the same as pthread_create in the C guide §20. Each thread gets about 2 MiB of stack by default (Rust's default, configurable with thread::Builder::stack_size). Go's goroutines are M:N and start at a few KiB (Go §15). Rust's version of cheap tasks is async (Ch 30).

Across the series: what a thread is to the kernel, and what a context switch costs, is in OS Ch 7 and OS Ch 9.

28.2 The data race that will not compile

// DOES NOT COMPILE
use std::thread;

fn main() {
    let mut counter = 0;
    let mut handles = vec![];
    for _ in 0..4 {
        handles.push(thread::spawn(|| {
            for _ in 0..1000 { counter += 1; } // four threads writing one variable
        }));
    }
    for h in handles { h.join().unwrap(); }
    println!("{counter}");
}
error[E0499]: cannot borrow `counter` as mutable more than once at a time
error[E0373]: closure may outlive the current function, but it borrows `counter`

The C version of this program compiles and prints a different wrong number on each run (OS Ch 17 shows the same race with shell processes). The Go version compiles, and go run -race catches it if the race happens during that run. Rust rejects it at compile time, because four &mut counter borrows would exist at the same time.

28.3 Send and Sync

Two marker traits, implemented automatically by the compiler, encode thread safety in the type system:

  • T: Send: a T can be moved to another thread. Almost everything is Send. Rc<T> is not: its reference count is non-atomic, so two threads cloning it would race on the counter.
  • T: Sync: a &T can be shared between threads, that is, &T is Send. RefCell<T> and Cell<T> are not Sync, because their runtime borrow tracking is not atomic. Mutex<T> is Sync.

thread::spawn requires F: Send + 'static. That one bound is what makes the next program fail:

// DOES NOT COMPILE
use std::rc::Rc;

fn main() {
    let shared = Rc::new(vec![1, 2, 3]);
    let s2 = Rc::clone(&shared);
    std::thread::spawn(move || println!("{s2:?}")).join().unwrap();
}
error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
  = help: within `{closure}`, the trait `Send` is not implemented for `Rc<Vec<i32>>`

Replace Rc with Arc, which uses atomic counters, and it compiles. The compiler does not know anything special about threads. Thread safety follows from two traits and a bound on spawn.

28.4 Scoped threads: borrowing across threads

fn main() {
    let data: Vec<u64> = (1..=10_000).collect();
    let mut partial = [0u64; 4];

    // Threads spawned in a scope are guaranteed to be joined before the scope ends,
    // so they may BORROW local data. No Arc and no 'static needed.
    std::thread::scope(|s| {
        for (chunk, slot) in data.chunks(data.len() / 4).zip(partial.iter_mut()) {
            s.spawn(move || *slot = chunk.iter().sum());
        }
    });
    println!("{partial:?} total={}", partial.iter().sum::<u64>()); // total=50005000
}

chunks gives each thread a disjoint &[u64], and iter_mut gives each thread its own &mut u64. Shared XOR mutable holds, so no locks are needed. For data-parallel loops, the rayon crate turns .iter() into .par_iter() and handles the work-splitting for you.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36