Skip to content
advanced Phase 13 · Async Rust with Tokio

Timeouts, Cancellation, and Async I/O

Bound async operations and understand why cancellation must be safe.

50m
0 problems
Topic Progress 0%

Bounding a Request with timeout and timeout_at

An HTTP client with no timeout is an outage waiting to happen: one slow upstream, a pool of tasks parked forever, and a service that never recovers because nothing is left to cancel. Tokio's fix is a combinator that races your future against a timer:

use tokio::time::{timeout, Duration};

match timeout(Duration::from_secs(3), client.get(url)).await {
    Ok(Ok(body))  => println!("{} bytes", body.len()),
    Ok(Err(e))    => println!("upstream error: {e}"),
    Err(_)        => println!("gave up after 3s"),   // Elapsed: "deadline has elapsed"
}

The signature is timeout(dur, fut) -> Timeout<F>; awaiting it yields Result<F::Output, Elapsed> — a nested Result when the inner future also returns one, which is why the arms read Ok(Ok(..)), Ok(Err(..)), Err(..). The Err(Elapsed) arm is where your deadline logic lives; the Display impl for Elapsed is deadline has elapsed.

Mechanically, timeout is a select! with two branches: your future and a sleep. When the timer wins, your future is dropped — not paused, not sent a stop signal. Whatever it had not finished is abandoned, and its destructors run. That single sentence is the spine of this topic; the next chapter unpacks what "abandoned" costs you.

In real code the arms usually collapse with ? inside a function returning Box<dyn Error> (both Elapsed and io::Error convert), or into .ok() when the operation is optional — a metrics flush that may be dropped, a cache warm-up nobody is waiting on:

if let Some(body) = timeout(Duration::from_millis(200), cache.get(key)).await.ok().flatten() {
    return body;                       // deadline hit = cache miss, not an error
}

For absolute deadlines — a request inheriting a client-wide budget — use timeout_at:

let deadline = Instant::now() + budget;      // budget: Duration from the caller
let user  = timeout_at(deadline, fetch_user(id)).await;
let left = deadline.saturating_duration_since(Instant::now());
let hist = timeout_at(deadline, fetch_history(id)).await;   // shares the same instant

Per-call timeouts without an outer budget are a classic interview trap: three upstream calls at 5 s each can spend 15 s inside a 5 s SLO. Budgets compose downward — one Instant for the whole operation, sub-timeouts clamped to what remains.

Two configuration facts worth stating precisely:

  • Timers need the runtime's time driver. #[tokio::main] enables it; a hand-built Builder::new_multi_thread().build() without enable_all()/enable_time() compiles fine and panics at the first timer.
  • timeout itself never touches a thread — the sleep is registered with the timer driver and the task parks, so thousands of concurrent timeouts cost thousands of entries in a timer wheel, not thousands of threads.

Related tool for periodic work: tokio::time::interval(period) ticks forever and reports how it handled a late wake-up via MissedTickBehavior — Burst (default: fire immediately to catch up), Skip (jump to the next whole period), Delay (restart counting from now). Choosing wrong turns a monitoring loop into a thundering herd after a GC pause.

let mut tick = tokio::time::interval(Duration::from_secs(10));
tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop { tick.tick().await; collect_metrics(); }

Two facts to close with. First, timers are cheap and virtual: every timeout/sleep inserts an entry into a hierarchical timer wheel driven by the runtime — one driver thread for the whole process, so ten thousand concurrent timeouts are ten thousand structs, not ten thousand threads. Second, timeouts do not compose with blocking: a spawn_blocking closure already running cannot be cut short by your Elapsed — the deadline only cancels the await, so never put a join!-style wait you cannot abandon on the critical path. For tests, Tokio can virtualize all of this: #[tokio::test(start_paused = true)] auto-advances the clock, so a 5-second timeout test finishes in microseconds and asserts the deadline logic instead of sleeping through it.

Framing for interviews: "Where do you put timeouts?" — on connect, on each read/write, and on the total operation; derived from one shared deadline; each Err(Elapsed) branch documented as to whether the partial work must be compensated.

A closing pattern that combines budget and cancellation — capping a shutdown so a wedged worker cannot hang the process:

let drain = timeout(Duration::from_secs(10), async {
    for h in handles { let _ = h.await; }   // wait for in-flight tasks
}).await;
if drain.is_err() { println!("drain timed out; dropping stragglers"); }

You are waiting for cooperative exit and still holding a forced cancel in reserve. That two-layer design — graceful first, forced as a backstop — is what production cancellation looks like, and it is built entirely out of the two mechanisms in this topic: timeout for the backstop, dropped futures for the enforcement.

Cancellation Is Dropping the Future: What Rust Guarantees and What You Own

timeout did not interrupt your request. It dropped it. In Rust, dropping a future at an .await point is cancellation — the same mechanism by which select! discards losing branches, an aborted task stops, and a runtime shutdown cleans up. Learn to read cancellation as RAII:

tokio::time::timeout(Duration::from_millis(10), save_order(order)).await
// on Elapsed: the `save_order` future is dropped here — locals unwound in order,
// MutexGuards released, buffers freed, Drop impls run to completion

Because the state machine is an ordinary Rust value, everything the language already promises still holds: no dangling references, no leaked locks, no undefined behavior. Memory safety is automatic; logical safety is yours.

Cancellation source What happens
timeout(..) expires inner future dropped inside the combinator
losing a select! branch branch futures dropped at the winner's return
JoinHandle::abort() task dropped at its next poll (usually next .await)
runtime shutdown / main returns unfinished tasks dropped
dropping a JoinHandle nothing — the task detaches and keeps running

What the compiler cannot fix for you: partial effects. Three failure modes to name in an interview:

  1. Progress loss — a read_exact future dropped mid-transfer has already consumed bytes; recreating it restarts at the wrong offset. This is why only cancel-safe operations may sit in a select! branch — the rule from join! and select! for Concurrent Futures has a real cost here.
  2. Non-idempotent side effects — the charge went through, the confirmation future was dropped, and no one records it. Cancellation is not rollback; compensate explicitly (outbox, idempotency key).
  3. Silent non-cooperation — a task in a loop with no .await never reaches a poll, so abort() cannot land; a task stuck inside spawn_blocking finishes the blocking call regardless. Cancellation lands at an await, never in the middle of a poll.

That third row drives the design of clean shutdowns: make cancellation cooperative, something the task opts into at well-chosen points.

async fn worker(mut stop: tokio::sync::oneshot::Receiver<()>) {
    loop {
        tokio::select! {
            _ = &mut stop => { println!("draining"); break; }   // signal arrived
            job = queue.recv() => process(job),
        }
    }
}

Production code usually generalizes this to tokio_util::sync::CancellationToken (cloneable, hierarchical: cancelling the parent cancels children) or a watch channel broadcast to every task, so one signal orderly-stops a tree of workers, then awaits the in-flight tasks instead of dropping them.

Cancellation is observed, never preemptive. While a task is inside a poll — executing plain Rust between two awaits — nothing can touch it. The runtime only acts when the task is polled again: either it resumes at the .await or it drops the future instead. Three consequences fall out: a poll that runs 100 ms of CPU delays cancellation by 100 ms; a task in a loop with no await is effectively uncancellable; and your destructors run inside the cancellation path, so a Drop that blocks (joining a thread, flushing synchronously, taking a lock) blocks the shutdown itself. Keep destructors fast and free of blocking work — cancellation quality equals destructor quality.

You can rely on You cannot rely on
destructors running exactly once, in declaration order partial side effects being undone
guards releasing; no UB, no leaks of borrowed data a blocked (non-await) region stopping early
Drop observing a consistent, memory-safe state cancellation arriving at a specific point in time
a cooperative task exiting at its chosen await a task with no awaits stopping at all

Interview answer to "how do you cancel async work?": forced path = drop the future (abort, timeout, losing select!), effective at the next await; graceful path = a signal future raced with the work inside select!, letting each task finish its current unit and release resources deliberately. Prefer graceful for shutdown, forced for deadlines.

Why TcpStream::connect().await Doesn't Block: The Reactor Model

std::net::TcpStream::connect(addr) blocks the calling thread until the kernel completes the handshake — under Tokio that would pin a worker for the duration, exactly the starvation described in The Tokio Runtime. Tokio's TcpStream solves it by never blocking the thread at all:

use tokio::net::TcpStream;
use tokio::io::AsyncReadExt;

let mut stream = timeout(Duration::from_secs(2), TcpStream::connect(addr)).await
    .map_err(|_| "connect timed out")??;         // Elapsed -> io::Result<TcpStream>

let mut buf = [0u8; 1024];
let n = timeout(Duration::from_secs(1), stream.read(&mut buf)).await
    .map_err(|_| "read timed out")??;
println!("read {n} bytes");

Under the hood, three pieces cooperate:

  1. Reactor. On first use, Tokio registers the socket with its I/O driver — mio on top of epoll (Linux), kqueue (macOS/BSD), or IOCP (Windows). One thread multiplexes every registered descriptor.
  2. Readiness. connect().await returns Poll::Pending while the handshake is in flight and subscribes for writability. Your task is parked; the worker goes to run other tasks.
  3. Wake. When the socket becomes ready, the reactor fires the task's Waker, the scheduler re-polls the future, and the operation completes on that poll — Ready(Ok(stream)).

The payoff is the scale argument interviewers love: 10,000 concurrent connections need one reactor thread plus a handful of workers and 10,000 state machines in memory — not 10,000 OS threads with multi-megabyte stacks. A connection costs bytes of state, not a thread. Blocking calls are the exception path: a synchronous DNS lookup, a legacy driver, a blocking TLS handshake in a library with no async API — those go to spawn_blocking, off the workers.

A minimal server shows the whole loop — accept, hand off, keep listening:

let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
    let (stream, peer) = listener.accept().await?;   // parks until a SYN arrives
    tokio::spawn(async move { handle(stream, peer).await });  // new task, new state machine
}

accept().await is on the cancel-safe list, which matters because it routinely sits in a select! racing a shutdown branch — an accept lost to cancellation simply happens again on the next loop trip.

The type mapping between blocking and async networking is one table, and choosing the wrong column is how a server's latency graph develops a cliff:

Blocking (std) Async (tokio) Notes
TcpStream::connect TcpStream::connect(..).await reactor-driven handshake
TcpListener::accept listener.accept().await returns a non-blocking stream
stream.read (blocks) stream.read(&mut buf).await park + waker instead of a blocked thread
to_socket_addrs sync DNS lookup_host(..).await resolver on the blocking pool
thread-per-connection one task per connection state vs. stacks

Cancellation safety shows up here too, because I/O futures get dropped constantly — every timeout and losing select! branch. Tokio draws the line precisely:

Operation in an async context Cancel safe? Why
AsyncReadExt::read / read_buf yes bytes stay in the socket buffer until a poll returns the count
read_exact, read_to_end, read_to_string no bytes already moved into your buffer; progress dies with the future
AsyncWriteExt::write yes returns how many bytes it took; nothing taken = nothing lost
write_all no it may have flushed a prefix with no record of how much
TcpListener::accept yes the accepted connection is only removed on the completing poll

So the rule for wrapping I/O in timeout: prefer the single-shot read/write primitives inside races and loops, keep the accumulating helpers (read_exact, read_to_string, write_all) in straight-line code where nothing can drop them mid-flight. Wrong choice produces the nastiest bug class in async I/O — a response that is truncated or duplicated only under timeout pressure, i.e. only in production.

Where next: the address-stability requirement behind every poll(self: Pin<&mut Self>) — Pin and Unpin Explained.

Quiz

1. What does `tokio::time::timeout(d, fut).await` return when the deadline passes first?

Question 1 options

2. Which statement about cancelling an async task is true?

Question 2 options

3. Why does `TcpStream::connect(addr).await` not occupy an OS thread while the handshake runs?

Question 3 options

4. You wrap `AsyncReadExt::read_to_string` in a timeout inside a loop. Why is this dangerous?

Question 4 options

Flashcards

Question

What are the signatures and results of timeout and timeout_at?

Answer

timeout(Duration, F) and timeout_at(Instant, F) both await to Result<F::Output, Elapsed> — nested as Result<Result<T, E>, Elapsed> when the inner future is fallible. On expiry the inner future is dropped (cancelled) and Elapsed displays 'deadline has elapsed'.

Question

Define async cancellation in Rust terms and give four triggers.

Answer

Cancellation is dropping a future while it is suspended at an await: locals unwind, Drop impls run, nothing resumes. Triggers: a timeout elapsing, losing a select! branch, JoinHandle::abort() (at the next poll), and runtime shutdown. Dropping a JoinHandle is NOT a trigger — it detaches.

Question

What does Rust guarantee when a future is cancelled, and what remains the application's responsibility?

Answer

Guaranteed: memory safety — destructors run in order, mutex guards release, no dangling references or undefined behavior. Yours: logical integrity — partial side effects (charges, sent bytes, taken messages) are not rolled back, so use idempotency keys, outboxes, or compensate explicitly.

Question

Describe the reactor path from TcpStream::connect().await to completion.

Answer

The socket is registered with Tokio's I/O driver (mio over epoll/kqueue/IOCP); the connect future returns Pending while the handshake proceeds and the task parks. Readiness fires the task's Waker, the scheduler re-polls, and the future returns Ready(Ok(stream)). One reactor thread multiplexes every descriptor — connections cost state, not threads.

Question

Which async I/O calls are safe to race against a timeout, and which are not?

Answer

Safe: read, read_buf, write, write_buf, accept, recv, next — side effects only commit in the poll that returns Ready. Unsafe: read_exact, read_to_end, read_to_string, write_all — they consume or emit bytes before completing, so dropping them mid-flight loses progress. Also unsafe: Mutex::lock / Semaphore::acquire lose queue position.

Revision Notes

Key Takeaways

  • 1. timeout races a sleep against your future and returns Err(Elapsed) by dropping the inner future — cancellation is not interruption
  • 2. Dropping at an await runs destructors and is always memory-safe, but partial side effects are never rolled back
  • 3. Cancellations land only at await points: abort(), timeouts, and select! losers all take effect when the task is next polled
  • 4. The reactor parks tasks as Pending on readiness and wakes them through Waker, so thousands of connections need no threads
  • 5. Only cancel-safe primitives (read, write, recv, accept) may sit behind a timeout or select!; accumulating helpers lose data when dropped

Interview Tips

  • • "How do you add a timeout to an async operation?" — timeout() gives Result<T, Elapsed>; mention the nested Result, then immediately note that expiry drops (cancels) the inner future
  • • "What happens when a future is dropped mid-await?" — RAII unwind: destructors run, guards release, no UB; then show depth by separating memory safety from logical safety and naming a compensation strategy
  • • "How do you cancel a Tokio task?" — abort() for forced (takes effect at next await), a oneshot/watch/CancellationToken raced in select! for graceful; note a task with no awaits cannot be stopped
  • • "Why does async Rust scale better than thread-per-connection?" — readiness-based reactor: park on Pending, wake via Waker, connections are state machines; contrast with blocking sockets and 10k threads

Cheat Sheet

Timeouts, Cancellation, and Async I/O — Cheat Sheet

Bounding:

match timeout(Duration::from_secs(3), fut).await {
    Ok(v) => handle(v),          // future completed
    Err(_) => give_up(),         // Elapsed -> inner future dropped (cancelled)
}
let dl = Instant::now() + budget;
timeout_at(dl, fut).await;       // shared absolute deadline across sub-operations

Cancellation = dropping the future at an await:

Source Effect
timeout elapsed inner future dropped
select! loser branch future dropped on the winner's return
JoinHandle::abort task dropped at next poll/await
runtime shutdown unfinished tasks dropped
drop of JoinHandle none — task detaches

Guaranteed vs. your job:

  • Guaranteed: destructors run, guards release, no UB, no dangling state.
  • Yours: idempotency (compensate partial side effects), cancel-safe primitives, cooperative stop points for graceful shutdown.

Cancel-safe I/O (tokio docs): read, read_buf, write, write_buf, accept, recv, next — yes. read_exact, read_to_end, read_to_string, write_all, Mutex::lock, Semaphore::acquire — no.

Reactor: socket registers with the I/O driver (epoll/kqueue/IOCP) → task parks on Pending → readiness fires the Waker → re-poll completes. Connections are state, not threads; blocking libraries go to spawn_blocking.

Continue Learning