gap·map

Rust concurrency

[ middle depth ]

What fearless concurrency actually means

The borrow rule you already know (many shared references XOR one mutable reference) does not stop at a function boundary. It holds across threads. Send a &mut to a value into two threads and the compiler rejects it, the same way it rejects aliased mutation in one function. So the guarantee is narrow and real: no data races, checked at compile time.

Two auto traits carry this. Send means a value is safe to move to another thread. Sync means a value is safe to share by reference across threads (T: Sync exactly when &T: Send). You rarely write these; the compiler derives them for any type whose fields are all Send and Sync.

How to share state between threads

Rc does not cross threads. Its reference count is a plain integer, so two threads bumping it would race the count, and the compiler stops you because Rc is neither Send nor Sync. The threaded version is Arc, which uses atomic increments.

Arc alone gives shared ownership but only immutable access. To mutate shared data you wrap it in a Mutex:

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

let counter = Arc::new(Mutex::new(0));
let c = Arc::clone(&counter);
thread::spawn(move || {
    let mut n = c.lock().unwrap(); // guard held until n drops
    *n += 1;
});

Wrong "Arc makes the data mutable across threads." Arc only shares ownership. Mutation needs the Mutex (or RwLock, or an atomic). Arc<Mutex<T>> is the idiom you reach for, and it is worth memorizing as one unit.

The other shape is a channel. std::sync::mpsc moves the value from sender to receiver, so ownership transfers and no lock is involved. Reach for a channel when work flows one direction (producer to consumer); reach for Arc<Mutex<T>> when many threads touch one piece of state.

Why spawn needs move, and when scope lets you borrow

thread::spawn requires the closure to be Send + 'static. The 'static bound exists because the thread can outlive the function that started it, so it cannot hold a borrow of a local. That is why you write move and give up the captured values.

When you only need threads for the duration of a block, thread::scope (stable since 1.63) is the escape hatch: it joins every spawned thread before the scope returns, so threads may borrow local data by reference. No Arc, no move of owned data, just &data.

What Rust does not catch: lock two mutexes in opposite orders on two threads and you deadlock. That compiles. The compiler prevents data races, not the logic of your locking.

[ senior depth ]

Send and Sync as the load-bearing abstraction

Everything thread-related in Rust reduces to two marker traits the compiler propagates structurally. Send: ownership can move to another thread. Sync: &T can be shared across threads, defined precisely as T: Sync iff &T: Send. A struct is Send/Sync when all its fields are; put one Rc or one raw pointer inside and the whole type loses it. That is why Rc is rejected across threads (non-atomic refcount) while Arc is accepted (atomic refcount), and why Mutex<T> is Sync even though T need not be: the lock is what makes shared access sound.

You implement these by hand only in unsafe code wrapping raw pointers (an FFI handle, a lock-free structure). To opt a type out, give it a PhantomData<*const ()> field. Interviewers probe this because the answer reveals whether you see the trait system doing the checking or think the runtime does it.

Why a std Mutex guard across .await breaks

.await is a yield point. On a multi-threaded runtime the task can be parked and its worker thread handed to another task, and later resumed on a different worker. For that to be sound the whole future must be Send. A std::sync::MutexGuard is !Send, so a future that holds one across an .await is itself !Send and tokio::spawn refuses it at compile time.

Wrong "So always use tokio::sync::Mutex in async code." It is more expensive (it queues wakers). Prefer std::sync::Mutex and just release the guard before you .await:

// good: guard dropped before the await
let data = {
    let g = state.lock().unwrap();
    g.snapshot()
}; // g drops here
process(data).await;

Use tokio::sync::Mutex only when you genuinely must hold the lock across a suspension point; lock().await yields on contention instead of blocking the worker.

Blocking the runtime, and the honest boundary

A tokio worker runs many tasks cooperatively. A CPU-bound loop or a synchronous blocking call (a blocking DB driver, std::thread::sleep, heavy serde over a huge payload) never yields, so every other task on that worker starves. Move it off with tokio::task::spawn_blocking, which uses a separate blocking pool, and use tokio::time::sleep().await rather than the std sleep.

Be precise about what Rust guarantees. No data races and no use-after-free across threads, enforced at compile time. Nothing about deadlocks, livelock, priority inversion, or logical races on external state such as file contents or database rows. Two mutexes locked in opposite orders still deadlock, and that code compiles. Say that unprompted and you have drawn the line an interviewer is checking you can draw.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Rust

was this useful?