Menu

Earn Premium with Referrals

Invite your friends and earn Premium rewards through our referral program.

See how it works and start inviting friends.

Concurrency & Async
RUST

Concurrency & Async

Practice 8 Rust questions covering threads, Send, Sync, shared state, channels, async programming, and concurrency safety.

1. What is a “data race” in Rust, and how does the compiler handle it?

Answer: A data race is two or more threads accessing the same memory concurrently with at least one write and no synchronization; Rust rejects it at compile time.

A data race is a specific, serious bug: multiple accesses to the same location, at least one a write, overlapping in time, with no ordering/synchronization between them — in C/C++ this is undefined behavior. Rust’s ownership and borrowing rules make data races impossible in safe code:

  • A value can only be accessed through one &mut (exclusive write) or many & (shared reads) at a time.
  • Across threads, Send/Sync bounds ensure you can’t share non-thread-safe types between threads.

So the data-race pattern is a compile error before the program even runs:

// safe Rust cannot compile this
let mut x = 0;
thread::spawn(move || { x += 1; });  // compile error unless x is a Mutex/Arc/atomic

For genuinely shared mutable state, Rust forces you through synchronized primitives (Mutex, RwLock, atomics, channels). The interview answer: a race with concurrent read+write and no sync; Rust’s ownership/borrow rules plus Send/Sync prevent it at compile time.

Answer:

A data race is two or more threads accessing the same memory concurrently with at least one write and no synchronization; Rust rejects it at compile time.

A data race is a specific, serious bug: multiple accesses to the same location, at least one a write, overlapping in time, with no ordering/synchronization between them — in C/C++ this is undefined behavior. Rust’s ownership and borrowing rules make data races impossible in safe code:

  • A value can only be accessed through one &mut (exclusive write) or many & (shared reads) at a time.
  • Across threads, Send/Sync bounds ensure you can’t share non-thread-safe types between threads.

So the data-race pattern is a compile error before the program even runs:

// safe Rust cannot compile this
let mut x = 0;
thread::spawn(move || { x += 1; });  // compile error unless x is a Mutex/Arc/atomic

For genuinely shared mutable state, Rust forces you through synchronized primitives (Mutex, RwLock, atomics, channels). The interview answer: a race with concurrent read+write and no sync; Rust’s ownership/borrow rules plus Send/Sync prevent it at compile time.

2. What issue is solved by using std::sync::Mutex<T> in concurrent Rust code?

Answer: Mutual exclusion — it guarantees only one thread accesses the guarded data at a time.

Mutex<T> wraps shared data in a lock:

  • .lock() blocks the calling thread until it acquires the lock, returning a MutexGuard.
  • While the guard is held, no other thread can access the data.
  • When the guard is dropped, the lock releases automatically (RAII) — even on early returns.
let counter = Arc::new(Mutex::new(0));
let mut guard = counter.lock().unwrap();
*guard += 1;   // guard dropped here → lock released

Notes: lock() returns Result — a poisoned mutex (a thread panicked while holding the lock) yields Err, which is why .unwrap()/.ok() handling is common. Mutex gives safe interior mutability across threads (unlike Rc<RefCell> which is single-threaded). The interview answer: mutual exclusion — one thread at a time via a lock guard that auto-releases on drop.

Answer:

Mutual exclusion — it guarantees only one thread accesses the guarded data at a time.

Mutex<T> wraps shared data in a lock:

  • .lock() blocks the calling thread until it acquires the lock, returning a MutexGuard.
  • While the guard is held, no other thread can access the data.
  • When the guard is dropped, the lock releases automatically (RAII) — even on early returns.
let counter = Arc::new(Mutex::new(0));
let mut guard = counter.lock().unwrap();
*guard += 1;   // guard dropped here → lock released

Notes: lock() returns Result — a poisoned mutex (a thread panicked while holding the lock) yields Err, which is why .unwrap()/.ok() handling is common. Mutex gives safe interior mutability across threads (unlike Rc<RefCell> which is single-threaded). The interview answer: mutual exclusion — one thread at a time via a lock guard that auto-releases on drop.

3. What is the role of Send and Sync auto-traits in Rust concurrency?

Answer: Send = the type’s ownership can move across threads; Sync = it’s safe to share &T references across threads (T: Sync iff &T: Send).

  • Send: safe to transfer the value to another thread. Most types are SendString, Vec, primitives. Rc<T> is not Send (non-atomic refcount), Arc<T> is.
  • Sync: safe for multiple threads to hold &T simultaneously. &T: SendT: Sync. So Mutex<T> is Sync (the lock serializes access); Cell<T>/RefCell<T> are not Sync.

These are auto-traits: the compiler implements them automatically based on fields, and you can’t implement them manually for most types. They’re the mechanism behind the compiler rejecting code that shares Rc or RefCell across threads:

fn spawn() {
    let rc = Rc::new(1);
    std::thread::spawn(move || { /* use rc */ });   // error: Rc is not Send
}

The interview answer: Send = ownership transferable between threads; Sync = references shareable between threads; both checked at compile time.

Answer:

Send = the type’s ownership can move across threads; Sync = it’s safe to share &T references across threads (T: Sync iff &T: Send).

  • Send: safe to transfer the value to another thread. Most types are SendString, Vec, primitives. Rc<T> is not Send (non-atomic refcount), Arc<T> is.
  • Sync: safe for multiple threads to hold &T simultaneously. &T: SendT: Sync. So Mutex<T> is Sync (the lock serializes access); Cell<T>/RefCell<T> are not Sync.

These are auto-traits: the compiler implements them automatically based on fields, and you can’t implement them manually for most types. They’re the mechanism behind the compiler rejecting code that shares Rc or RefCell across threads:

fn spawn() {
    let rc = Rc::new(1);
    std::thread::spawn(move || { /* use rc */ });   // error: Rc is not Send
}

The interview answer: Send = ownership transferable between threads; Sync = references shareable between threads; both checked at compile time.

4. What guarantees does the Pin type provide in Rust async/await execution?

Answer: It guarantees the underlying object won’t be moved in memory, which is essential for self-referential structures like async futures.

Pin<P> pins a value to its memory address. Why it matters: an async function compiles into a future that can hold self-referential data — fields that contain pointers to other fields within the same struct. If the future were moved after construction, those internal pointers would dangle.

let fut = some_async_fn();     // may contain pointers into itself
pin!(fut);                     // now pinned — safe to poll

The compiler and runtime use Pin to guarantee a polled future never moves. Safe Rust can’t create a Pin<&mut T> from just any value precisely to prevent accidental moves of self-referential data; unsafe (or safe helpers like pin!/Box::pin) is needed when you know the value is safe to pin. The interview answer: Pin prevents the target from being moved, enabling self-referential async futures to remain valid.

Answer:

It guarantees the underlying object won’t be moved in memory, which is essential for self-referential structures like async futures.

Pin<P> pins a value to its memory address. Why it matters: an async function compiles into a future that can hold self-referential data — fields that contain pointers to other fields within the same struct. If the future were moved after construction, those internal pointers would dangle.

let fut = some_async_fn();     // may contain pointers into itself
pin!(fut);                     // now pinned — safe to poll

The compiler and runtime use Pin to guarantee a polled future never moves. Safe Rust can’t create a Pin<&mut T> from just any value precisely to prevent accidental moves of self-referential data; unsafe (or safe helpers like pin!/Box::pin) is needed when you know the value is safe to pin. The interview answer: Pin prevents the target from being moved, enabling self-referential async futures to remain valid.

5. What is an async function in Rust translated into by the compiler?

Answer: A state machine that implements the Future trait.

async fn foo() is desugared by the compiler into a hidden struct implementing Future:

  • The function body becomes a state machine — each .await point is a state; local variables are stored in the future’s fields (for use across awaits).
  • Calling the function returns a Future that does nothing until polled (futures are lazy).
  • Polling it drives the state machine forward until it resolves to a value.
async fn fetch() -> String { /* ... .await ... */ }
let fut: impl Future<Output = String> = fetch();   // lazy, not yet run

So async/await is not threads, callbacks, or kernel interrupts — it’s a compile-time state machine over the Future trait, polled by a runtime. The interview answer: the compiler translates async fn into a state machine implementing the Future trait.

Answer:

A state machine that implements the Future trait.

async fn foo() is desugared by the compiler into a hidden struct implementing Future:

  • The function body becomes a state machine — each .await point is a state; local variables are stored in the future’s fields (for use across awaits).
  • Calling the function returns a Future that does nothing until polled (futures are lazy).
  • Polling it drives the state machine forward until it resolves to a value.
async fn fetch() -> String { /* ... .await ... */ }
let fut: impl Future<Output = String> = fetch();   // lazy, not yet run

So async/await is not threads, callbacks, or kernel interrupts — it’s a compile-time state machine over the Future trait, polled by a runtime. The interview answer: the compiler translates async fn into a state machine implementing the Future trait.

6. What is required to make an async future actually execute and produce a result in Rust?

Answer: The future must be polled by an async runtime (Tokio, async-std, etc.) or awaited with .await.

Rust futures are lazy: constructing one does zero work. Execution happens only when an executor polls the future:

  • .await inside another async context hands the future to the surrounding runtime, which polls it to completion.
  • A standalone future needs a runtime driver (Tokio, async-std, smol, std) to poll it; without one, nothing runs.
#[tokio::main]
async fn main() {
    let result = fetch_data().await;   // the runtime polls fetch_data()
}

Without .await or an executor, the future is just an inert value that never executes. The interview answer: the future must be polled — via .await within a runtime such as Tokio — since futures perform no work until polled.

Answer:

The future must be polled by an async runtime (Tokio, async-std, etc.) or awaited with .await.

Rust futures are lazy: constructing one does zero work. Execution happens only when an executor polls the future:

  • .await inside another async context hands the future to the surrounding runtime, which polls it to completion.
  • A standalone future needs a runtime driver (Tokio, async-std, smol, std) to poll it; without one, nothing runs.
#[tokio::main]
async fn main() {
    let result = fetch_data().await;   // the runtime polls fetch_data()
}

Without .await or an executor, the future is just an inert value that never executes. The interview answer: the future must be polled — via .await within a runtime such as Tokio — since futures perform no work until polled.

7. What is the standard channel type in std::sync::mpsc?

Answer: Multi-Producer, Single-Consumer — many senders, one receiver.

mpsc = Multi-Producer, Single-Consumer:

let (tx, rx) = mpsc::channel::<String>();
let tx2 = tx.clone();                 // multiple senders
std::thread::spawn(move || tx.send("a".into()));
std::thread::spawn(move || tx2.send("b".into()));
rx.recv();  rx.recv();                // one consumer receives all
  • Multiple senders: clone the Sender to get more (each clone can be moved to a different thread).
  • One receiver: only a single Receiver exists to consume messages.

If you need multiple consumers or broadcast semantics, you’d use crossbeam channels or an Arc<Mutex<Vec>>. The interview answer: multi-producer, single-consumer — many Senders (via clone) and one Receiver.

Answer:

Multi-Producer, Single-Consumer — many senders, one receiver.

mpsc = Multi-Producer, Single-Consumer:

let (tx, rx) = mpsc::channel::<String>();
let tx2 = tx.clone();                 // multiple senders
std::thread::spawn(move || tx.send("a".into()));
std::thread::spawn(move || tx2.send("b".into()));
rx.recv();  rx.recv();                // one consumer receives all
  • Multiple senders: clone the Sender to get more (each clone can be moved to a different thread).
  • One receiver: only a single Receiver exists to consume messages.

If you need multiple consumers or broadcast semantics, you’d use crossbeam channels or an Arc<Mutex<Vec>>. The interview answer: multi-producer, single-consumer — many Senders (via clone) and one Receiver.

8. What happens when all Sender instances associated with a std::sync::mpsc::Receiver are dropped?

Answer: The channel closes, and receiving operations return an error (RecvError for recv(), None for try_recv()).

When every Sender is dropped, no more messages can arrive — the channel is disconnected. The Receiver’s recv() then:

  • Drains any remaining buffered messages first.
  • Then returns Err(RecvError) (or try_recv()Err(TryRecvError::Disconnected), iter() ends).
let (tx, rx) = mpsc::channel();
drop(tx);
let r = rx.recv();   // Err(RecvError) — channel closed

This is the standard way a receiver knows senders are done (like EOF on a stream) — crucial for worker-pool shutdown patterns. The interview answer: the channel is closed and recv() returns Err(RecvError) (or try_recv returns Disconnected/None).

Answer:

The channel closes, and receiving operations return an error (RecvError for recv(), None for try_recv()).

When every Sender is dropped, no more messages can arrive — the channel is disconnected. The Receiver’s recv() then:

  • Drains any remaining buffered messages first.
  • Then returns Err(RecvError) (or try_recv()Err(TryRecvError::Disconnected), iter() ends).
let (tx, rx) = mpsc::channel();
drop(tx);
let r = rx.recv();   // Err(RecvError) — channel closed

This is the standard way a receiver knows senders are done (like EOF on a stream) — crucial for worker-pool shutdown patterns. The interview answer: the channel is closed and recv() returns Err(RecvError) (or try_recv returns Disconnected/None).

My Private Notes

Notes are auto-saved locally to this device.