Lesson 44 of 44
Interview Coding, Bug-Spotting, and Design Questions
Coding problems, bug-spotting, and design questions for senior and lead Tokio interviews. Code is a sketch of the expected answer and is flagged rust,ignore because it is not machine-checked. Concept questions are in Interview Questions: Concepts, Internals, and Production.
Coding problems
Each problem lists the requirement, a short solution, and the points an interviewer checks.
1. Bounded parallel map
Apply an async function to every item with at most limit calls in flight. Return results in input order.
use std::{future::Future, sync::Arc};
use tokio::{sync::Semaphore, task::JoinSet};
async fn par_map<T, R, F, Fut>(items: Vec<T>, limit: usize, f: F) -> Vec<R>
where
T: Send + 'static,
R: Send + 'static,
F: Fn(T) -> Fut + Send + Sync + Clone + 'static,
Fut: Future<Output = R> + Send + 'static,
{
assert!(limit > 0, "limit must be greater than zero");
let sem = Arc::new(Semaphore::new(limit));
let mut set = JoinSet::new();
for (i, item) in items.into_iter().enumerate() {
let permit = sem.clone().acquire_owned().await.unwrap();
let f = f.clone();
set.spawn(async move {
let out = f(item).await;
drop(permit);
(i, out)
});
}
let mut out = Vec::new();
while let Some(res) = set.join_next().await {
out.push(res.unwrap());
}
out.sort_by_key(|(i, _)| *i);
out.into_iter().map(|(_, r)| r).collect()
}Checks: the permit is acquired before spawn, so the number of spawned tasks stays bounded. The index restores order. JoinSet aborts leftover tasks if the caller drops the future. res.unwrap() turns a task panic into a panic here; a production version returns Result. A shorter form is futures::stream::iter(items).map(f).buffered(limit).collect(), which runs the futures in the caller's task without spawning.
2. Timeout wrapper
Write with_timeout(dur, fut) that returns None on expiry.
use std::{future::Future, time::Duration};
async fn with_timeout<T>(dur: Duration, fut: impl Future<Output = T>) -> Option<T> {
tokio::select! {
v = fut => Some(v),
_ = tokio::time::sleep(dur) => None,
}
}Checks: on expiry fut is dropped, so its cancel safety matters. Ready fut and ready timer at the same poll are chosen at random unless biased; is used. tokio::time::timeout implements this and returns Result<T, Elapsed>. A timeout covers the whole operation, so a retry loop needs one per attempt and one overall.
3. Retry with exponential backoff and jitter
Retry an async operation up to max attempts. Wait a random time between zero and an exponentially growing cap (full jitter).
use std::{future::Future, time::Duration};
async fn retry<T, E, F, Fut>(max: u32, base: Duration, cap: Duration, mut op: F) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, E>>,
{
let mut attempt = 0;
loop {
match op().await {
Ok(v) => return Ok(v),
Err(e) if attempt + 1 >= max => return Err(e),
Err(_) => {
let ceiling = cap.min(base * 2u32.saturating_pow(attempt));
let wait = ceiling.mul_f64(rand::random::<f64>());
tokio::time::sleep(wait).await;
attempt += 1;
}
}
}
}Checks: jitter prevents many clients from retrying at the same instant. The cap bounds the wait. Only retry errors that are safe to retry, and only operations that are idempotent. With paused time, the loop can be tested without real waiting.
4. Rate limiter
Allow bursts up to burst calls and refill one permit every every.
use std::{sync::Arc, time::Duration};
use tokio::{sync::Semaphore, time::{interval, MissedTickBehavior}};
struct RateLimiter {
sem: Arc<Semaphore>,
}
impl RateLimiter {
fn new(burst: usize, every: Duration) -> Self {
let sem = Arc::new(Semaphore::new(burst));
let weak = Arc::downgrade(&sem);
tokio::spawn(async move {
let mut tick = interval(every);
tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
tick.tick().await; // the first tick completes immediately; skip it
loop {
tick.tick().await;
match weak.upgrade() {
Some(s) if s.available_permits() < burst => s.add_permits(1),
Some(_) => {}
None => break,
}
}
});
Self { sem }
}
async fn acquire(&self) {
self.sem.acquire().await.unwrap().forget();
}
}Checks: forget consumes the permit so it is not returned on drop. The weak reference ends the refill task when the limiter is dropped. Skip avoids a burst of refills after a stall. The permit count is capped at burst. For try semantics (shed instead of wait), use try_acquire.
5. Debounce
Forward the last value of a burst after quiet time passes without new values.
use std::time::Duration;
use tokio::{sync::mpsc, time::{sleep, Instant}};
async fn debounce<T>(mut rx: mpsc::Receiver<T>, quiet: Duration, out: mpsc::Sender<T>) {
let mut pending: Option<T> = None;
let timer = sleep(quiet);
tokio::pin!(timer);
loop {
tokio::select! {
msg = rx.recv() => match msg {
Some(v) => {
pending = Some(v);
timer.as_mut().reset(Instant::now() + quiet);
}
None => {
if let Some(v) = pending.take() {
let _ = out.send(v).await;
}
break;
}
},
_ = &mut timer, if pending.is_some() => {
if let Some(v) = pending.take() {
if out.send(v).await.is_err() {
break;
}
}
}
}
}
}Checks: one pinned Sleep is reset instead of creating a new timer per event. The if pending.is_some() guard disables the timer branch when nothing waits. rx.recv() is cancel safe, so no message is lost when the timer branch wins. Flushing on channel close avoids dropping the final value.
6. Worker pool
Run n workers that take jobs from one queue. The pool stops when all senders are dropped and the queue drains.
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
fn start_pool(n: usize, queue: usize) -> (mpsc::Sender<Job>, Vec<tokio::task::JoinHandle<()>>) {
let (tx, rx) = mpsc::channel::<Job>(queue);
let rx = Arc::new(Mutex::new(rx));
let handles = (0..n)
.map(|_| {
let rx = rx.clone();
tokio::spawn(async move {
loop {
let job = rx.lock().await.recv().await;
match job {
Some(job) => job.run().await,
None => break,
}
}
})
})
.collect();
(tx, handles)
}Checks: mpsc::Receiver is not Clone, so workers share it behind a mutex. The guard is a temporary in the let statement and is dropped before the job runs, so workers process in parallel. Only one idle worker waits inside recv at a time; the others wait on the lock. The bounded queue gives backpressure. Awaiting the handles after dropping the sender gives a clean drain. An alternative is one channel per worker with round-robin dispatch, at the cost of uneven load.
7. Fan-in
Merge results from many producers into one consumer. End when all producers finish.
use tokio::sync::mpsc;
async fn fan_in(sources: Vec<Source>) -> Vec<Item> {
let (tx, mut rx) = mpsc::channel(64);
for src in sources {
let tx = tx.clone();
tokio::spawn(async move {
while let Some(item) = src.next().await {
if tx.send(item).await.is_err() {
break;
}
}
});
}
drop(tx);
let mut all = Vec::new();
while let Some(item) = rx.recv().await {
all.push(item);
}
all
}Checks: drop(tx) removes the original sender. Without it recv never returns None. Each producer stops when the receiver is gone. Items from one producer keep their order, and there is no order across producers. For streams, futures::stream::select_all or tokio_stream::StreamMap merge without spawning.
8. Single-flight
Concurrent calls for the same key share one execution of the loader.
use std::{collections::HashMap, hash::Hash, sync::{Arc, Mutex}};
use tokio::sync::OnceCell;
struct SingleFlight<K, V> {
inflight: Mutex<HashMap<K, Arc<OnceCell<V>>>>,
}
impl<K: Hash + Eq + Clone, V: Clone> SingleFlight<K, V> {
async fn get<F, Fut>(&self, key: K, load: F) -> V
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = V>,
{
let cell = {
let mut map = self.inflight.lock().unwrap();
map.entry(key.clone()).or_default().clone()
};
let value = cell.get_or_init(load).await.clone();
let mut map = self.inflight.lock().unwrap();
if let Some(current) = map.get(&key) {
if Arc::ptr_eq(current, &cell) {
map.remove(&key);
}
}
value
}
}Checks: OnceCell::get_or_init runs one initializer while other callers wait for its value. The std mutex guards only map access and is never held across an .await. The pointer comparison keeps a late caller from removing a newer flight. Failure handling: if the loader returns Result, the Err is shared by all waiters, which is often intended. A cache adds a time-to-live on top.
Spot the bug
For each snippet, name the bug, then the fix.
1. Guard across await
async fn record(state: Arc<std::sync::Mutex<Vec<String>>>, msg: String) {
let mut log = state.lock().unwrap();
log.push(msg);
flush_to_disk(&log).await;
}Bug: a std::sync::MutexGuard lives across an .await. On the multi-thread runtime the future is not Send, so tokio::spawn(record(...)) does not compile. If it is forced through, other tasks block workers on the lock. Fix: copy what is needed and drop the guard first.
async fn record(state: Arc<std::sync::Mutex<Vec<String>>>, msg: String) {
let snapshot = {
let mut log = state.lock().unwrap();
log.push(msg);
log.clone()
};
flush_to_disk(&snapshot).await;
}If the lock itself must cover the I/O, use tokio::sync::Mutex.
2. Blocking in async
async fn handle(req: Request) -> Response {
let data = std::fs::read_to_string(&req.path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
Response::new(data)
}Bug: both calls block a worker thread. With as many concurrent requests as workers, the runtime stops running every other task, including timers. Fix: tokio::fs::read_to_string (or spawn_blocking) and tokio::time::sleep(...).await.
3. Unbounded channel
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
while let Some(job) = rx.recv().await {
slow_process(job).await;
}
});
loop {
let job = accept_job().await;
tx.send(job).unwrap();
}Bug: the producer never waits, so the queue grows without limit when slow_process is slower than accept_job. Memory grows until the process is killed. Fix: a bounded channel(N) and tx.send(job).await, or try_send with a rejection path when the system should shed load. Choose N from the latency target divided by service time.
4. Dropped JoinHandle
async fn on_message(msg: Msg) {
tokio::spawn(async move {
store(msg).await.unwrap();
});
}Bug: the handle is dropped, so nothing observes the task. If store fails, the unwrap panics inside the task, the panic is caught at the task boundary, and the caller never learns of it. Shutdown does not wait for the task either. Fix: keep the handle in a JoinSet or TaskTracker, await it, or handle the error inside the task (log and retry).
5. Cancel-unsafe select!
loop {
tokio::select! {
r = stream.read_exact(&mut header) => { r?; handle(&header); }
_ = tick.tick() => heartbeat().await,
}
}Bug: read_exact is not cancel safe. If the tick branch wins after read_exact has read part of the header, those bytes are dropped with the future and the stream loses sync. Fix: read into a buffer that lives outside the loop with a cancel-safe read, or move reading into its own task or a FramedRead, and select on the resulting channel or stream.
let mut frames = FramedRead::new(stream, MyCodec::new());
loop {
tokio::select! {
f = frames.next() => match f { Some(f) => handle(f?), None => break },
_ = tick.tick() => heartbeat().await,
}
}6. Missed Notify wake-up
// waiter
if !ready.load(Ordering::Acquire) {
notify.notified().await;
}
// signaller
ready.store(true, Ordering::Release);
notify.notify_waiters();Bug: if the signaller runs between the waiter's check and the creation of the Notified future, notify_waiters finds no waiter and stores no permit. The waiter then sleeps forever. Fix: loop, and create and enable the Notified future before checking the condition; or use notify_one, which stores a permit.
loop {
let notified = notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if ready.load(Ordering::Acquire) {
break;
}
notified.await;
}7. block_on inside async
async fn handler(rt: &tokio::runtime::Handle) -> u32 {
rt.block_on(async { compute().await })
}Bug: block_on from a thread that runs a runtime panics ("cannot start a runtime from within a runtime"), and would block the worker if it did not. Fix: .await the future directly. If synchronous code must call async code, run it on a thread outside the runtime, or call block_on there through a Handle.
8. Forgotten await
async fn save_all(items: Vec<Item>, db: &Db) {
for item in items {
db.insert(item);
}
}Bug: db.insert(item) returns a future that is never polled, so nothing is stored. The compiler warns (unused_must_use) for futures. Fix: db.insert(item).await; and handle the Result. Enable #![deny(unused_must_use)] so this fails the build.
Scenario and design questions
Each answer lists the primitives, the failure modes, and the criteria for choosing between designs.
1. High-throughput TCP proxy
Task. Forward bytes between clients and a backend, with thousands of connections.
Primitives. TcpListener::accept loop, one task per connection, tokio::io::copy_bidirectional (or two copy tasks over into_split halves), TcpStream::connect with timeout, Semaphore to cap connections, CancellationToken for shutdown.
Failure modes.
- No accept limit: file descriptors run out. Acquire a permit before spawning and release it when the connection ends.
- No connect timeout: tasks pile up on a dead backend.
- Half-close ignored: one direction ends, the other hangs.
copy_bidirectionalhandles shutdown of the write side. - Slow client: TCP flow control fills buffers. Idle timeouts close stalled connections.
- Accept errors such as
EMFILE: back off briefly instead of exiting.
Trade-offs. Buffer size (larger means fewer system calls, more memory per connection); idle timeout (shorter frees resources, may cut slow but valid flows); worker threads versus cores; connection reuse to the backend (fewer handshakes, head-of-line risk). Related lessons: TCP Clients and Servers, Performance and Tuning.
2. Job queue with retries
Task. Accept jobs over an API, run them with limited concurrency, retry failures, and survive restarts.
Primitives. Bounded mpsc for intake, worker pool (see coding problem 6) or Semaphore plus JoinSet, oneshot for per-job replies, retry with jitter and timeout per attempt, a persistent store (database table or broker) as the source of truth, TaskTracker and CancellationToken for shutdown.
Failure modes.
- In-memory queue lost on crash. Persist jobs before acknowledging, and mark them complete after success.
- Duplicate execution after a retry or a crash between success and acknowledgement. Jobs must be idempotent or carry a key.
- Poison job retried forever. Cap attempts and move the job to a dead-letter store.
- Slow consumers fill the intake channel. Return 429 or 503 through
try_send. - Shutdown mid-job. Stop intake, let running jobs finish within a deadline, and return unfinished jobs to the store.
Trade-offs. In-memory queue (fast, loses jobs) versus persistent queue (durable, adds latency); at-least-once delivery (duplicates, simple) versus at-most-once (loss); per-job timeout length; worker count against downstream capacity. Related lessons: Actors and workers, Graceful Shutdown.
3. WebSocket gateway
Task. Hold 100,000 client connections, route messages by topic, and push broadcasts.
Primitives. One task per connection split into a reader and a writer half; a bounded mpsc per connection as the outbound queue; a registry (RwLock<HashMap> or a sharded map, or an actor) from topic to subscribers; broadcast or per-subscriber try_send for fan-out; interval for pings; select! on socket, outbound queue, and shutdown token.
Failure modes.
- Slow client: an unbounded outbound queue grows. Bound it and, when full, drop messages or disconnect the client.
- Registry lock contention: a global lock held during fan-out. Copy subscriber handles out of the lock, then send.
- Lock guard across
.awaitwhile sending. Usetry_send, which does not wait. - Dead connections: missing pongs keep sockets open. Close after a missed-pong deadline.
- Reconnect storms after a deploy. Client backoff with jitter; accept-rate limits.
broadcastreceivers that lag getLagged. Decide whether to resync or disconnect.
Trade-offs. broadcast (one copy path, lagging receivers lose messages) versus per-client queues (per-client policy, more memory); sharded registries (less contention, more code); message size limits against memory per connection. Related lessons: oneshot, broadcast, and watch, mpsc Channels.
4. Rate-limited API client
Task. Call a third-party API that allows N requests per second and returns 429 under load.
Primitives. Token-bucket limiter (coding problem 4) shared through Arc; Semaphore for maximum in-flight requests; timeout per request; retry with jitter that honors a Retry-After header; a tower layer stack (RateLimit, Timeout, Retry) as an alternative to hand-written code; tracing spans per request.
Failure modes.
- Limiter and in-flight limit confused: the rate limit controls starts per second, the semaphore controls concurrency. Slow responses under only a rate limit build up concurrent requests.
- Retries multiply load. Count retries against the same budget and cap total attempts.
- Non-idempotent requests retried. Retry only safe methods or use idempotency keys.
- Several processes each enforce N. The combined rate exceeds the quota. Divide the quota or use a shared limiter.
- Clock jumps: use
tokio::time::Instant, which is monotonic.
Trade-offs. Token bucket (allows bursts) versus fixed interval (smooth, wastes idle capacity); wait for a token (latency) versus reject at once (errors); per-key limits (fairness, more state) versus one global limit. Related lessons: Semaphore, Notify, Barrier, and OnceCell, Error Handling in Async Code.
5. Cache with single-flight
Task. Serve reads from a cache, and on a miss load from a slow backend once even when many requests miss together.
Primitives. A map guarded by std::sync::Mutex (short critical sections) or a sharded map; per-key OnceCell or watch for in-flight loads (coding problem 8); timeout on the loader; entry expiry with a time-to-live checked on read or by a background interval task; Semaphore to cap concurrent loads across keys.
Failure modes.
- Cache stampede: many misses for one key hit the backend. Single-flight removes it.
- Loader task cancelled with the first caller: if the first caller's future is dropped, waiters may see a failure. Run the load in a spawned task and let callers await its result.
- Errors cached forever. Cache errors briefly or not at all.
- Unbounded growth. Set a capacity and an eviction policy.
- Guard held across the load. Never hold the map lock while awaiting the backend.
- Stale data served after expiry rules change. Define freshness explicitly (serve stale while refreshing, or block).
Trade-offs. Serve stale during refresh (low latency, older data) versus block until fresh; a global lock (simple) versus sharding (less contention); a spawned loader (survives caller cancellation, needs its own timeout) versus loading inside the caller's task. Related lessons: Shared State: Mutex and RwLock.
6. Zero-downtime deploy
Task. Replace a running service version without dropping in-flight requests.
Primitives. A signal listener (tokio::signal::unix::signal(SignalKind::terminate())); a readiness flag (watch<bool>) that a health endpoint reads; CancellationToken to stop intake; TaskTracker or JoinSet to wait for in-flight work; timeout as the drain deadline; a listener socket inherited or a load balancer that removes the instance first.
Sequence.
- On
SIGTERM, mark the instance not ready so the load balancer stops sending traffic. - Wait a short grace period for the balancer to notice.
- Stop accepting connections and cancel background intake.
- Wait for in-flight requests, up to the drain deadline.
- Flush logs, metrics, and buffers, then exit.
Failure modes.
- Drain deadline longer than the orchestrator's kill timeout: the process is killed mid-drain. Keep the deadline below it.
- Long-lived connections (WebSocket, streaming) never finish. Send a close message and a reconnect hint.
- Background tasks holding work when the runtime drops. Track them and await them.
- Blocking-pool work delaying exit. Use
shutdown_timeout. - Jobs half-processed at exit. Rely on idempotency and re-delivery, not on a clean exit.
Trade-offs. Drain deadline (longer means fewer aborted requests, slower deploys); aborting connections versus asking clients to reconnect; overlapping old and new versions (needs schema and protocol compatibility) versus stop-then-start (has downtime). Related lessons: Graceful Shutdown, Backpressure and Load Shedding.
Coverage map
| Topic | Where |
|---|---|
Spawning, JoinSet, join |
Coding 1, 6, 7; design 2 |
Time, timeout, retry, interval |
Coding 2, 3, 4, 5; design 4 |
Semaphore, Notify, OnceCell |
Coding 1, 4, 8; bugs 6; design 4, 5 |
| Channels and actors | Coding 5, 6, 7; bugs 3; design 2, 3 |
| Locks | Bugs 1; design 3, 5 |
Cancellation, select! |
Coding 2, 5; bugs 4, 5 |
| Blocking and runtime rules | Bugs 2, 7 |
| Framing, TCP, I/O | Bugs 5; design 1 |
| Shutdown, backpressure | Design 1, 2, 6; bugs 3 |
| Tracing, errors, pitfalls | Bugs 4, 8; design 4 |
Interview points
- State requirements first: ordering, bounds, cancellation behavior, and what happens on error.
- Bound every queue and every concurrency count, and say what the bound is derived from.
- Name the cancel-safety property of every future inside a
select!. - For each design, give the shutdown path and the overload path, not only the steady state.
- Give trade-off criteria (latency, durability, memory, contention) instead of one answer.
Summary
- Bounded parallelism combines a
Semaphoreorbufferedwith ordered collection. - Timeouts, retries, and limiters compose, and each needs its own bound.
Notifyand channel closure need care: enable the waiter before checking, and drop the original sender.- Guards across
.await, blocking calls, unbounded queues, dropped handles, and unawaited futures cover most Tokio bugs in review. - Designs are judged by failure behavior: overload, slow peers, restarts, and shutdown.
