Lesson 67 of 94
Atomics, Send & Sync
This lesson finishes the Concurrency section in two parts:
- Atomics and lazy globals: the lock-free tools under
MutexandArc, plus one-time lazy initialization. SendandSync: 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).
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));
}hits: 80000Note 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.
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}");
}requests: 100
path bytes: 4503. 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.
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());
}200 ids, 200 unique
range: 1000..=11994. 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.
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);
}worker polled and then stopped: true5. 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.
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));
}bytes per interval: [400, 800, 1200]
after reset: 06. 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.
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));
}max latency: 90 ms
ready flags: 111
after cache failure: 1017. 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.
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));
}number of winners: 1
winner matches stored leader: true8. 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.
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);
}sold 99, left 1
nothing oversold: truecompare_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.
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");
}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.
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);
}atomic: 4000 / 40000
mutex: 4000 / 4000011. 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.
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));
}[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).
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());
}["quick", "brown", "fox", "jumping", "over", "lazy", "dog"]
19 stop words loadedBefore 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.
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());
}computed yet? false
[7, 7, 7], computed yet? truePart 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&Tcan be shared between threads. Formally,TisSyncif&TisSend.
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.
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");
}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:
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());
}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:4The 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:
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());
}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:0The 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:
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));
});
}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:418. 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:
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();
}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:0Change 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.
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());
}(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.
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());
}["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.
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}"),
}
}
}port 8080
error: port 80 is privileged
error: invalid digit found in string21. 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:
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));
}strong count after 4000 clones and drops: 122. 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:
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.
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());
}1000 requests, 2.0% errors, 2000 KiB out, max 996 usUnder 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.
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));
}3 workers stopped
every worker served something: true
totals agree: true25. 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.
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);
}1000 ids, all unique: true
blocks reserved: 12Each 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.
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));
}[["bookmark", "lamp"], ["sleeping bag", "stove"], ["bookmark", "lamp"], []]
warm-ups performed: 127. 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.
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:?}");
}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 bestatic.fetch_add,swap,fetch_max,compare_exchange, andfetch_updatecover most needs. Ordering::Relaxedis fine for independent counters. UseReleasestores withAcquireloads when a flag publishes other data, andSeqCstwhen unsure.- An atomic protects one value; use a
Mutexfor invariants across several. OnceLockandLazyLockgive thread-safe lazy initialization, replacing double-checked locking.Sendmeans "can move to another thread";Syncmeans "can be shared by reference between threads". Both are derived from a type's fields automatically.Rc,Cell,RefCelland raw pointers opt out, which is why the compiler rejects them in threaded code; their thread-safe partners areArc, atomics,Mutex/RwLock.- Put
Send + 'staticbounds 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.
