We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Async From First Principles step 22 of 25
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:
- Throughput collapse. The lock is held for wall-clock time that has nothing to do with the critical section.
- 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.
-
!Sendfutures. AMutexGuardis notSend, 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.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.