Lesson 43 of 44
Interview Questions: Concepts, Internals, and Production
Short answers to common Tokio interview questions. Code blocks are illustrations and are flagged rust,ignore. Coding problems, bug-spotting, and design questions are in Interview Coding, Bug-Spotting, and Design Questions.
Async basics and futures
1. What is the difference between concurrency and parallelism, and which does async give?
Concurrency means several tasks make progress in overlapping time periods. Parallelism means several tasks run at the same instant on different cores. Async gives concurrency on one thread, and parallelism only when the runtime has several worker threads. See Threads, Async, and Where Tokio Fits.
2. What does calling an async fn do?
It returns a future and runs no body code. The body runs only when the future is polled, which .await, spawn, block_on, or a combinator does. A future that is created and dropped without being polled never runs. See Futures and async/await.
3. What is the Future trait?
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}poll returns Poll::Ready(value) when done or Poll::Pending when it cannot make progress yet. A Pending result comes with a promise: the future has stored the waker from cx and will call it when progress is possible.
4. What is a waker, and what happens if a future returns Pending without registering one?
A waker is a handle the executor gives a future through Context. Calling wake tells the executor to poll the task again. A future that returns Pending without arranging a wake-up is never polled again, so the task hangs. See Polling, Wakers, and a Tiny Executor.
Follow-up: May a future be polled without a wake-up? Yes. Spurious polls are allowed, so poll must be safe to call again and must re-register the latest waker.
5. Why does poll take Pin<&mut Self>?
An async block compiles to a state machine that can hold references into its own fields across an .await. Moving such a value would leave those references dangling. Pin guarantees the value stays at one address until dropped. Types that are safe to move implement Unpin, which lets Pin be ignored for them.
6. What determines the size of a future, and why does async recursion need boxing?
The state machine stores every local that lives across an .await, so its size is that of the largest state. A recursive async fn would contain itself, giving an infinite size. Wrapping the recursive call in Box::pin(...) puts it on the heap and gives a fixed size. Large futures make spawn allocate more and copy more, so big buffers are usually boxed or held in a Vec.
7. Why must a spawned future be Send, and what commonly breaks it?
On the multi-thread runtime a task can resume on a different worker after each .await, so everything held across an .await must be Send. The usual causes are Rc, RefCell references, raw pointers, and a std::sync::MutexGuard held across an .await. Dropping the value before the .await or scoping it in a block fixes the error.
8. What is the difference between .await in sequence and running futures concurrently?
a.await; b.await; runs b only after a finishes. tokio::join!(a, b) polls both in one task, so they overlap while waiting. tokio::spawn puts each on its own task, which also lets them run on different threads. See join!, try_join!, and Running Work Concurrently.
9. What are the constraints of async fn in traits?
async fn in traits is stable, but the returned future's Send-ness is not part of the trait unless the trait states it. Generic code that spawns such a future needs the bound written explicitly, for example with a return type impl Future<Output = T> + Send. Using the method through dyn Trait needs boxing, which the async-trait crate automates.
10. When is async the wrong choice?
When the work is CPU-bound, when there are few connections, or when a library is blocking-only. Async gains come from waiting on many slow I/O operations with few threads. A thread per connection is simpler and adequate at low concurrency. Criteria: number of concurrent waits, memory per waiter, and how much of the time is CPU work.
Runtime and scheduler
11. What runtime flavors exist, and how do they differ?
| Flavor | Threads running tasks | spawn needs Send |
Typical use |
|---|---|---|---|
multi_thread (default) |
one worker per core by default | yes | servers |
current_thread |
the thread that calls block_on |
yes for spawn, no for LocalSet::spawn_local |
tests, small tools, single-threaded designs |
12. What does #[tokio::main] expand to?
It builds a runtime and calls block_on on the body of the function, which has been turned into an async block. flavor = "current_thread" and worker_threads = N change the builder. See Your First Tokio Program.
13. How does the multi-thread scheduler distribute work?
Each worker owns a fixed-size local queue. New tasks spawned from a worker go to that worker's queue. An idle worker steals half of another worker's queue. Tasks spawned from outside the runtime go to a shared injection queue that workers check periodically. See Runtime builder and scheduler.
14. What is the LIFO slot?
Each worker has a one-task slot for the task most recently woken by the current task. That task runs next, which keeps a producer and consumer pair on the same core with warm caches. The scheduler limits how long the slot can be reused so other tasks are not starved.
15. What is cooperative scheduling, and why can a loop without .await freeze a runtime?
Tokio cannot preempt a task. A task runs until it returns Pending. A loop that never awaits, or awaits only things that are always ready, holds its worker. Tokio resources also count against a per-task budget and return Pending once it is spent, which forces a yield. tokio::task::yield_now().await yields explicitly.
16. How does Tokio learn that a socket is readable?
An I/O driver built on mio waits on the operating system's readiness API (epoll on Linux, kqueue on macOS and BSD, IOCP on Windows). When a socket becomes ready, the driver wakes the task that registered interest. See Reactor and timers.
17. How are timers implemented?
Timers live in a hierarchical timer wheel owned by the runtime's time driver. sleep registers an entry and returns Pending. When the wheel advances past the deadline, the entry wakes its task. A dropped Sleep removes its entry.
18. How do you use a runtime from code that is not async?
Keep a Handle (from Handle::current() or Runtime::handle()) and call handle.spawn or handle.block_on from a thread outside the runtime. Calling block_on from a thread already inside a runtime panics.
19. What happens when a runtime is dropped?
Dropping a Runtime shuts it down. Tasks still alive are dropped, and the drop waits for running blocking-pool work to finish. shutdown_timeout and shutdown_background bound or skip that wait.
20. What are the default thread counts?
The multi-thread runtime starts one worker thread per CPU core. The blocking pool grows on demand up to a limit (512 by default) and retires idle threads. worker_threads and max_blocking_threads on the builder change these.
Tasks, cancellation, cancel safety, select!
21. Why does tokio::spawn require 'static?
The task can outlive the function that spawned it, so it cannot borrow from that function's stack. Owned data, Arc, or cloned handles are moved in with async move. Scoped tasks that borrow do not exist in Tokio itself. See Spawning Tasks.
22. What happens when a JoinHandle is dropped?
The task is detached, not cancelled. It keeps running and its result is discarded. Panics inside it are then visible only in the panic hook output. JoinSet behaves differently: dropping it aborts its tasks.
23. What does a panic inside a spawned task do?
The panic is caught at the task boundary. The runtime and other tasks continue. handle.await returns Err(JoinError) and is_panic() on it is true. With panic = "abort" in the profile, the process aborts instead.
24. How does abort work, and when does it take effect?
JoinHandle::abort requests cancellation. The task is dropped the next time the scheduler polls it, which means at an .await point. Code between two awaits always runs to completion. A task stuck in a blocking call or a busy loop is not stopped. See Cancellation, abort, and Panics.
25. What is cancel safety?
An async operation is cancel safe when dropping its future at an .await loses no data and leaves no half-finished state. Cancellation in Rust is dropping a future, so any select! branch, timeout, or abort can cancel an operation midway.
26. Which common operations are cancel safe?
| Operation | Cancel safe | Reason |
|---|---|---|
mpsc::Receiver::recv |
yes | a message is taken only when returned |
oneshot::Receiver (as a future, by &mut) |
yes | value stays in the channel |
broadcast::Receiver::recv, watch::Receiver::changed |
yes | position or version is not advanced until ready |
TcpListener::accept |
yes | no connection is lost |
AsyncReadExt::read |
yes | bytes are consumed only when returned |
Sender::send(value) |
partly | on cancel the value is dropped, so use reserve to keep it |
AsyncReadExt::read_exact, read_to_end |
no | bytes already read into the buffer are lost |
AsyncWriteExt::write_all |
no | an unknown prefix may have been written |
Mutex::lock |
yes | the lock is held only when returned |
The table follows the Tokio documentation for each method. The select! lesson lists more.
27. How does select! choose between ready branches?
It polls branches in random order by default, which avoids starving a branch. biased; polls in source order. When one branch completes, the other branch futures are dropped. A branch can have a precondition, , if cond, that disables it.
28. How do you keep a future alive across loop iterations of select!?
Create it before the loop, pin it, and select on &mut fut.
let sleep = tokio::time::sleep(std::time::Duration::from_secs(5));
tokio::pin!(sleep);
loop {
tokio::select! {
msg = rx.recv() => { /* handle */ }
_ = &mut sleep => break,
}
}A future created inside the select! expression restarts each iteration. Selecting on &mut fut needs the future to be Unpin, which tokio::pin! provides.
29. How do join!, try_join!, and JoinSet differ?
join! waits for all futures in the current task and returns all outputs. try_join! returns early on the first Err and drops the rest. JoinSet holds a dynamic number of spawned tasks, returns results as they finish with join_next, and aborts remaining tasks when dropped. See JoinSet and Structured Concurrency.
30. What is structured concurrency, and how does Tokio approximate it?
Child tasks should not outlive the scope that created them, and their errors should reach the parent. Tokio does not enforce this for spawn. JoinSet provides the ownership and abort-on-drop part, and TaskTracker with CancellationToken (from tokio-util) provides tracking and signalling.
31. What is the difference between abort and a cancellation token?
abort drops the task at its next await, so cleanup must live in Drop. A CancellationToken is a signal the task observes, usually in a select!, so it can flush, close connections, and return normally. Tokens suit graceful stops. abort suits tasks that hold nothing that needs orderly cleanup.
32. How do you read a length-prefixed message in a select! loop without losing bytes?
Do not call read_exact inside a branch that can be cancelled. Keep a buffer outside the loop and select on read (cancel safe), append to the buffer, and parse complete frames from it. A FramedRead with a codec does this internally.
Blocking work
33. What counts as blocking, and how does it show up?
Any call that keeps a worker thread busy for a long time without returning to the scheduler: std::thread::sleep, synchronous file or network I/O, std::sync locks under contention, heavy computation, block_on. The symptoms are rising latency on unrelated tasks and timers firing late. A useful rule is that a poll should take microseconds. See Blocking and CPU-Bound Work.
34. Compare spawn_blocking, block_in_place, a dedicated thread, and Rayon.
| Tool | Runs on | Good for | Limit |
|---|---|---|---|
spawn_blocking |
blocking pool | short-to-medium blocking calls and file I/O | pool size; cannot be aborted once started |
block_in_place |
current worker, other tasks moved off | blocking call that borrows local data | multi-thread runtime only; panics on current_thread |
std::thread::spawn |
own thread | long-lived blocking loops | manual lifecycle |
Rayon pool plus oneshot |
separate CPU pool | data-parallel CPU work | needs a channel back to async code |
35. Can a spawn_blocking task be cancelled?
Not once it starts. abort on its handle works only if the closure has not started. Blocking closures need their own stop signal, such as an AtomicBool. The runtime drop also waits for them unless shutdown_timeout is used.
36. Is std::sync::Mutex allowed in async code?
Yes, when the critical section is short and the guard is dropped before any .await. It is faster than the async mutex. It becomes wrong when the guard crosses an .await: the future is no longer Send, or, if it compiles, other tasks may block a worker waiting for the lock.
37. How do you run a CPU-heavy function from async code and return its result?
Use spawn_blocking(f).await for moderate work. For sustained CPU work, run it on a Rayon pool and return through a oneshot.
let (tx, rx) = tokio::sync::oneshot::channel();
rayon::spawn(move || {
let _ = tx.send(expensive(input));
});
let result = rx.await?;Channels and locks
38. Why prefer bounded channels?
An unbounded mpsc channel grows until memory runs out when producers outpace consumers. A bounded channel makes send wait when full, which pushes the slowdown back to producers. Capacity 0 panics for mpsc::channel. See mpsc Channels.
39. Which channel fits which job?
| Channel | Senders | Receivers | Values | Use |
|---|---|---|---|---|
mpsc |
many | one | queue of messages | work queues, actor mailboxes |
oneshot |
one | one | a single value | request reply, task result |
broadcast |
many | many | every receiver sees every message | events, shutdown notices |
watch |
one (shareable behind an Arc) |
many | only the latest value | configuration, state snapshots |
See oneshot, broadcast, and watch.
40. What is RecvError::Lagged on a broadcast channel?
The channel keeps a ring buffer of fixed capacity. A receiver that falls behind by more than the capacity loses the oldest messages, and its next recv returns Lagged(n) with the number skipped. Receivers then continue from the oldest retained message.
41. What guarantees does watch give?
Receivers see only the most recent value, so intermediate values can be missed. changed().await completes when a value newer than the one last seen exists. borrow() returns a read guard over the current value and must not be held across an .await.
42. When is tokio::sync::Mutex needed instead of std::sync::Mutex?
When the guard must live across an .await, for example when the protected resource is itself used asynchronously. The Tokio mutex has an async lock, a Send guard, and FIFO fairness. It costs more per lock. Often a task that owns the resource and receives messages (an actor) removes the need for either. See Actors and workers.
43. How can holding a lock across .await deadlock?
Task A holds the lock and awaits something that only task B can provide. Task B waits for the lock. Neither progresses. The same happens with a lock held while sending on a full bounded channel whose consumer needs that lock.
44. How does the Tokio RwLock treat writers?
It is fair: a waiting writer blocks later readers, so writers do not starve. A task that takes a read lock and then requests another read lock while a writer waits can deadlock.
45. What is a Semaphore used for beyond limiting concurrency?
It bounds any counted resource: connections, in-flight requests, memory budget (permits per byte). acquire_owned on an Arc<Semaphore> gives a permit that can move into a spawned task. try_acquire supports load shedding. close wakes all waiters with an error. See Semaphore, Notify, Barrier, and OnceCell.
46. How does Notify avoid lost wake-ups?
notify_one stores one permit if no task is waiting, so a later notified().await returns immediately. notify_waiters wakes only tasks already waiting and stores nothing. For a condition checked in a loop, create the Notified future and call enable() before checking the condition, so a notification between the check and the wait is not lost.
47. When should shared state be replaced with message passing?
When operations on the state must be ordered, when the state does async work, or when many fields must change together. One task owns the state and handles messages in order. Trade-offs: an extra hop per operation, a bounded mailbox to size, and a reply channel per request.
48. Can bounded channels deadlock?
Yes, in a cycle. Stage A sends to stage B and B sends back to A. If both queues are full and both tasks are blocked in send, neither can receive. Break the cycle with a try_send and drop policy, separate queues per direction, or a design without back-edges.
I/O and framing
49. What does read returning Ok(0) mean, and why does write need write_all?
Ok(0) from read with a non-empty buffer means end of stream. read may return fewer bytes than the buffer size, and write may accept fewer bytes than given. Loops or read_exact and write_all handle partial transfers. See AsyncRead and AsyncWrite.
50. Why does TCP need framing, and what are the options?
TCP is a byte stream with no message boundaries, so one write may arrive as several reads and several writes as one read. Options: a length prefix, a delimiter such as a newline, or fixed-size records. See Bytes and Framing.
51. How does a Decoder signal that a frame is incomplete?
decode(&mut self, src: &mut BytesMut) returns Ok(None) when the buffer does not hold a full frame, and Ok(Some(frame)) after removing the frame's bytes from src. Framed calls it again after reading more data. A maximum frame length prevents a peer from making the buffer grow without limit. See Codecs with tokio-util.
52. Why do BufWriter and BufReader matter?
Each unbuffered read or write is a system call. Buffering batches small operations. A BufWriter holds data until it is full or flushed, so a program must call flush (or shutdown) before dropping it, because drop cannot await the flush.
53. What is the difference between tokio::io::split and TcpStream::into_split?
tokio::io::split works on any AsyncRead + AsyncWrite and shares the value behind a lock. TcpStream::into_split returns owned halves with no lock, and split returns borrowed halves. Owned halves can move into separate tasks.
54. What are Bytes and BytesMut?
BytesMut is a growable buffer for building or reading data. freeze converts it to Bytes, a reference-counted, immutable view. Cloning or slicing Bytes shares the allocation and copies no data, which avoids copies when one buffer is handed to several tasks.
55. How does a client signal that it has finished sending but still wants a reply?
It calls shutdown on the write half, which sends a FIN. The peer's reads return Ok(0) while the connection stays open for the peer to write the reply.
56. How does UDP differ from TCP in Tokio?
UdpSocket::recv_from returns one datagram at a time with its sender address. A datagram larger than the buffer is truncated. There is no connection, no ordering, and no delivery guarantee. See UDP and Unix Sockets.
57. Is tokio::fs asynchronous I/O?
It runs the operating system's blocking file calls on the blocking pool and wakes the task on completion. It keeps workers free, but each call costs a thread hand-off. Many small reads are better done in one spawn_blocking closure. See Files, Processes, and Signals.
58. How are signals and child processes handled?
tokio::signal::ctrl_c and tokio::signal::unix::signal(SignalKind::terminate()) produce futures that complete on the signal. tokio::process::Command spawns children with async pipes. A dropped Child is not killed unless kill_on_drop(true) is set.
Streams
59. How does Stream differ from Iterator?
Stream::poll_next returns Poll<Option<Item>>, so the next item may be pending. Stream is not in std; it comes from futures-core, re-exported by tokio-stream and futures. Combinators like map and filter come from StreamExt. See Streams.
60. What is the difference between buffered and buffer_unordered?
Both run up to N futures at once. buffered(n) yields results in input order. buffer_unordered(n) yields them in completion order, which avoids head-of-line waiting. Both bound concurrency to n.
61. How is backpressure applied in a stream pipeline?
A stream produces an item only when polled. A slow consumer polls less often, so the producer is not driven faster. Bridging from a channel with ReceiverStream keeps the channel's bound as the limit. A pipeline stage that spawns per item without a bound removes this effect.
62. When would you write a Stream by hand?
When no combinator fits, for example a state machine over a socket with a custom timeout. Implement poll_next, and register the waker whenever it returns Pending. See Writing Futures and Streams by Hand.
Timers and testing
63. How does interval behave, and what is MissedTickBehavior?
The first tick completes immediately, and later ticks are spaced by the period. If the task is late, MissedTickBehavior decides what follows: Burst (default) fires missed ticks back to back, Delay shifts the schedule from the late tick, Skip drops missed ticks and returns to the original grid. See Time: sleep, timeout, and interval.
64. What does timeout do to the inner future?
It polls the inner future and a timer. On expiry it returns Err(Elapsed) and drops the inner future. Cleanup is therefore subject to cancel safety, and a blocking call inside is not interrupted.
65. How do you test time-dependent code without waiting?
Enable Tokio's test-util feature and use #[tokio::test(start_paused = true)] or tokio::time::pause(). Time advances automatically to the next timer when all tasks are idle, so a 10-second sleep completes at once and deterministically. std::time::Instant is not affected. See Testing Async Code.
66. How do you test networking code without real sockets?
tokio::io::duplex(size) returns two connected in-memory streams that implement AsyncRead and AsyncWrite. For real sockets, bind 127.0.0.1:0 and read the assigned port from local_addr.
Shutdown and backpressure
67. What is a graceful shutdown sequence?
- Receive a trigger (
ctrl_corSIGTERM). - Stop accepting new work (drop the listener, close intake).
- Signal running tasks (a
CancellationTokenorbroadcast). - Wait for tasks to finish (
TaskTracker::waitorJoinSet) with a deadline. - Flush buffers, then exit.
See Graceful Shutdown.
68. What is backpressure, and what mechanisms does Tokio give?
Backpressure is a signal from a slow consumer that slows the producer. Mechanisms: bounded mpsc (send waits), Semaphore permits, reserve before producing work, stream polling, and TCP flow control when a task stops reading. See Backpressure and Load Shedding.
69. When should a service shed load instead of queue?
When waiting work would finish after its caller gave up, so it adds cost and no value. Queues add latency equal to queue length divided by service rate. Criteria: a latency target, a queue bound derived from it, and a cheap rejection path (try_send, try_acquire, HTTP 429 or 503).
70. How do you size a concurrency limit?
Start from downstream capacity (database pool size, upstream rate limit) and target latency. By Little's law, in-flight requests equal arrival rate times latency. Choose a limit at or below what the slowest dependency handles, and measure queue time.
Observability, performance, pitfalls
71. How does tracing work with async code?
Attach spans to futures with .instrument(span) or #[tracing::instrument], so the span is entered on each poll and exited on each Pending. Holding span.enter() across an .await attributes events from other tasks to the span. See Tracing and Observability.
72. How do you find a blocked worker or a stuck task?
tokio-console (needs console-subscriber and the tokio_unstable cfg) shows per-task poll times and waits. Handle::metrics() exposes runtime counters; some require tokio_unstable. Signs of blocking: one task with very long poll durations, timers firing late, queue depth growing.
73. What are the main levers for performance?
Avoid blocking in workers, avoid spawning tasks for tiny work, batch small writes with buffers, reuse allocations, bound concurrency, and pick worker counts to match cores (or fewer when sharing a machine). Measure before changing. See Performance and Tuning.
74. How should errors be handled in spawned tasks?
A spawned task's Result is visible only through its JoinHandle, so an unawaited task drops its error silently. Await handles (or use JoinSet), log errors inside the task, or send them over a channel. Libraries define typed errors (thiserror); applications often use anyhow for context. See Error Handling in Async Code.
75. List common Tokio pitfalls.
- Blocking calls in async code.
stdguard across.await.- Unbounded channels and unbounded
spawn. - Dropped
JoinHandlehiding errors. - Cancel-unsafe futures in
select!. block_oninside a runtime.- Forgetting
.await(the future never runs). - Creating a runtime per request.
See Common Pitfalls and How to Debug Them.
76. A request handler hangs forever. How do you investigate?
Check whether the future is being polled: add tracing events before and after each .await. Look for a lock held across an .await, a full bounded channel with no consumer, a Notify whose notification was missed, and a future returning Pending without a waker. tokio-console shows tasks idle with no wake-up source.
Ecosystem
77. How do hyper, axum, and tower relate?
hyper implements HTTP/1 and HTTP/2. tower defines the Service trait and middleware layers (timeouts, rate limits, retries). axum is a router and extractor layer built on hyper and tower, so tower layers apply to axum routes. See The Tokio Ecosystem.
78. Where do tonic, reqwest, and sqlx fit?
tonic is a gRPC implementation on hyper. reqwest is an HTTP client on hyper. sqlx is an async SQL client with connection pooling. All run on Tokio, and connection pool sizes act as concurrency limits.
79. What do tokio-util, tokio-stream, and bytes provide?
tokio-util provides codecs and Framed, CancellationToken, and TaskTracker. tokio-stream provides Stream adapters such as ReceiverStream and StreamExt. bytes provides Bytes and BytesMut.
80. How does Tokio compare to async-std, smol, and io_uring runtimes?
async-std and smol are other general-purpose executors and are less widely used in server libraries. Runtimes built on io_uring (such as tokio-uring, glommio, and monoio) use completion-based I/O and usually thread-per-core designs, which changes buffer ownership rules. Criteria: library compatibility, platform support, and I/O pattern.
81. What are mio and loom for?
mio is the cross-platform readiness layer Tokio's I/O driver uses. loom is a model checker that runs concurrent code under all thread interleavings, and Tokio uses it to test its own synchronization.
Topic map
| Lesson topics | Questions |
|---|---|
Why async, futures, wakers, Pin, custom futures |
1 to 10, 62 |
| Runtime, scheduler, reactor, timers | 11 to 20 |
Spawning, join, cancellation, select! |
21 to 32 |
| Blocking and CPU work | 33 to 37 |
| Channels, locks, semaphores, actors | 38 to 48 |
| I/O, framing, UDP, files, signals | 49 to 58 |
| Streams | 59 to 62 |
| Time and testing | 63 to 66 |
| Shutdown and backpressure | 67 to 70 |
| Tracing, performance, errors, pitfalls | 71 to 76 |
| Ecosystem | 77 to 81 |
Interview points
- State the mechanism first (poll, waker, drop), then the consequence.
- Tie every rule to its reason: cancel safety follows from cancellation being a drop.
- Give criteria for a choice (channel type, blocking tool, limit size), not a single answer.
- Name the failure mode of each tool: unbounded growth, starvation, lost wake-up, deadlock.
Summary
- A future runs only when polled and needs a registered waker when it returns
Pending. - Tasks are cancelled by dropping; only cancel-safe futures belong in
select!loops. - Blocking work belongs on
spawn_blocking, a dedicated thread, or a CPU pool. - Bounded channels and semaphores carry backpressure; unbounded ones do not.
- Graceful shutdown stops intake, signals tasks, waits with a deadline, then flushes.
- Paused time makes timer tests fast and deterministic.
