Dropping a Future Cancels It: The Question Behind select! and Timeouts
The interviewer asks the question that separates Rust async users from async memorizers: "you tokio::select! two branches, branch A wins — what happened to branch B's future?" The precise answer: branch B's future was dropped, and dropping a future cancels it. Everything downstream — timeout design, graceful shutdown, buffered writes — follows from that one fact.
Why dropping is cancellation: a future is a paused computation whose state (captured variables, partial read buffers, poll position) lives inside the future value itself. Nothing keeps running in the background, because there is no background — the executor stopped polling it, and dropping the value deletes the state it would have resumed from. That is a deliberate design choice: Rust gives you structured concurrency. A task is owned by whatever joined or selected it; when that owner goes away, the task goes away too, and no orphaned work outlives the scope that spawned it. The contrast interviewers expect: in goroutine-based runtimes a losing branch and an abandoned task keep running until they finish on their own.
Now the consequence that produces real bugs. Cancellation is safe for futures that can be resumed from scratch, and unsafe-by-surprise for futures holding partial state in the outside world. The canonical example is a buffered write:
let mut file = tokio::fs::File::create("out.txt").await?;
// suppose this write future is selected against a timeout and loses:
file.write_all(&buf).await?; // may have written SOME bytes when cancelled
If the write future is dropped mid-way, the kernel may have accepted a prefix of buf. Retrying the whole buffer later duplicates bytes; not retrying leaves a torn record. The same hazard appears with TcpStream reads (a partially consumed packet is consumed forever), with anything that mutates shared state between .await points, and with tokio::sync::Mutex::lock (a cancelled waiter is dequeued — this one is handled for you; the guard you would have acquired is not the issue). Tokio's own docs maintain the list of cancellation-safe operations; the rule to rehearse is the write half of a stream and read_exact-style stateful reads are not cancellation safe.
How to handle it in code you are asked to write:
// Wrap the non-safe operation so cancellation retries from the start:
loop {
tokio::select! {
_ = timeout(Duration::from_secs(5), write_all_and_flush(&mut file, &buf)) => break,
_ = shutdown_rx.recv() => return Err(Error::Aborted),
}
}
where write_all_and_flush is written so each invocation owns its full buffer and flushes before returning — a cancelled invocation leaves no partial state the next one depends on. Or structure the race at a higher level: select on whole operations, never inside one.
tokio::select! mechanics worth stating under questioning: each branch's future is created when the macro runs (so async blocks are polled only inside the select), exactly one branch completes, and the macro is biased-off by default — it polls branches in random order each iteration to avoid starvation, which you override with select! { biased; ... } when order matters (a shutdown check before new work). select! requires every branch expression to be cancel-safe by your analysis; tokio::time::timeout(d, fut) is the same mechanism with a timer branch. And because dropping cancels, an unfinished JoinHandle from tokio::spawn aborts the task when the handle drops — which is why libraries wrap it in AbortOnDropHandle: RAII shutdown instead of a forgotten background task. For how futures and executors work underneath this, see Async and Await and Join and Select; this drill is about the semantic — cancellation by drop — that those tools are built on.
Static vs Dynamic Dispatch: Generics, Trait Objects, and dyn Future
"What's the difference between impl Trait, Box<dyn Trait>, and generics?" — asked as a dispatch question, and the answer has one spine: static dispatch compiles a specialized copy per type (monomorphization, no runtime indirection); dynamic dispatch goes through a vtable at runtime (one code copy, one pointer indirection per call). Everything else is consequences.
Static dispatch first. A generic function:
fn process<T: Handler>(handler: T) -> Response {
handler.handle()
}
is compiled once per concrete T the caller instantiates — process<JsonHandler>, process<XmlHandler>, each a specialized machine-code version with the calls inlined. Benefits: the compiler sees the concrete type, so handle() is devirtualized and inlinable, and T can carry extra bounds that trait objects cannot express (T: Handler + Clone, or associated types pinned to exact values). Costs: code bloat (every instantiation emits its own copy) and compile-time coupling — downstream crates that call your generic function must also be recompiled, which is why changing a public generic function's signature breaks incremental builds more than a dyn one does.
Dynamic dispatch:
fn process(handler: &dyn Handler) -> Response {
handler.handle()
}
carries a fat pointer: data pointer plus a pointer to the vtable (drop glue, type metadata, one slot per virtual method). Every handle() call loads the function pointer from the vtable and jumps — inlining across that boundary is impossible. In exchange you get heterogeneous collections (a Vec<Box<dyn Handler>> holding different types), binary-size control (one copy of the code regardless of type count), and the ability to hide a type behind a stable ABI-ish boundary. The rule of thumb worth saying out loud: dispatch kind follows the call site's needs — generics at the hot API edge where types are concrete, dyn where a collection or a plugin boundary forces runtime variety.
Async makes the question sharper, because a future is a state machine whose size is only known after type checking — async fn bodies have unnameable, compiler-generated types. Returning impl Future<Output = T> keeps you on the static path:
async fn fetch(client: &Client, id: u64) -> Item { /* ... */ }
// desugars to: fn fetch(...) -> impl Future<Output = Item> + '_
one concrete future type, zero indirection. When the concrete type must be erased — a trait with async methods, a return type chosen at runtime, boxed trait objects — you pay twice:
use std::pin::Pin;
use std::future::Future;
trait Job: Send {
fn run<'a>(&'a self) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
}
Pin<Box<...>> because a self-referential async state machine cannot be moved after being polled, + 'a because the future borrows self, and a new Box allocated per call — the interviewers' favorite follow-up is exactly this: "why is boxed async so expensive?" (heap allocation + indirection + inhibited inlining, not just the vtable). Note also that async fn in traits (stable since 1.75) does dispatch statically when the trait is used generically, but does not automatically produce a dyn-compatible trait — the desugared Pin<Box<dyn Future>> above is what you still write for object safety.
Close the loop with a decision the interviewer can check: a web framework handler list is usually Vec of boxed handlers (runtime variety, cold-ish dispatch), while the request path inside one handler is fully generic and monomorphized (hot, static). Naming that split shows you use both on purpose — the same two-sided instinct Ownership and Borrowing Interview Drills built for Box versus Rc: dispatch kind follows the call site, ownership kind follows the sharing pattern. The trait-object machinery this rests on is Trait Objects; the Pin story is Pin and Unpin.
What a Runtime Actually Does — and Why Arc<Mutex> Still Shows Up in async Code
Two questions finish the drill set, and they are really one system examined from both ends: what makes async fn actually execute, and what synchronization looks like once many tasks run concurrently on one thread pool.
Start with the first. Write async fn read_name() -> String { ... }, call it, print nothing. The function body has not executed — the call returned a value, a future, which is a description of work, not the work. A future is polled: poll(&mut self) -> Poll<Output> runs the body synchronously until it hits an .await on something not ready, at which point the state machine saves its position and returns Pending. The runtime is the driver that calls poll: an executor holds a runnable-queue of tasks, polls each until it yields or finishes, and — crucially — uses wakers. Each pending .await (a socket read, a timer) registers a waker; when the I/O completes, the reactor (epoll/kqueue/IOCP behind mio) invokes the waker, which pushes the task back onto the queue, and some worker thread polls it again. So the full sentence interviewers want: async fn creates a state machine; the executor polls it; the reactor wakes it when I/O is ready; tokio::spawn is what puts a future into that machine. Calling the async fn directly executes it only when something polls it — which is why #[tokio::main] exists and why a forgotten spawn on a CPU-bound loop starves nothing but also runs nowhere.
Second half: the executor multiplexes thousands of tasks on a handful of threads — concurrency on one thread, not parallelism by itself. When the interview turns to genuinely parallel work — a shared cache consulted by two std::thread::spawned threads — the compile errors start with Sender cannot be sent between threads safely or the classic:
error: future cannot be sent between threads safely
= note: future is not `Send` as this value is used across an await
The drill question: "two threads must increment and read one counter — write it." The correct shape:
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
for _ in 0..2 {
let c = Arc::clone(&counter);
handles.push(std::thread::spawn(move || {
let mut n = c.lock().unwrap();
*n += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap());
Unpack each layer because that is where candidates skip depth. Mutex<u64> gives exclusive access — one thread holds the lock, everyone else blocks; the unwrap() handles poisoning (a previous holder panicked mid-critical-section, and the data's invariant may be broken). Arc gives shared ownership across threads: each thread owns a handle to the same allocation, and the atomic count drops it once the last join completes. Neither alone suffices — Mutex without Arc is one owner that can't be in two threads; Arc without Mutex is shared access with no exclusion, which doesn't compile for mutation and wouldn't be safe if it did. Inside tokio the rules bend: tokio::sync::Mutex is for a lock held across .await (brief, contended-across-yield cases), and spawn_blocking or channels are usually better than any lock for CPU work. A channel is the alternative shape — ownership travels in the message, no lock needed — which is precisely the design the Concurrent Service Capstone builds. The thread-side prerequisites are Send and Sync and Arc Mutex; what you add here is the executor story that ties the two worlds together.
Quiz
1. Inside a tokio::select!, branch A completes while branch B is blocked mid-write of a 10 KB buffer to a TcpStream. What is true?
2. What is the essential performance difference between `fn f<T: Handler>(t: T)` and `fn f(t: &dyn Handler)`?
3. You call `let fut = my_async_fn();` and never await or spawn it. What happens?
4. Two `std::thread::spawn`ed threads must increment one counter. Why is `Arc<Mutex<u64>>` the correct type, layer by layer?
Flashcards
Question
When a future is dropped, what happens to the operation it was performing?
Click to reveal answer
Answer
It is cancelled: the future's entire state lives in the value, so dropping deletes the paused computation and nothing continues in the background (structured concurrency). Operations with partial external effects — buffered stream writes, read_exact-style stateful reads — are not cancellation safe; wrapping them so each attempt owns its full input, or selecting around whole operations, is the fix.
Question
What does tokio::select! guarantee about losing branches, and what does `biased` change?
Click to reveal answer
Answer
Exactly one branch completes and every other branch's future is dropped (cancelled) at that moment. By default branches are polled in random order each iteration to prevent starvation; `select! { biased; ... }` polls strictly in written order — used e.g. to check a shutdown signal before accepting new work.
Question
Static dispatch vs dynamic dispatch: the mechanism and the trade.
Click to reveal answer
Answer
Static (generics, impl Trait): monomorphized per concrete type, calls inlinable, extra bounds allowed — cost is code bloat and downstream recompiles. Dynamic (&dyn Trait): a fat pointer (data + vtable) with one indirect jump per virtual call, no cross-boundary inlining — buys heterogeneous collections, one code copy, and type erasure. In async, boxed dyn futures additionally pay a heap allocation per call plus Pin.
Question
What are the executor, reactor, and waker, and what does async fn alone do?
Click to reveal answer
Answer
async fn builds a state machine that does nothing until polled. The executor polls tasks from a run queue until each yields or finishes; the reactor (epoll/kqueue/IOCP via mio) waits on I/O and timers; when a resource becomes ready it invokes the registered waker, which re-queues the task. tokio::spawn puts a future into this machine — there is no ambient poller.
Question
Why Arc<Mutex<T>> to share a value between two spawned threads — what does each layer contribute?
Click to reveal answer
Answer
Mutex<T> gives exclusive access: one thread in the critical section, others block; lock().unwrap() covers poisoning from a panicking holder. Arc<T> gives shared ownership: cloned handles point at one heap allocation with an atomic reference count, making the value Send+Sync. Mutex alone has one owner (can't be in two threads); Arc alone has no exclusion (mutation wouldn't compile). tokio::sync::Mutex or channels are usually preferable in async code.
Revision Notes
Key Takeaways
- 1. Dropping a future cancels it because the paused computation's state lives inside the future value — structured concurrency by construction, unlike fire-and-forget goroutines
- 2. Cancellation safety is about external effects: buffered writes and stateful reads leave torn state when cancelled; select around whole operations, and know tokio's documented safe list
- 3. Generics/impl Trait compile one specialized copy per type with inlinable calls; dyn Trait trades a vtable indirection for heterogeneity — and boxed async futures pay allocation + Pin on top
- 4. An async fn call builds a state machine that executes nothing until an executor polls it; the reactor wakes tasks when I/O completes, and tokio::spawn is the entry into that machine
- 5. Arc<Mutex<T>> splits cleanly: Mutex provides the critical section, Arc provides the shared ownership; in async code channels or tokio::sync primitives usually beat a std Mutex held across awaits
Interview Tips
- • "What happens when you drop a future mid-await?" — cancellation, state deleted with the value, then immediately give the hazard (torn buffered write) and the mitigation (whole-operation retry, cancellation-safe list); that three-beat answer is the differentiator
- • "impl Trait or Box<dyn Future>?" — static by default, erase only at real boundaries; be ready to write the desugared signature `Pin<Box<dyn Future<Output=T> + Send + 'a>>` and explain each piece (Pin for self-reference, 'a for the borrow, Send for spawn)
- • "Walk me through what happens between .await and the next poll" — waker registration, reactor readiness event, re-queue, worker poll; naming epoll/mio and the run queue shows you know the machine, not just the syntax
- • "Two threads share a counter — write it" — write Arc<Mutex<>> without hesitation, then volunteer the two follow-ups interviewers hold back: poisoning (why unwrap) and why a single increment is three instructions, not one atomic one
Cheat Sheet
Async & Concurrency Drills
Cancellation: dropping a future cancels it — all state lives in the value; nothing runs in the background.
// not cancellation safe: partial write survives a cancelled select branch
file.write_all(&buf).await?;
// safe pattern: whole-operation attempts, each owning its full input
loop { select! { _ = timeout(d, write_all_and_flush(&mut f, &buf)) => break,
_ = shut.recv() => return Err(Error::Aborted) } }
select!: one branch completes, losers are dropped; biased; polls in written order. Unfinished JoinHandle drop = abort the task.
Dispatch:
impl Trait / generics |
&dyn Trait |
|
|---|---|---|
| Resolution | compile-time (monomorphized) | vtable at runtime |
| Call cost | inlinable | load fn ptr + jump |
| Collection | one type | heterogeneous |
| Async note | future type unnameable | Pin<Box<dyn Future + Send + 'a>> per call |
Runtime: async fn = state machine, runs only when polled. Executor polls the run queue; reactor (epoll/mio) waits on I/O; waker re-queues on readiness. #[tokio::main] = block_on the first future.
Thread sharing: Arc<Mutex<u64>> — Mutex = exclusion (+poisoning via unwrap), Arc = one shared allocation with atomic count. In tokio prefer channels or spawn_blocking over locks held across .await.