Skip to content
Languages

8 Rust Send/Sync and Atomics Interview Questions and Answers

This focused guide turns RecallDeck’s curated Rust Send/Sync and Atomics material into 8 interview-ready questions. Answer each one before opening the explanation, then use the examples and edge cases to repair anything vague or incomplete.

10 min read8 detailed answersUpdated
What to remember

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

A recorded source review is available for 3 of 8 answers. Each reviewed answer lists its date and scope.

01

What are Send and Sync, and how does the compiler derive them automatically?

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:

  1. Structural derivation — the auto trait propagates through fields: a struct of Send fields is itself Send. Raw pointers (*const T, *mut T) deliberately opt out of both, which strips the traits from types built on them.
  2. unsafe impl — if you uphold thread safety manually (e.g. wrapping a raw pointer in correct synchronization), you may write unsafe impl Send for MyType {}. That's an assertion you're responsible for: the compiler can't check it.
  3. Opting out explicitly — a negative impl (impl !Send for T) is nightly-only; that's how Rc is marked inside std. On stable you strip the trait with a marker field like PhantomData<*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.

02

Why does RwLock<T> require T: Send + Sync while Mutex<T> only requires T: Send?

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:

  1. Mutex<T>: Sync when T: Send — access is always serialized; two threads never hold &T at the same time. The value is effectively only moved between threads over time → only Send is needed.
  2. RwLock<T>: Sync when T: Send + Sync — multiple readers hold &T in parallel. That's literally the definition of Sync (sharing &T), so it's added to the requirements.
  3. Send in 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.

03

Give an example of a type that is Send but not Sync — and one that is Sync but not Send.

Short answer: Cell<T> / RefCell<T> are Send when T: 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:

  1. Send + !Sync → Cell/RefCell — moving is exclusive, hence safe. A shared &Cell from two threads would allow simultaneous writes without synchronization — so !Sync.
  2. Sync + !Send → MutexGuard — &guard only yields &T, safe to share (hence Sync). But on many OSes pthread_mutex_unlock must be called by the thread that owns the lock, so the guard cannot be moved to another thread and dropped there → !Send.
  3. !Send + !Sync → Rc — for completeness: a non-atomic reference count is unsafe both to move and to share.
Type Send Sync
Cell<T> / RefCell<T> when T: Send no
MutexGuard<'_, T> no yes (when T: Sync)
Arc<T> when T: Send + Sync when T: Send + Sync
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.

04

What's the difference between a data race and a race condition? Which does Rust prevent?

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:

  1. Data race — two threads touch the same cell without synchronization and one writes. Impossible in safe Rust: the type system won't grant &mut alongside any other access.
  2. 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.
  3. 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.

05

What is Mutex poisoning? What does .lock() return after another thread panicked while holding the lock?

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:

  1. Why — a panic mid-mutation may have broken invariants. Poisoning stops you from silently continuing with broken state.
  2. What's returned — lock() yields LockResult<MutexGuard<T>> = Result<_, PoisonError<_>>. Common practice is .unwrap() if the invariant is critical, or a deliberate recovery via into_inner() plus resetting the flag with Mutex::clear_poison() (stable since 1.77).
  3. Who does NOT poison — parking_lot::Mutex and tokio::sync::Mutex have no poisoning mechanism at all; their lock() 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.

06

What are scoped threads (std::thread::scope) for, and how do they let you borrow non-'static data?

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:

  1. The spawn problem — thread::spawn requires a 'static closure because the thread may outlive the current stack frame. So you end up cloning into an Arc or move-ing ownership.
  2. The scope fix — the scope joins all threads at its boundary, so references to locals are guaranteed valid for the whole lifetime of the threads.
  3. Result — you can read &data in parallel from several threads, and even hand out disjoint &mut slices without extra Arc allocations or data copies to transfer ownership. Creating threads still has a cost.
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: treating scoped threads as free. Borrowing can remove Arc and copies, but thread creation, scheduling, and join still have costs and synchronization. Shared mutable state still needs protection; disjoint &mut regions can avoid a lock when the relevant Send/Sync requirements hold.

07

What atomic memory orderings exist (Relaxed, Acquire, Release, AcqRel, SeqCst), and how do you choose?

Short answer: Relaxed preserves atomicity and the modification order of that atomic, but does not by itself publish accesses to other memory. Release/Acquire enable publication when the acquire reads the corresponding release write or its release sequence. AcqRel combines both sides for read-modify-write. SeqCst additionally orders all SeqCst operations together.

In depth:

Ordering Purpose
Relaxed an independent counter that does not signal readiness of other data
Release publish preceding accesses with a write
Acquire observe published accesses through the matching read
AcqRel acquire on the read and release on the write of a read-modify-write operation
SeqCst the relevant acquire/release guarantees plus a single order of SeqCst operations
  1. Equal numeric values are insufficient: which write the acquire operation read matters.
  2. Not every ordering is valid for every operation: an ordinary load cannot use Release/AcqRel, and a store cannot use Acquire/AcqRel; compare-exchange has a separate allowed failure ordering.
  3. SeqCst simplifies some reasoning but does not make an arbitrary algorithm correct or replace protection of non-atomic data.
  4. Cost depends on architecture and operation. Weaker ordering does not guarantee a speedup; weaken only with a correctness argument and measurements.

⚠️ Common mistake: choosing by “Relaxed is faster” or pairing any acquire read with any release. Explain which operations need a happens-before relationship and how it is established.

08

Compare std::sync::mpsc, crossbeam-channel and tokio::sync::mpsc — when do you pick which?

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:

  1. std::sync::mpsc — in the standard library, blocking recv(), 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.
  2. crossbeam-channel — MPMC (clonable Receiver), a select! macro over several channels; the de-facto choice for multi-threaded (non-async) code when you need those capabilities.
  3. tokio::sync::mpsc — .await points 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

Source links provide further reading. Where a recorded technical review is available, the answer shows its date and scope. Tool selections use direct provider links and contain no affiliate placements. Sources, features, and hiring formats can change.

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.

Start studying

Keep going

Languages105 min

Top 100 Go Interview Questions and Answers

Prepare with 100 Go interview questions and detailed answers on slices, interfaces, concurrency, runtime, testing, distributed systems, and reliability.

100 detailed answers