Guide contents

Lesson 67 of 94

Atomics, Send & Sync

This lesson finishes the Concurrency section in two parts:

  1. Atomics and lazy globals: the lock-free tools under Mutex and Arc, plus one-time lazy initialization.
  2. Send and Sync: the two traits behind every "cannot be sent between threads safely" error you've seen so far. Once you understand them, Rust's concurrency rules stop being magic.
Need Rust
Atomic integers AtomicI32, AtomicU64, AtomicUsize, ...
Atomic boolean AtomicBool
Increment or add fetch_add(n, ordering) (returns the old value)
Compare and set compare_exchange(expected, new, ok_ord, err_ord)
Read-modify-write with a closure fetch_update(ord, ord, f)
Publish data through a flag an atomic with Acquire/Release (or SeqCst) ordering
Thread-safe lazy initialization OnceLock<T>, LazyLock<T>
"this type is thread-safe" Send / Sync, checked by the compiler

Part 1: Atomics

1. An atomic counter

Atomics are integers and booleans that can be updated safely from many threads without a lock. fetch_add adds and returns the previous value in one indivisible step. Every operation takes an Ordering; Relaxed is enough for a simple counter (example 8 explains orderings).

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

fn main() {
    let hits = AtomicUsize::new(0);

    thread::scope(|s| {
        for _ in 0..8 {
            s.spawn(|| {
                for _ in 0..10_000 {
                    hits.fetch_add(1, Ordering::Relaxed);
                }
            });
        }
    });

    println!("hits: {}", hits.load(Ordering::Relaxed));
}
Output
hits: 80000

Note that hits isn't mut: atomics change through &self, like Cell, but safely across threads.

2. A static atomic: no Arc needed

Atomics can be static, because AtomicUsize::new is a const fn. A global counter needs no Arc, no Mutex, and no unsafe.

Rust
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

static REQUESTS: AtomicU64 = AtomicU64::new(0);

fn handle_request(path: &str) -> usize {
    REQUESTS.fetch_add(1, Ordering::Relaxed);
    path.len()
}

fn main() {
    let handles: Vec<_> = (0..4)
        .map(|i| thread::spawn(move || (0..25).map(|_| handle_request(if i % 2 == 0 { "/home" } else { "/api" })).sum::<usize>()))
        .collect();
    let bytes: usize = handles.into_iter().map(|h| h.join().unwrap()).sum();

    println!("requests: {}", REQUESTS.load(Ordering::Relaxed));
    println!("path bytes: {bytes}");
}
Output
requests: 100
path bytes: 450

3. fetch_add returns the old value: unique ids

Because fetch_add returns the value before the add, each caller gets a different number. That makes a lock-free id generator a one-liner.

Rust
use std::collections::HashSet;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;

static NEXT_ORDER_ID: AtomicU32 = AtomicU32::new(1000);

fn next_order_id() -> u32 {
    NEXT_ORDER_ID.fetch_add(1, Ordering::Relaxed)
}

fn main() {
    let ids: Vec<u32> = thread::scope(|s| {
        let handles: Vec<_> = (0..4).map(|_| s.spawn(|| (0..50).map(|_| next_order_id()).collect::<Vec<_>>())).collect();
        handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
    });

    let unique: HashSet<u32> = ids.iter().copied().collect();
    println!("{} ids, {} unique", ids.len(), unique.len());
    println!("range: {}..={}", ids.iter().min().unwrap(), ids.iter().max().unwrap());
}
Output
200 ids, 200 unique
range: 1000..=1199

4. AtomicBool: a stop flag

A shared "keep running" flag is the classic use of AtomicBool, and the usual way to ask a thread to stop.

Rust
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;

fn main() {
    let running = Arc::new(AtomicBool::new(true));

    let r = Arc::clone(&running);
    let worker = thread::spawn(move || {
        let mut polls = 0;
        while r.load(Ordering::Acquire) {
            polls += 1; // poll a queue, check a socket...
            thread::sleep(Duration::from_millis(5));
        }
        polls
    });

    thread::sleep(Duration::from_millis(40));
    running.store(false, Ordering::Release);
    let polls = worker.join().unwrap();
    println!("worker polled and then stopped: {}", polls > 0);
}
Output
worker polled and then stopped: true

5. load, store, and swap

load reads, store writes, and swap writes and returns what was there before. swap is perfect for "read and reset" operations like collecting a metric every interval.

Rust
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

static BYTES_SENT: AtomicU64 = AtomicU64::new(0);

fn main() {
    let mut reports = Vec::new();
    for second in 1..=3 {
        thread::scope(|s| {
            for _ in 0..4 {
                s.spawn(move || BYTES_SENT.fetch_add(100 * second, Ordering::Relaxed));
            }
        });
        // Read this interval's total and reset to zero in one atomic step.
        reports.push(BYTES_SENT.swap(0, Ordering::Relaxed));
    }
    println!("bytes per interval: {reports:?}");
    println!("after reset: {}", BYTES_SENT.load(Ordering::Relaxed));
}
Output
bytes per interval: [400, 800, 1200]
after reset: 0

6. fetch_max, fetch_min, fetch_and, fetch_or

Atomics have read-modify-write operations beyond add. fetch_max tracks a maximum without a CAS loop; fetch_or/fetch_and set and clear bits in a flag word.

Rust
use std::sync::atomic::{AtomicU32, AtomicU8, Ordering};
use std::thread;

const FLAG_DB: u8 = 0b001;
const FLAG_CACHE: u8 = 0b010;
const FLAG_QUEUE: u8 = 0b100;

fn main() {
    let max_latency = AtomicU32::new(0);
    let ready = AtomicU8::new(0);
    let latencies = [[12, 40, 7], [90, 3, 15], [22, 61, 5]];

    thread::scope(|s| {
        for (worker, samples) in latencies.iter().enumerate() {
            let (max_latency, ready) = (&max_latency, &ready);
            s.spawn(move || {
                for &ms in samples {
                    max_latency.fetch_max(ms, Ordering::Relaxed);
                }
                ready.fetch_or([FLAG_DB, FLAG_CACHE, FLAG_QUEUE][worker], Ordering::Relaxed);
            });
        }
    });

    println!("max latency: {} ms", max_latency.load(Ordering::Relaxed));
    println!("ready flags: {:03b}", ready.load(Ordering::Relaxed));
    ready.fetch_and(!FLAG_CACHE, Ordering::Relaxed); // cache went down
    println!("after cache failure: {:03b}", ready.load(Ordering::Relaxed));
}
Output
max latency: 90 ms
ready flags: 111
after cache failure: 101

7. compare_exchange: the CAS primitive

compare_exchange(expected, new, ...) sets the value to new only if it currently equals expected, and tells you whether it succeeded. Here many threads race to claim a single "leader" slot, and exactly one wins.

Rust
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;

const NO_LEADER: u32 = 0;

fn main() {
    let leader = AtomicU32::new(NO_LEADER);

    let winners: Vec<u32> = thread::scope(|s| {
        let handles: Vec<_> = (1..=5)
            .map(|node_id| {
                let leader = &leader;
                s.spawn(move || {
                    match leader.compare_exchange(NO_LEADER, node_id, Ordering::AcqRel, Ordering::Acquire) {
                        Ok(_) => Some(node_id),       // we set it: we're the leader
                        Err(_current) => None,         // someone else got there first
                    }
                })
            })
            .collect();
        handles.into_iter().filter_map(|h| h.join().unwrap()).collect()
    });

    println!("number of winners: {}", winners.len());
    println!("winner matches stored leader: {}", winners[0] == leader.load(Ordering::Acquire));
}
Output
number of winners: 1
winner matches stored leader: true

8. A CAS loop and fetch_update

For updates that no single fetch_* covers, loop: read, compute, compare_exchange, and retry if another thread changed the value in between. fetch_update writes this loop for you. Returning None from the closure aborts the update.

Rust
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;

// Take `n` items from stock if enough are left. Never goes below zero.
fn reserve(stock: &AtomicU32, n: u32) -> Result<u32, u32> {
    stock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| current.checked_sub(n))
}

// The same thing, spelled out as a manual CAS loop.
fn reserve_manual(stock: &AtomicU32, n: u32) -> Result<u32, u32> {
    let mut current = stock.load(Ordering::Acquire);
    loop {
        let Some(new) = current.checked_sub(n) else { return Err(current) };
        match stock.compare_exchange_weak(current, new, Ordering::AcqRel, Ordering::Acquire) {
            Ok(prev) => return Ok(prev),
            Err(actual) => current = actual, // someone changed it: retry with the fresh value
        }
    }
}

fn main() {
    let stock = AtomicU32::new(100);

    let sold: u32 = thread::scope(|s| {
        let handles: Vec<_> = (0..8)
            .map(|i| {
                let stock = &stock;
                s.spawn(move || {
                    let mut sold = 0;
                    for _ in 0..10 {
                        let r = if i % 2 == 0 { reserve(stock, 3) } else { reserve_manual(stock, 3) };
                        if r.is_ok() {
                            sold += 3;
                        }
                    }
                    sold
                })
            })
            .collect();
        handles.into_iter().map(|h| h.join().unwrap()).sum()
    });

    println!("sold {sold}, left {}", stock.load(Ordering::Acquire));
    println!("nothing oversold: {}", sold + stock.load(Ordering::Acquire) == 100);
}
Output
sold 99, left 1
nothing oversold: true

compare_exchange_weak may fail spuriously even when the value matches, which is fine inside a retry loop and can be faster on some CPUs.

9. Memory ordering in one example

An atomic operation's Ordering says what other memory the operation synchronizes. The key pair is Release/Acquire: everything a thread wrote before a Release store is visible to a thread that sees that value with an Acquire load.

Rust
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;

static DATA: AtomicU64 = AtomicU64::new(0);
static READY: AtomicBool = AtomicBool::new(false);

fn main() {
    thread::scope(|s| {
        s.spawn(|| {
            DATA.store(42, Ordering::Relaxed); // 1. write the data
            READY.store(true, Ordering::Release); // 2. publish: "data is ready"
        });

        s.spawn(|| {
            while !READY.load(Ordering::Acquire) {} // 3. wait until published
            // 4. Guaranteed to see 42: Acquire "sees" everything before the Release.
            assert_eq!(DATA.load(Ordering::Relaxed), 42);
        });
    });
    println!("the reader saw the published data");
}
Output
the reader saw the published data
Ordering Meaning Use for
Relaxed atomic, but no ordering with other memory counters, statistics, id generators
Release (store) / Acquire (load) publish / observe everything written before flags that guard other data, locks
AcqRel both, for read-modify-write ops CAS that both reads and publishes
SeqCst a single global order of all SeqCst ops when in doubt

When unsure, use SeqCst: it's never less correct than the others, just sometimes slower. In practice, most application code uses Mutex, channels, and Relaxed counters, and never needs to think harder than this.

10. Atomics are for single values

An atomic protects one value. Two atomics updated one after the other can be observed half-updated, so invariants across several values need a Mutex.

Rust
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

// Wrong tool: count and total are updated separately, so a reader can see
// a count that doesn't match the total yet.
struct RacyStats {
    count: AtomicU64,
    total_ms: AtomicU64,
}

// Right tool: both fields change together under one lock.
#[derive(Default)]
struct Stats {
    count: u64,
    total_ms: u64,
}

fn main() {
    let racy = RacyStats { count: AtomicU64::new(0), total_ms: AtomicU64::new(0) };
    let safe = Mutex::new(Stats::default());

    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..1000 {
                    racy.count.fetch_add(1, Ordering::Relaxed);
                    racy.total_ms.fetch_add(10, Ordering::Relaxed);
                    let mut st = safe.lock().unwrap();
                    st.count += 1;
                    st.total_ms += 10;
                }
            });
        }
    });

    // Once all threads are done, both agree. The difference is what a reader
    // sees *during* the run: only the Mutex version is always consistent.
    let st = safe.lock().unwrap();
    println!("atomic: {} / {}", racy.count.load(Ordering::Relaxed), racy.total_ms.load(Ordering::Relaxed));
    println!("mutex:  {} / {}", st.count, st.total_ms);
}
Output
atomic: 4000 / 40000
mutex:  4000 / 40000

11. OnceLock: initialize exactly once

OnceLock<T> holds a value that is set once and then read forever. get_or_init runs the initializer on first use; if several threads race, exactly one runs it and the others wait for the result.

Rust
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;

static INIT_CALLS: AtomicU32 = AtomicU32::new(0);
static COUNTRY_NAMES: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();

fn country_name(code: &str) -> Option<&'static str> {
    let table = COUNTRY_NAMES.get_or_init(|| {
        INIT_CALLS.fetch_add(1, Ordering::Relaxed);
        HashMap::from([("DE", "Germany"), ("IN", "India"), ("JP", "Japan")]) // pretend: loaded from a file
    });
    table.get(code).copied()
}

fn main() {
    let names: Vec<Option<&str>> = thread::scope(|s| {
        let handles: Vec<_> = ["IN", "JP", "XX", "DE"].map(|c| s.spawn(move || country_name(c))).into_iter().collect();
        handles.into_iter().map(|h| h.join().unwrap()).collect()
    });
    println!("{names:?}");
    println!("initializer ran {} time(s)", INIT_CALLS.load(Ordering::Relaxed));
}
Output
[Some("India"), Some("Japan"), None, Some("Germany")]
initializer ran 1 time(s)

12. LazyLock: a lazy static with its initializer attached

LazyLock<T> bundles the initializer with the value, so every use simply dereferences it. It's the standard way to write a global that needs runtime initialization (a compiled regex, a lookup table, a parsed config).

Rust
use std::collections::HashSet;
use std::sync::LazyLock;

static STOP_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
    "a an and are as at be by for from in is it of on or the to with".split(' ').collect()
});

fn keywords(text: &str) -> Vec<String> {
    text.split_whitespace()
        .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase())
        .filter(|w| !w.is_empty() && !STOP_WORDS.contains(w.as_str()))
        .collect()
}

fn main() {
    println!("{:?}", keywords("The quick brown fox is jumping over the lazy dog."));
    println!("{} stop words loaded", STOP_WORDS.len());
}
Output
["quick", "brown", "fox", "jumping", "over", "lazy", "dog"]
19 stop words loaded

Before these types were stabilized, the lazy_static! macro and the once_cell crate filled this role; you'll still see them in older code.

13. OnceLock inside a struct: a cached field

OnceLock also works as a field, for a value that's expensive to compute and may never be needed. Unlike a RefCell<Option<T>> cache, it can be shared between threads.

Rust
use std::sync::OnceLock;
use std::thread;

struct Document {
    text: String,
    word_count: OnceLock<usize>,
}

impl Document {
    fn new(text: &str) -> Self {
        Document { text: text.to_string(), word_count: OnceLock::new() }
    }

    fn word_count(&self) -> usize {
        *self.word_count.get_or_init(|| self.text.split_whitespace().count()) // computed at most once
    }
}

fn main() {
    let doc = Document::new("Rust makes concurrent code safe and fast");
    println!("computed yet? {}", doc.word_count.get().is_some());

    let counts: Vec<usize> = thread::scope(|s| {
        let hs: Vec<_> = (0..3).map(|_| s.spawn(|| doc.word_count())).collect();
        hs.into_iter().map(|h| h.join().unwrap()).collect()
    });
    println!("{counts:?}, computed yet? {}", doc.word_count.get().is_some());
}
Output
computed yet? false
[7, 7, 7], computed yet? true

Part 2: Send and Sync

Every compile error about threads you've seen so far comes from two marker traits:

  • Send: a value of this type can be moved to another thread. (Ownership can cross threads.)
  • Sync: a &T can be shared between threads. Formally, T is Sync if &T is Send.

They're auto traits: the compiler implements them automatically for a type whose fields are all Send (or Sync). You almost never write them yourself. thread::spawn requires its closure and result to be Send, and anything shared through Arc or scoped threads must be Sync. That's all the checking there is, and it's enough to rule out data races.

14. Checking Send and Sync yourself

A generic function with a Send or Sync bound compiles only for types that have it. This is a handy way to ask the compiler a question.

Rust
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::{Arc, Mutex, RwLock};

fn is_send<T: Send>() {}
fn is_sync<T: Sync>() {}

fn main() {
    // These all compile, so each type has the trait.
    is_send::<String>();
    is_sync::<String>();
    is_send::<Arc<Vec<i32>>>();
    is_sync::<Arc<Vec<i32>>>();
    is_sync::<Mutex<Vec<i32>>>();
    is_sync::<RwLock<String>>();
    is_send::<Cell<i32>>(); // Send: can be moved to another thread...
    is_send::<RefCell<i32>>();
    // is_sync::<Cell<i32>>();   // ...but NOT Sync: can't be shared (error E0277)
    // is_send::<Rc<i32>>();     // Rc is neither Send nor Sync
    let _ = (Rc::new(1), Cell::new(1), RefCell::new(1));
    println!("all checks compiled");
}
Output
all checks compiled
Type Send Sync Why
i32, String, Vec<T> (for T: Send + Sync) yes yes plain owned data
Rc<T> no no non-atomic reference count
Arc<T> if T: Send + Sync if T: Send + Sync atomic count; shares T
Cell<T>, RefCell<T> if T: Send no unsynchronized interior mutability
Mutex<T> if T: Send if T: Send the lock makes sharing safe
RwLock<T> if T: Send if T: Send + Sync readers share &T at the same time
MutexGuard<T> no if T: Sync must be unlocked by the thread that locked it
atomics yes yes that's their purpose
&T if T: Sync if T: Sync
raw pointers *const T, *mut T no no the compiler can't know what they point to

15. Cell is not Sync

Cell allows mutation through a shared reference with no synchronization. Sharing it between threads would be a data race, so it isn't Sync:

Rust✗ does not compile · E0277
use std::cell::Cell;
use std::thread;

fn main() {
    let counter = Cell::new(0);
    thread::scope(|s| {
        s.spawn(|| counter.set(counter.get() + 1));
        s.spawn(|| counter.set(counter.get() + 1));
    });
    println!("{}", counter.get());
}
Output
error[E0277]: `Cell<i32>` cannot be shared between threads safely
 --> src/main.rs:7:17
  |
7 |         s.spawn(|| counter.set(counter.get() + 1));
  |           ----- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Cell<i32>` cannot be shared between threads safely
  |           |
  |           required by a bound introduced by this call
  |
  = help: the trait `Sync` is not implemented for `Cell<i32>`
  = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicI32` instead
  = note: required for `&Cell<i32>` to implement `Send`
note: required because it's used within this closure
 --> src/main.rs:7:17
  |
7 |         s.spawn(|| counter.set(counter.get() + 1));
  |                 ^^
note: required by a bound in `Scope::<'scope, 'env>::spawn`
 --> /rustc/<version>/library/std/src/thread/scoped.rs:198:4

The thread-safe versions: AtomicI32 for Cell<i32>, and Mutex<T> or RwLock<T> for RefCell<T>.

16. Arc<RefCell<T>> doesn't work either

A common first attempt is to combine Arc (for sharing) with RefCell (for mutation). Arc<T> is only Send when T is Sync, and RefCell isn't:

Rust✗ does not compile · E0277
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;

fn main() {
    let cart = Arc::new(RefCell::new(Vec::new()));
    let c = Arc::clone(&cart);
    thread::spawn(move || c.borrow_mut().push("book")).join().unwrap();
    println!("{:?}", cart.borrow());
}
Output
error[E0277]: `RefCell<Vec<&str>>` cannot be shared between threads safely
 --> src/main.rs:8:19
  |
8 |     thread::spawn(move || c.borrow_mut().push("book")).join().unwrap();
  |     ------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RefCell<Vec<&str>>` cannot be shared between threads safely
  |     |
  |     required by a bound introduced by this call
  |
  = help: the trait `Sync` is not implemented for `RefCell<Vec<&str>>`
  = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
  = note: required for `Arc<RefCell<Vec<&str>>>` to implement `Send`
note: required because it's used within this closure
 --> src/main.rs:8:19
  |
8 |     thread::spawn(move || c.borrow_mut().push("book")).join().unwrap();
  |                   ^^^^^^^
note: required by a bound in `spawn`
 --> /rustc/<version>/library/std/src/thread/functions.rs:125:0

The fix is to replace RefCell with Mutex (and borrow_mut() with lock().unwrap()). The single-threaded and multi-threaded pairs line up:

Single-threaded Multi-threaded
Rc<T> Arc<T>
Cell<T> AtomicXxx
RefCell<T> Mutex<T> / RwLock<T>
OnceCell<T> OnceLock<T>
LazyCell<T> LazyLock<T>

17. A MutexGuard can't leave its thread

Some platforms require a mutex to be unlocked by the same thread that locked it, so MutexGuard is not Send. Trying to hand a held lock to another thread fails to compile:

Rust✗ does not compile · E0277
use std::sync::Mutex;
use std::thread;

fn main() {
    let queue = Mutex::new(vec![1, 2, 3]);
    let guard = queue.lock().unwrap();
    thread::scope(|s| {
        s.spawn(move || println!("{:?}", *guard));
    });
}
Output
error[E0277]: `std::sync::MutexGuard<'_, Vec<i32>>` cannot be sent between threads safely
 --> src/main.rs:8:17
  |
8 |         s.spawn(move || println!("{:?}", *guard));
  |           ----- -------^^^^^^^^^^^^^^^^^^^^^^^^^
  |           |     |
  |           |     `std::sync::MutexGuard<'_, Vec<i32>>` cannot be sent between threads safely
  |           |     within this `{closure@src/main.rs:8:17: 8:24}`
  |           required by a bound introduced by this call
  |
  = help: within `{closure@src/main.rs:8:17: 8:24}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, Vec<i32>>`
note: required because it's used within this closure
 --> src/main.rs:8:17
  |
8 |         s.spawn(move || println!("{:?}", *guard));
  |                 ^^^^^^^
note: required by a bound in `Scope::<'scope, 'env>::spawn`
 --> /rustc/<version>/library/std/src/thread/scoped.rs:198:4

18. Your structs get Send and Sync automatically

A struct is Send if all its fields are Send, and Sync if all are Sync. Add one Rc field and the whole struct stops being Send, and the error message points at the culprit:

Rust✗ does not compile · E0277
use std::rc::Rc;
use std::thread;

struct Session {
    user: String,
    permissions: Rc<Vec<String>>, // shared between sessions to save memory
}

fn main() {
    let s = Session { user: String::from("ana"), permissions: Rc::new(vec![String::from("read")]) };
    thread::spawn(move || println!("{} can {:?}", s.user, s.permissions)).join().unwrap();
}
Output
error[E0277]: `Rc<Vec<String>>` cannot be sent between threads safely
  --> src/main.rs:11:19
   |
11 |     thread::spawn(move || println!("{} can {:?}", s.user, s.permissions)).join().unwrap();
   |     ------------- -------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |     |             |
   |     |             `Rc<Vec<String>>` cannot be sent between threads safely
   |     |             within this `{closure@src/main.rs:11:19: 11:26}`
   |     required by a bound introduced by this call
   |
   = help: within `{closure@src/main.rs:11:19: 11:26}`, the trait `Send` is not implemented for `Rc<Vec<String>>`
note: required because it's used within this closure
  --> src/main.rs:11:19
   |
11 |     thread::spawn(move || println!("{} can {:?}", s.user, s.permissions)).join().unwrap();
   |                   ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/<version>/library/std/src/thread/functions.rs:125:0

Change Rc to Arc and the struct becomes Send and Sync again, with no other changes. Thread-safety is derived from a type's fields and checked at every use.

19. Writing Send bounds in your own APIs

When you write a function that runs work on another thread, you put the same bounds thread::spawn has: the closure and its result must be Send + 'static.

Rust
use std::thread::{self, JoinHandle};
use std::time::Instant;

fn run_timed<F, T>(name: &'static str, work: F) -> JoinHandle<(T, bool)>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
{
    thread::Builder::new()
        .name(name.to_string())
        .spawn(move || {
            let start = Instant::now();
            let result = work();
            (result, start.elapsed().as_secs() < 5)
        })
        .unwrap()
}

fn main() {
    let words = vec![String::from("send"), String::from("sync")];
    let h1 = run_timed("sum", || (1..=100u32).sum::<u32>());
    let h2 = run_timed("words", move || words.join(" + "));

    println!("{:?}", h1.join().unwrap());
    println!("{:?}", h2.join().unwrap());
}
Output
(5050, true)
("send + sync", true)

20. Send in trait objects

A trait object doesn't know its concrete type, so the compiler can't infer Send for it. You add it to the type yourself: Box<dyn Fn() + Send>. Without + Send, a boxed closure can't be moved to a worker thread.

Rust
use std::thread;

type Task = Box<dyn FnOnce() -> String + Send>;

fn main() {
    let customer = String::from("ACME");
    let tasks: Vec<Task> = vec![
        Box::new(|| String::from("compress logs")),
        Box::new(move || format!("invoice {customer}")),
        Box::new(|| (1..=5).map(|n| n.to_string()).collect::<Vec<_>>().join(",")),
    ];

    let worker = thread::spawn(move || tasks.into_iter().map(|t| t()).collect::<Vec<_>>());
    println!("{:?}", worker.join().unwrap());
}
Output
["compress logs", "invoice ACME", "1,2,3,4,5"]

The same goes for errors: a thread that returns errors as trait objects uses Box<dyn Error + Send + Sync>, the standard "any error that can cross threads" type.

Rust
use std::error::Error;
use std::thread;

type BoxError = Box<dyn Error + Send + Sync>;

fn load_port(raw: &str) -> Result<u16, BoxError> {
    let port: u16 = raw.parse()?;
    if port < 1024 {
        return Err(format!("port {port} is privileged").into());
    }
    Ok(port)
}

fn main() {
    let handles: Vec<_> = ["8080", "80", "http"].map(|raw| thread::spawn(move || load_port(raw))).into_iter().collect();
    for h in handles {
        match h.join().unwrap() {
            Ok(port) => println!("port {port}"),
            Err(e) => println!("error: {e}"),
        }
    }
}
Output
port 8080
error: port 80 is privileged
error: invalid digit found in string

21. Why Rc is !Send: what would go wrong

It helps to see the race that Send prevents. Rc::clone does count = count + 1 as a normal read and write. If two threads cloned at the same moment, both could read 1 and both write 2, losing an increment; later the value is freed while still in use. Arc uses fetch_add for the same step. You can see the difference in the counts:

Rust
use std::sync::Arc;
use std::thread;

fn main() {
    let shared = Arc::new(String::from("config"));

    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                let mut clones = Vec::new();
                for _ in 0..1000 {
                    clones.push(Arc::clone(&shared)); // atomic increment
                }
                // all 1000 clones dropped here: atomic decrements
            });
        }
    });

    // With a non-atomic count, lost updates would leave this wrong.
    println!("strong count after 4000 clones and drops: {}", Arc::strong_count(&shared));
}
Output
strong count after 4000 clones and drops: 1

22. Opting in with unsafe impl (a preview)

For types built on raw pointers, the compiler can't tell whether sharing is safe, so it assumes not. The author can promise it with unsafe impl Send/unsafe impl Sync. That's how Arc and Mutex themselves are written. It's unsafe because the compiler takes your word for it:

Rust
struct MyBuffer {
    ptr: *mut u8, // raw pointer: makes the struct !Send and !Sync by default
    len: usize,
}

// SAFETY: MyBuffer owns its allocation exclusively and never shares `ptr`,
// so moving it to another thread is safe.
unsafe impl Send for MyBuffer {}

The Unsafe Rust & FFI lesson covers when (rarely) this is appropriate.

23. Realistic: lock-free server statistics

A statistics block shared by request handlers, updated with Relaxed atomics on the hot path, and read occasionally by a reporter. Each counter is independent, so atomics fit perfectly.

Rust
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::thread;

#[derive(Default)]
struct ServerStats {
    requests: AtomicU64,
    errors: AtomicU64,
    bytes_out: AtomicU64,
    max_latency_us: AtomicU64,
}

impl ServerStats {
    fn record(&self, status: u16, bytes: u64, latency_us: u64) {
        self.requests.fetch_add(1, Relaxed);
        if status >= 500 {
            self.errors.fetch_add(1, Relaxed);
        }
        self.bytes_out.fetch_add(bytes, Relaxed);
        self.max_latency_us.fetch_max(latency_us, Relaxed);
    }

    fn summary(&self) -> String {
        let req = self.requests.load(Relaxed);
        format!(
            "{req} requests, {:.1}% errors, {} KiB out, max {} us",
            100.0 * self.errors.load(Relaxed) as f64 / req as f64,
            self.bytes_out.load(Relaxed) / 1024,
            self.max_latency_us.load(Relaxed)
        )
    }
}

fn main() {
    let stats = ServerStats::default();
    thread::scope(|s| {
        for worker in 0..4u64 {
            let stats = &stats;
            s.spawn(move || {
                for i in 0..250u64 {
                    let status = if (worker * 250 + i) % 50 == 0 { 503 } else { 200 };
                    stats.record(status, 2048, 100 + (i * 7 + worker * 13) % 900);
                }
            });
        }
    });
    println!("{}", stats.summary());
}
Output
1000 requests, 2.0% errors, 2000 KiB out, max 996 us

Under heavy contention a single shared counter becomes a bottleneck; the fix is to shard counters per thread and sum them, much like the sharded map in the previous lesson.

24. Realistic: graceful shutdown with a flag and a lazy global

A tiny server skeleton: configuration is a LazyLock global, workers loop until a shutdown flag is set, and each worker reports how many "requests" it served.

Rust
use std::sync::LazyLock;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
use std::time::Duration;

struct Config {
    workers: usize,
    poll_ms: u64,
}

static CONFIG: LazyLock<Config> = LazyLock::new(|| Config { workers: 3, poll_ms: 2 }); // pretend: read env vars
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
static SERVED: AtomicU64 = AtomicU64::new(0);

fn worker_loop(id: usize) -> (usize, u64) {
    let mut served = 0;
    while !SHUTDOWN.load(Ordering::Acquire) {
        served += 1; // "handle a request"
        SERVED.fetch_add(1, Ordering::Relaxed);
        thread::sleep(Duration::from_millis(CONFIG.poll_ms));
    }
    (id, served)
}

fn main() {
    let handles: Vec<_> = (0..CONFIG.workers).map(|id| thread::spawn(move || worker_loop(id))).collect();

    thread::sleep(Duration::from_millis(50));
    SHUTDOWN.store(true, Ordering::Release); // e.g. on Ctrl-C
    let per_worker: Vec<(usize, u64)> = handles.into_iter().map(|h| h.join().unwrap()).collect();

    let sum: u64 = per_worker.iter().map(|(_, n)| n).sum();
    println!("{} workers stopped", per_worker.len());
    println!("every worker served something: {}", per_worker.iter().all(|(_, n)| *n > 0));
    println!("totals agree: {}", sum == SERVED.load(Ordering::Relaxed));
}
Output
3 workers stopped
every worker served something: true
totals agree: true

25. Realistic: a sequence allocator handing out id blocks

Database-style id allocation: instead of one atomic operation per id, each worker reserves a block of ids with a single fetch_add, then hands them out locally with no contention.

Rust
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;

struct Sequence {
    next_block_start: AtomicU64,
    block_size: u64,
}

struct LocalIds<'a> {
    seq: &'a Sequence,
    next: u64,
    end: u64,
}

impl Sequence {
    fn local(&self) -> LocalIds<'_> {
        LocalIds { seq: self, next: 0, end: 0 }
    }
}

impl LocalIds<'_> {
    fn next_id(&mut self) -> u64 {
        if self.next == self.end {
            let start = self.seq.next_block_start.fetch_add(self.seq.block_size, Ordering::Relaxed);
            self.next = start;
            self.end = start + self.seq.block_size;
        }
        let id = self.next;
        self.next += 1;
        id
    }
}

fn main() {
    let seq = Sequence { next_block_start: AtomicU64::new(1), block_size: 100 };

    let all: Vec<u64> = thread::scope(|s| {
        let hs: Vec<_> = (0..4)
            .map(|_| {
                s.spawn(|| {
                    let mut ids = seq.local();
                    (0..250).map(|_| ids.next_id()).collect::<Vec<_>>()
                })
            })
            .collect();
        hs.into_iter().flat_map(|h| h.join().unwrap()).collect()
    });

    let unique: HashSet<_> = all.iter().collect();
    println!("{} ids, all unique: {}", all.len(), unique.len() == all.len());
    println!("blocks reserved: {}", (seq.next_block_start.load(Ordering::Relaxed) - 1) / seq.block_size);
}
Output
1000 ids, all unique: true
blocks reserved: 12

Each worker needed 250 ids, so it reserved 3 blocks of 100: 12 atomic operations in total instead of 1000.

26. Realistic: a one-time warm-up shared by many requests

A service where the first request triggers an expensive warm-up (loading a model, filling a cache). Concurrent first requests must not trigger it twice, and later requests must not pay for it. OnceLock handles all of that.

Rust
use std::collections::HashMap;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
use std::time::Duration;

struct Recommender {
    model: OnceLock<HashMap<&'static str, Vec<&'static str>>>,
    warmups: AtomicU32,
}

impl Recommender {
    fn model(&self) -> &HashMap<&'static str, Vec<&'static str>> {
        self.model.get_or_init(|| {
            self.warmups.fetch_add(1, Ordering::Relaxed);
            thread::sleep(Duration::from_millis(50)); // expensive load
            HashMap::from([("book", vec!["bookmark", "lamp"]), ("tent", vec!["sleeping bag", "stove"])])
        })
    }

    fn recommend(&self, item: &str) -> Vec<&'static str> {
        self.model().get(item).cloned().unwrap_or_default()
    }
}

fn main() {
    let rec = Recommender { model: OnceLock::new(), warmups: AtomicU32::new(0) };

    let rec = &rec;
    let answers: Vec<Vec<&str>> = thread::scope(|s| {
        let hs: Vec<_> = ["book", "tent", "book", "sofa"].map(|item| s.spawn(move || rec.recommend(item))).into_iter().collect();
        hs.into_iter().map(|h| h.join().unwrap()).collect()
    });

    println!("{answers:?}");
    println!("warm-ups performed: {}", rec.warmups.load(Ordering::Relaxed));
}
Output
[["bookmark", "lamp"], ["sleeping bag", "stove"], ["bookmark", "lamp"], []]
warm-ups performed: 1

27. Realistic: a spin-wait "ready" latch

A CountDownLatch-style latch built from one atomic: workers count down, the coordinator waits for zero. For long waits a Condvar or channel is better (spinning burns CPU), but for very short waits a spin with yield_now is simple and fast.

Rust
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Duration;

struct Latch {
    remaining: AtomicUsize,
}

impl Latch {
    fn count_down(&self) {
        self.remaining.fetch_sub(1, Ordering::Release);
    }

    fn wait(&self) {
        while self.remaining.load(Ordering::Acquire) > 0 {
            thread::yield_now();
        }
    }
}

fn main() {
    let latch = Latch { remaining: AtomicUsize::new(3) };
    let services = ["db", "cache", "search"];

    let started: Vec<&str> = thread::scope(|s| {
        let hs: Vec<_> = services
            .iter()
            .enumerate()
            .map(|(i, name)| {
                let latch = &latch;
                s.spawn(move || {
                    thread::sleep(Duration::from_millis(10 * (i as u64 + 1))); // start up
                    latch.count_down();
                    *name
                })
            })
            .collect();
        latch.wait();
        println!("all services up, accepting traffic");
        hs.into_iter().map(|h| h.join().unwrap()).collect()
    });
    println!("{started:?}");
}
Output
all services up, accepting traffic
["db", "cache", "search"]

28. Choosing a tool

Situation Tool
a counter, a flag, a max, an id generator atomics (Relaxed for counters, Acquire/Release for flags)
several values that must change together Mutex<T>
read-mostly shared data RwLock<T>, or RwLock<Arc<T>> for snapshots
a global computed once at runtime LazyLock<T> (or OnceLock<T> when the initializer needs arguments)
a field computed once, lazily OnceLock<T>
handing work between threads channels
waiting for a condition Condvar, a channel, or Barrier

Summary

  • Atomics (AtomicBool, AtomicU64, ...) are lock-free, work through &self, and can be static. fetch_add, swap, fetch_max, compare_exchange, and fetch_update cover most needs.
  • Ordering::Relaxed is fine for independent counters. Use Release stores with Acquire loads when a flag publishes other data, and SeqCst when unsure.
  • An atomic protects one value; use a Mutex for invariants across several.
  • OnceLock and LazyLock give thread-safe lazy initialization, replacing double-checked locking.
  • Send means "can move to another thread"; Sync means "can be shared by reference between threads". Both are derived from a type's fields automatically.
  • Rc, Cell, RefCell and raw pointers opt out, which is why the compiler rejects them in threaded code; their thread-safe partners are Arc, atomics, Mutex/RwLock.
  • Put Send + 'static bounds on closures you run on other threads, and + Send (+ Sync) on trait objects that cross threads.

This completes the Concurrency section. Next: Async Fundamentals, where thousands of tasks share a handful of threads.