Skip to content
Languages

10 Rust Async and Tokio Interview Questions and Answers

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

11 min read10 detailed answersReviewed Aug 24, 2026
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

10 detailed answers

01

What is a Future, and why does calling an async fn without .await run nothing?

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:

  1. async fn returns a value — a type impl Future<Output = T>. The call is a state-machine constructor, not the start of the computation.
  2. Laziness — unlike a thread, a future does not spin in the background on its own. Until something polls it, it's inert.
  3. Who drives the future — an .await inside another async function, or tokio::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."

02

What does the compiler turn the body of an async fn into?

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:

  1. A state per await — between two .awaits the body runs synchronously; an .await is the point where poll may return Pending and later resume.
  2. Locals become fields — variables needed after a suspension point are stored in the enum so they can be restored on the next poll.
  3. !Unpin — if the future references its own fields (self-referential), it can't be moved in memory, so the Unpin auto-trait isn't implemented for it — which is why Pin exists.
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.

03

What is Pin and what problem does it solve for futures?

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:

  1. The problem — after an .await, a future may hold a reference into its own data; a move would corrupt it.
  2. The Pin guarantee — a pinned value won't shift until its destructor runs; that's exactly why poll takes Pin<&mut Self>.
  3. Unpin — an auto-trait for types with no self-references; Pin over 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>.

04

Why is holding a std::sync::MutexGuard across an .await point a bug?

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:

  1. !Send!Send future — the guard is stored as a state-machine field across the .await, which infects the whole future.
  2. Deadlock on current-thread — task A parks at an .await while holding the lock; task B on the same thread calls the synchronous lock() and blocks the whole thread. Task A is never polled again, and the lock is never released.
  3. 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.

05

Why does tokio::spawn require the future to be Send + 'static?

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:

  1. Send — workers move tasks between threads; anything surviving an .await crosses a thread boundary and must be Send.
  2. 'staticspawn returns immediately and the task lives independently; it can't borrow a caller local, so data is passed by ownership (move) or via Arc.
  3. How to pass dataasync move { ... }, cloning an Arc, 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).

06

A task spins a heavy CPU loop with no .await — what happens to the tokio runtime and how do you fix it?

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."

07

What is cancel safety? Why can a read() inside tokio::select! lose bytes?

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:

  1. Cancellation = Drop — the losing select! branches aren't "paused," they're destroyed in place.
  2. 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-written read_frame() keeping its cursor in locals.
  3. How to avoid it — keep partial state outside the future: a long-lived buffer with a cursor, FramedRead codecs from tokio-util, cancel-safe primitives like mpsc::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.

08

Does dropping a JoinHandle cancel the spawned task? And what if the task panics?

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:

  1. drop(handle) — the task becomes detached and lives on; you just lose the ability to await its result.
  2. handle.abort() — the standard way to cancel: the task is stopped at its next .await point (abort on an already-finished task is a no-op).
  3. Panic — isolated to the task (unlike a main-thread panic, which takes down the program); the result is Err(JoinError) on await.
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.

09

Why 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?

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:

  1. Why dyn broke — a vtable stores function pointers with a known signature; the returned impl Future has a different size per impl, which doesn't fit a vtable slot.
  2. async-trait — the macro rewrites the method to -> Pin<Box<dyn Future + Send + '_>>; works with dyn, but pays a heap allocation per call.
  3. 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 Send must 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.

10

What's the difference between the executor and the reactor in tokio, and how does the Waker connect them?

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:

  1. Executor — runs the loop: take a task from the queue, call poll. If it returns Pending, park it and take the next.
  2. 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.
  3. Waker — on Pending, a future stores the Waker from its Context; on readiness the reactor calls wake(), 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.

Start studying

Keep going

Languages103 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
RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS