Answer from a language model, not from memorized syntax: name the mechanism, show a small example, and explain the production consequence.
Question set
10 detailed answers
01What is a Future, and why does calling an async fn without .await run nothing?
middle
Short answer: A Future is a lazy state machine implementing the Future trait with a poll method. Calling an async fn doesn't start work — it only constructs the future value. The code inside runs only when an executor polls it, i.e. when you .await it or hand it to spawn.
In depth:
async fnreturns a value — a typeimpl Future<Output = T>. The call is a state-machine constructor, not the start of the computation.- Laziness — unlike a thread, a future does not spin in the background on its own. Until something polls it, it's inert.
- Who drives the future — an
.awaitinside another async function, ortokio::spawn, which hands the task to the runtime. Without that, the work never begins.
async fn work() { println!("running"); }
let fut = work(); // nothing happened — no print
fut.await; // only now the body actually runs
⚠️ Common mistake: expecting an async fn to start "like a thread" the moment you call it. A forgotten .await is work that never ran — and often an unused_must_use warning: "futures do nothing unless you .await or poll them."
02What does the compiler turn the body of an async fn into?
senior
Short answer: The compiler desugars the body of an async fn into an anonymous state machine — essentially an enum with one state variant per .await point plus terminal states. Locals that live across an .await become fields of that enum. Each poll call advances the machine from one await to the next.
In depth:
- A state per await — between two
.awaits the body runs synchronously; an.awaitis the point wherepollmay returnPendingand later resume. - Locals become fields — variables needed after a suspension point are stored in the enum so they can be restored on the next
poll. !Unpin— if the future references its own fields (self-referential), it can't be moved in memory, so theUnpinauto-trait isn't implemented for it — which is whyPinexists.
async fn f() { a().await; b().await; }
poll poll poll
Start ──────────► AwaitA ───────► AwaitB ──────► Done
(fields: locals that live across the .await points)
⚠️ Common mistake: answering just "it returns a Future." The interviewer wants the state-machine model specifically: states = .await points, fields = variables living between them.
03What is Pin and what problem does it solve for futures?
senior
Short answer: Pin<P> is a pointer wrapper that guarantees the value behind it will not move in memory once pinned. This matters because async state machines can be self-referential — one field holds a pointer into another field of the same future. Moving such a value would invalidate the internal pointer and cause UB. Unpin marks ordinary types that are safe to move — for them Pin imposes no restriction.
In depth:
- The problem — after an
.await, a future may hold a reference into its own data; a move would corrupt it. - The
Pinguarantee — a pinned value won't shift until its destructor runs; that's exactly whypolltakesPin<&mut Self>. Unpin— an auto-trait for types with no self-references;Pinover them still allows mutating and moving.
before move after move (broken)
┌─────────────┐ ┌─────────────┐
│ data: [...] │◄──┐ │ data: [...] │ ← address changed
│ ptr ────────┼───┘ │ ptr ────────┼──► ✗ old address
└─────────────┘ └─────────────┘
⚠️ Common mistake: confusing Pin with immutability. Pin forbids moving the value, not writing to it — you can still mutate data through Pin<&mut T>.
04Why is holding a std::sync::MutexGuard across an .await point a bug?
senior
Short answer: std::sync::MutexGuard is !Send, so if it's alive at an .await point the whole future becomes !Send, and tokio::spawn (multi-thread runtime) will simply reject it. On a current-thread runtime it can deadlock: the task holding the lock suspends, and another task on the same thread blocks forever on lock(). Plus the lock stays held for the entire duration of the wait.
In depth:
!Send→!Sendfuture — the guard is stored as a state-machine field across the.await, which infects the whole future.- Deadlock on current-thread — task A parks at an
.awaitwhile holding the lock; task B on the same thread calls the synchronouslock()and blocks the whole thread. Task A is never polled again, and the lock is never released. - Lock held too long — contention grows because the critical section now includes a network wait.
// BUG: guard alive across .await
let g = m.lock().unwrap();
socket.write_all(&g.buf).await?; // future is !Send → spawn rejects
// CORRECT: take the data and release the lock before .await
let data = { let g = m.lock().unwrap(); g.buf.clone() };
socket.write_all(&data).await?;
// or tokio::sync::Mutex if the lock genuinely must live across await
⚠️ Common mistake: swapping std::sync::Mutex for tokio::sync::Mutex reflexively. It's often cheaper to shrink the guard's scope and drop it before the .await, reaching for tokio::sync::Mutex only when the lock truly must live across a suspension.
05Why does tokio::spawn require the future to be Send + 'static?
middle
Short answer: Tokio's multi-thread runtime is work-stealing: any worker can pick up a task, and after any .await point it may resume on a different thread. So all state that lives across an .await must be Send. And 'static is required because the task may outlive the caller's stack frame, so it can't hold borrowed non-'static references.
In depth:
Send— workers move tasks between threads; anything surviving an.awaitcrosses a thread boundary and must beSend.'static—spawnreturns immediately and the task lives independently; it can't borrow a caller local, so data is passed by ownership (move) or viaArc.- How to pass data —
async move { ... }, cloning anArc, channels.
worker A worker B
┌───────┐ .await ┌───────┐
│ task ─┼──park────►│ task │ ← resumed on a different thread
└───────┘ steal └───────┘ → state must be Send
⚠️ Common mistake: pulling an Rc or RefCell into the task — they're !Send, so the future won't satisfy spawn's Send bound. In async tasks you use Arc + Mutex (std or tokio::sync).
06A task spins a heavy CPU loop with no .await — what happens to the tokio runtime and how do you fix it?
middle
Short answer: Tokio's scheduler is cooperative: a worker only switches to other tasks at .await points. A loop with no .await never yields, so it starves every other task on that worker (and on a current-thread runtime, the whole runtime — timers, I/O, all of it stalls). The fix is to move CPU work off the async loop.
In depth:
spawn_blocking— hand blocking/CPU work to a separate thread pool so it doesn't occupy the runtime's workers.tokio::task::yield_now().await— if the loop must stay in an async context, periodically return control to the scheduler.rayon/ separate pool — for real data-parallel CPU crunch, keep a dedicated compute pool and pipe the result back into async via a channel.
| Technique | When to use |
|---|---|
spawn_blocking |
synchronous blocking or CPU code called from async |
yield_now().await |
a long loop that must stay in async |
rayon / own pool |
heavy parallel CPU crunch |
⚠️ Common mistake: running CPU-bound or blocking calls (std::fs, thread::sleep) right inside an async task. A couple of those saturate all workers and I/O latency spikes — even though "the code is async."
07What is cancel safety? Why can a read() inside tokio::select! lose bytes?
senior
Short answer: tokio::select! polls all branches concurrently and, as soon as one completes, drops the unfinished futures of the others. Cancellation in async is just Drop of a future at an .await point. An operation is cancel-safe only if abandoning it there loses nothing. The subtlety: a single read() in Tokio is documented as cancel-safe (a losing branch is guaranteed to have read zero bytes) — bytes are lost by compound operations — read_exact(), read_line(), a hand-rolled frame-reading loop — whose partial progress lives inside the dropped future.
In depth:
- Cancellation = Drop — the losing
select!branches aren't "paused," they're destroyed in place. - Where bytes get lost — by cancellation time
read_exact()may have read part of the data (with no way to tell how much),read_line()loses partially-read data; their docs say so explicitly. Same for a hand-writtenread_frame()keeping its cursor in locals. - How to avoid it — keep partial state outside the future: a long-lived buffer with a cursor,
FramedReadcodecs from tokio-util, cancel-safe primitives likempsc::Receiver::recv.
select! { _ = sock.read_exact(&mut buf) => ..., _ = tick => ... }
t0 read_exact pulled 4 of 8 bytes (progress lives inside the future)
t1 tick fires ──► losing branch is dropped
t2 future destroyed ✗ bytes are off the socket, the count is lost
⚠️ Common mistake: sorting operations into "safe" and "unsafe" by intuition. Tokio specifies cancel safety per method — check the "Cancel safety" section in the docs: read() and recv() are safe; read_exact(), read_line(), write_all() are not.
08Does dropping a JoinHandle cancel the spawned task? And what if the task panics?
middle
Short answer: No. Dropping a JoinHandle does not cancel the task — it detaches and keeps running in the runtime. Explicit cancellation is handle.abort(). If the task panics, the runtime catches it: the process doesn't crash; the panic surfaces as Err(JoinError) when you await the handle (JoinError::is_panic()).
In depth:
drop(handle)— the task becomes detached and lives on; you just lose the ability to await its result.handle.abort()— the standard way to cancel: the task is stopped at its next.awaitpoint (aborton an already-finished task is a no-op).- Panic — isolated to the task (unlike a main-thread panic, which takes down the program); the result is
Err(JoinError)onawait.
| Action | What happens to the task |
|---|---|
drop(handle) |
detached — keeps running |
handle.abort() |
cancelled at its next .await point |
| panic inside | caught by the runtime → Err(JoinError) on await |
⚠️ Common mistake: assuming the task dies with the dropped handle. It keeps running in the background; to actually stop it use abort(), and to see the result/panic you must .await the handle — otherwise the JoinError is silently swallowed.
09Why didn't async fn in traits work with dyn for so long, and how do async-trait and native async fn in traits (Rust 1.75) solve it?
senior
Short answer: async fn in a trait desugars to a method returning an anonymous impl Future with no fixed size — each impl has its own type and size, which is incompatible with a vtable slot. The async-trait crate worked around this by boxing the result into Pin<Box<dyn Future + Send + '_>> (a heap allocation per call). Native async fn in traits (RPITIT, stable since Rust 1.75) drop the box, but are not yet dyn-compatible and add no automatic Send bounds.
In depth:
- Why
dynbroke — a vtable stores function pointers with a known signature; the returnedimpl Futurehas a different size perimpl, which doesn't fit a vtable slot. async-trait— the macro rewrites the method to-> Pin<Box<dyn Future + Send + '_>>; works withdyn, but pays a heap allocation per call.- RPITIT since 1.75 — the compiler generates an associated type for the returned future itself; no box, static dispatch, but the method stays non-dyn-compatible and
Sendmust be stated explicitly.
// how it looks after desugaring
trait Db { fn get(&self) -> impl Future<Output = Row> + Send; }
// async-trait instead produces:
// fn get(&self) -> Pin<Box<dyn Future<Output = Row> + Send + '_>>;
⚠️ Common mistake: assuming async-trait is obsolete as of 1.75. As soon as you need dyn Trait (trait objects) or guaranteed Send, the boxing async-trait is still the right tool.
10What's the difference between the executor and the reactor in tokio, and how does the Waker connect them?
middle
Short answer: The executor (scheduler) polls and schedules tasks: it holds run queues of ready futures and distributes them across workers (work stealing). The reactor (I/O driver, in Tokio mio over epoll/kqueue/IOCP) watches sockets and timers for readiness and calls wake() on an event. The link is the Waker: the reactor uses it to tell the executor "this task is ready again," and it returns to the queue for a poll.
In depth:
- Executor — runs the loop: take a task from the queue, call
poll. If it returnsPending, park it and take the next. - Reactor — registers interest in readiness of file descriptors in epoll/kqueue/IOCP and sleeps on a syscall until the OS wakes it with an event.
Waker— onPending, a future stores theWakerfrom itsContext; on readiness the reactor callswake(), and the task lands back in the executor's queue.
executor: poll(task) ── Pending ──► task parked (Waker stored)
│ registered with reactor
reactor (epoll/kqueue): waits event ───┘
── ready ──► waker.wake() ──► task back into the queue
⚠️ Common mistake: describing the runtime as a monolith — "Tokio just does everything." The executor ⇄ reactor split via the Waker is exactly what the interviewer wants to hear: without wake(), a task that returned Pending is never polled again.
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.