Answer from a language model, not from memorized syntax: name the mechanism, show a small example, and explain the production consequence.
Question set
8 detailed answers
01What are Send and Sync, and how does the compiler derive them automatically?
middle
Short answer: Send means ownership of a value can be safely moved to another thread; Sync means &T can be safely shared across threads (by definition T: Sync ⇔ &T: Send). These are auto traits: the compiler implements them structurally — a type is Send/Sync if all its fields are. You write nothing by hand.
In depth:
- Structural derivation — the auto trait propagates through fields: a struct of
Sendfields is itselfSend. Raw pointers (*const T,*mut T) deliberately opt out of both, which strips the traits from types built on them. unsafe impl— if you uphold thread safety manually (e.g. wrapping a raw pointer in correct synchronization), you may writeunsafe impl Send for MyType {}. That's an assertion you're responsible for: the compiler can't check it.- Opting out explicitly — a negative impl (
impl !Send for T) is nightly-only; that's howRcis marked insidestd. On stable you strip the trait with a marker field likePhantomData<*const ()>(!Send + !Sync).
| Type | Send | Sync | Why |
|---|---|---|---|
Rc<T> |
no | no | non-atomic reference count |
Arc<T> |
yes | yes | atomic count (when T: Send + Sync) |
Cell<T> / RefCell<T> |
yes | no | unsynchronized interior mutability |
Mutex<T> |
yes | yes | synchronization inside (when T: Send) |
*mut T |
no | no | a raw pointer offers no guarantees |
⚠️ Common mistake: thinking Send and Sync are about a type's "thread-safety in general". Send is about moving ownership between threads, Sync about shared access via a reference; these are distinct guarantees, and a type can have one without the other.
02Why does RwLock<T> require T: Send + Sync while Mutex<T> only requires T: Send?
senior
Short answer: A Mutex hands out exclusive access to exactly one thread at any moment — only ownership "travels" across the thread boundary, so T: Send suffices. A RwLock can hand out several read guards simultaneously to different threads, which is a shared &T across threads — so it additionally needs T: Sync.
In depth:
Mutex<T>: SyncwhenT: Send— access is always serialized; two threads never hold&Tat the same time. The value is effectively only moved between threads over time → onlySendis needed.RwLock<T>: SyncwhenT: Send + Sync— multiple readers hold&Tin parallel. That's literally the definition ofSync(sharing&T), so it's added to the requirements.Sendin both — needed because the value itself can be moved into another thread along with the lock.
Mutex: only one in the critical section
thread A ──[lock]──► &mut T ──[unlock]──► thread B ──[lock]──► ...
(exclusive, over time — Send is enough)
RwLock: many readers AT ONCE
thread A ─┐
thread B ─┼─► &T (shared access right now → needs Sync)
thread C ─┘
⚠️ Common mistake: claiming both locks require the same bounds. The difference is exactly RwLock's concurrent reads: parallel &T held by several threads is the very sharing that requires Sync.
03Give an example of a type that is Send but not Sync — and one that is Sync but not Send.
senior
Short answer: Cell<T> / RefCell<T> are Send but !Sync: unsynchronized interior mutability is safe to move to another thread but not to share by reference (two threads would write without synchronization → data race). MutexGuard<'_, T> is the reverse — Sync (when T: Sync) but !Send: the guard is safe to share via &, but the same thread that locked it must release the lock.
In depth:
Send + !Sync→Cell/RefCell— moving is exclusive, hence safe. A shared&Cellfrom two threads would allow simultaneous writes without synchronization — so!Sync.Sync + !Send→MutexGuard—&guardonly yields&T, safe to share (henceSync). But on many OSespthread_mutex_unlockmust be called by the thread that owns the lock, so the guard cannot be moved to another thread and dropped there →!Send.!Send + !Sync→Rc— for completeness: a non-atomic reference count is unsafe both to move and to share.
| Type | Send | Sync |
|---|---|---|
Cell / RefCell |
yes | no |
MutexGuard<'_, T> |
no | yes (when T: Sync) |
Arc<T> |
yes | yes |
Rc<T> |
no | no |
⚠️ Common mistake: assuming Send and Sync always come as a pair. Cell and MutexGuard are the canonical counterexamples, and interviewers often ask about exactly these.
04What's the difference between a data race and a race condition? Which does Rust prevent?
middle
Short answer: A data race is unsynchronized concurrent access to the same memory where at least one access is a write; that's UB. A race condition is a logic bug from unlucky operation ordering (e.g. TOCTOU). Rust statically rules out data races (via ownership + Send/Sync), but not race conditions or deadlocks: those compile just fine.
In depth:
- Data race — two threads touch the same cell without synchronization and one writes. Impossible in safe Rust: the type system won't grant
&mutalongside any other access. - Race condition — correctness depends on timing. The classic is TOCTOU: you check a condition, and by the time you act it's no longer true. The compiler doesn't catch this.
- What Rust does NOT guarantee — freedom from deadlocks, memory leaks, logic races, or overflows. "Fearless concurrency" is about the absence of data races, not the absence of all concurrency bugs.
| Property | Data race | Race condition |
|---|---|---|
| Essence | unsync access + write | dependence on ordering |
| Result | UB | wrong result |
| Example | two threads write x |
check balance, then debit |
| Rust catches it | yes, at compile time | no |
⚠️ Common mistake: claiming "Rust prevents races" without qualification. It guarantees no data races, but not race conditions or deadlocks — those still have to be designed away by hand.
05What is Mutex poisoning? What does .lock() return after another thread panicked while holding the lock?
middle
Short answer: If a thread panics while holding a MutexGuard, the std mutex is marked "poisoned". After that, .lock() returns Err(PoisonError) — a signal that the protected data may be left in an inconsistent state. .unwrap() on that Result re-panics; you can still recover the data via PoisonError::into_inner().
In depth:
- Why — a panic mid-mutation may have broken invariants. Poisoning stops you from silently continuing with broken state.
- What's returned —
lock()yieldsLockResult<MutexGuard<T>>=Result<_, PoisonError<_>>. Common practice is.unwrap()if the invariant is critical, or a deliberate recovery viainto_inner()plus resetting the flag withMutex::clear_poison()(stable since 1.77). - Who does NOT poison —
parking_lot::Mutexandtokio::sync::Mutexhave no poisoning mechanism at all; theirlock()returns the guard directly.
use std::sync::Mutex;
let data = Mutex::new(0);
let guard = match data.lock() {
Ok(g) => g,
// deliberately continue, aware the invariant may be broken
Err(poisoned) => poisoned.into_inner(),
};
⚠️ Common mistake: thinking poisoning "breaks" the mutex forever. The data is reachable via into_inner(), and parking_lot/tokio don't poison at all — don't carry std's behavior over to them automatically.
06What are scoped threads (std::thread::scope) for, and how do they let you borrow non-'static data?
middle
Short answer: std::thread::scope guarantees that every thread spawned inside it finishes (is joined) before the scope exits. That lets the compiler prove borrows outlive the threads, so you can pass ordinary &T / &mut T to stack data — no Arc, no 'static requirement. Stable since Rust 1.63.
In depth:
- The
spawnproblem —thread::spawnrequires a'staticclosure because the thread may outlive the current stack frame. So you end up cloning into anArcormove-ing ownership. - The
scopefix — the scope joins all threads at its boundary, so references to locals are guaranteed valid for the whole lifetime of the threads. - Result — you can read
&datain parallel from several threads, and even hand out disjoint&mutslices without touching the heap.
let mut v = vec![1, 2, 3];
// spawn: needs 'static — this would NOT compile with &v
// thread::spawn(|| println!("{v:?}")); // error: v isn't 'static
std::thread::scope(|s| {
s.spawn(|| println!("reading: {v:?}")); // plain &v — ok
s.spawn(|| println!("again: {}", v.len()));
}); // both threads are already joined here
v.push(4); // mutable again — the borrows are released
⚠️ Common mistake: reaching for Arc<Mutex<_>> where the data lives on the stack and threads are short-lived. For that pattern thread::scope gives you borrowing with zero allocations or synchronization.
07What atomic memory orderings exist (Relaxed, Acquire, Release, SeqCst) and when do you use each?
senior
Short answer: Relaxed gives only atomicity of the operation, with no ordering guarantees relative to other accesses — fine for independent counters. Acquire/Release form a pair for a "publish → consume" hand-off (flags, lock implementations). SeqCst imposes a single global order over all SeqCst operations — a safe default, but the most expensive.
In depth:
Relaxed— atomicity only, no happens-before. The classic case is incrementing a metric/counter where only the final sum matters.Release(on the write) +Acquire(on the read) — everything written before theReleasebecomes visible to the thread that did anAcquireof the same value. This is the data-publication mechanism and the basis of mutexes.SeqCst— likeAcquire/Release, plus a single total order over allSeqCstops. Easier to reason about but costlier; rarely needed (e.g. by some lock-free algorithms).
| Ordering | Guarantee | Typical use |
|---|---|---|
Relaxed |
atomicity only | counters, metrics |
Acquire |
sees writes before the paired Release |
reading a flag/lock |
Release |
publishes its writes | writing a flag/unlock |
SeqCst |
single global order | safe default, rare algorithms |
⚠️ Common mistake: slapping SeqCst everywhere "just in case". The interviewer wants a justified choice: Relaxed is enough for a counter, an Acquire/Release pair for a hand-off, and a needless SeqCst just pays for barriers with no benefit.
08Compare std::sync::mpsc, crossbeam-channel and tokio::sync::mpsc — when do you pick which?
middle
Short answer: std::sync::mpsc is a blocking multi-producer, single-consumer (MPSC) channel with no select. crossbeam-channel is MPMC (multiple consumers too), with select! and a clonable Receiver. tokio::sync::mpsc is an async channel with backpressure, meant for use inside async tasks. The choice is dictated by the execution model: blocking code or async.
In depth:
std::sync::mpsc— in the standard library, blockingrecv(), many producers / one consumer, no multiplexing across channels. Since Rust 1.67 it's internally a port of crossbeam-channel, so "std is slow" is an outdated argument.crossbeam-channel— MPMC (clonableReceiver), aselect!macro over several channels; the de-facto choice for multi-threaded (non-async) code when you need those capabilities.tokio::sync::mpsc—.awaitpoints instead of blocking; bounded capacity provides backpressure; used inside an async runtime.
| Channel | Model | select | Where to use |
|---|---|---|---|
std::sync::mpsc |
MPSC, blocking | no | simple multi-threaded code |
crossbeam-channel |
MPMC, blocking | yes (select!) |
threaded code needing select/MPMC |
tokio::sync::mpsc |
MPSC, async | yes (tokio::select!) |
inside async tasks |
⚠️ Common mistake: calling a blocking recv() (std/crossbeam) inside an async function. It blocks the runtime's worker thread and can stall the whole task pool — in async code use tokio::sync::mpsc (or spawn_blocking).
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.