Skip to content

← Async From First Principles step 22 of 25

Hard End-to-End

Cancel safety, blocking, and locks across `.await`

Three async-specific hazards share one root cause: something is being held across a suspension point. Two of them you can see in this problem’s output; the third you can only reason about, and it is the one that reaches production.

pub fn interleaving(mode: String, tasks: usize, units: u32) -> Vec<usize>

Spawn tasks tasks on your executor. Each performs units units of work, appending its own index to a shared log per unit. In "yield" mode it awaits yield_now() between units; in "block" mode it runs a bounded arithmetic loop instead.

interleaving("yield", 3, 3) == [0, 1, 2, 0, 1, 2, 0, 1, 2]
interleaving("block", 3, 3) == [0, 0, 0, 1, 1, 1, 2, 2, 2]

Blocking, in one line of output

The second row is the whole lesson. Control returns to the executor only when a future returns Pending. A task that does not await holds its worker thread for as long as it runs. On a single-threaded executor that means the entire program stops — not slows down, stops — and every other task waits.

Nothing about “block” mode is exotic: it is a for loop doing arithmetic. In real code it is std::thread::sleep, a synchronous File::read, a Mutex::lock that contends, JSON parsing of a 40 MB body, or bcrypt. All of them look innocent and all of them produce that second row.

Verified: no default clippy lint catches this. Put a thread::sleep in an async fn and the tooling says nothing at all. There is no borrow checker for latency. The only way to see it is to measure, or to make the interleaving visible the way this problem does.

The standard remedies do not exist in std. tokio has spawn_blocking to move the work to a dedicated pool, and tokio::task::yield_now() to punctuate a CPU loop. Without a runtime crate, your options are: do not do it, or do it on a thread you spawned yourself.

Locks across .await, which clippy does catch

The starter takes the log’s borrow_mut() and holds it across the await:

let mut entries = log.borrow_mut();
entries.push(i);
yield_now().await;          // <-- the guard is still alive

Two things happen. clippy refuses it with clippy::await_holding_refcell_ref — “this RefCell reference is held across an await point” — and at runtime the next task’s borrow_mut() panics with “already borrowed”. A rare treat: the linter is the curriculum, and the failure is loud.

The reason is item 16.12’s picture. A local that is live across an .await is stored inside the future’s state machine. A MutexGuard or a RefMut held across a suspension point is therefore held for the entire suspension, which may be milliseconds, or forever. Three consequences:

  1. Throughput collapse. The lock is held for wall-clock time that has nothing to do with the critical section.
  2. Deadlock. If the awaited work needs the same lock — directly or three layers down — nothing can ever proceed. On a single-threaded executor this is guaranteed rather than probabilistic.
  3. !Send futures. A MutexGuard is not Send, so a future that stores one cannot be handed to a multi-threaded executor at all. That is where the famous “future cannot be sent between threads safely” error comes from, and the guard is usually invisible in the source.

clippy::await_holding_lock is the Mutex version of the same lint, and await_holding_invalid_type lets a crate mark its own guard types. Historically this family has reported guards that were explicitly dropped before the await; if you meet that, a scope block ({ let g = ..; ..; }) is the uncontroversial fix. And note the gap this exposes: std has no async-aware mutex. Runtimes ship their own precisely because the std one is the wrong tool across a suspension point.

Cancel safety — the one you cannot see here

Item 16.15 dropped the loser of a race and nothing bad happened, because the loser held nothing. Now suppose it did.

A read_line-style future that has consumed bytes from a socket into a buffer it owns is dropped by a losing select! branch. Those bytes are gone: they have left the kernel, they are not in the socket, and the buffer they were in has just been freed. The next read starts mid-message. The connection is silently corrupt, under load, occasionally.

A future is cancel safe if dropping it before completion loses no data and leaves no torn state. The remedy is structural: keep partial progress in a longer-lived object the future merely borrows, so dropping the future discards nothing. That is why tokio’s channel recv() is documented as cancel safe — the queue outlives the future — and why many hand-written futures are not.

There is a genuine disagreement here, and it is worth knowing that it exists. One camp holds that cancel safety is an unforced wart that makes select! a footgun: an invisible property, unenforced by types, that turns a routine refactor into a data-loss bug. The other holds that implicit, allocation-free, synchronous cancellation is one of async Rust’s real advantages over every runtime that needs cooperative tokens. Both are arguing from the same mechanism.

The practical advice is not “always use cancel-safe futures” — that is not a property you can always have. It is: know which of your futures are cancel safe, and document it, exactly as tokio does per method. A faithful demonstration needs buffered I/O and a real select!, which this harness cannot provide, so this half stays prose. The mechanism, though, you have already built by hand.