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
An async task can prevent other tasks from running, or keep a shared value borrowed while another task needs it. This exercise makes both problems visible in a log.
Your task
Repair the task body in interleaving, using the supplied executor:
pub fn interleaving(mode: String, tasks: usize, units: u32) -> Vec<usize>
Spawn tasks in index order, from 0 through tasks - 1. Each task performs
units iterations. On every iteration, append its index to the shared log,
then either await the given yield_now() in "yield" mode or call
spin(200) in "block" mode. Yield after the final append too.
The input mode is one of those two strings. Zero tasks or zero units produce
an empty log. Inputs fit in memory and stay below the executor's queue-step
limit: tasks * (units + 1) < 100000 in yield mode and tasks < 100000 in
block mode. These bounds are mathematical expressions, not instructions to
multiply unchecked input values in Rust.
| Mode | Tasks | Units | Expected log |
|---|---|---|---|
yield |
3 | 3 | [0, 1, 2, 0, 1, 2, 0, 1, 2] |
block |
3 | 3 | [0, 0, 0, 1, 1, 1, 2, 2, 2] |
| either | 3 | 0 | [] |
Keep the supplied executor and helper futures. Producing the vector directly would bypass the scheduling and borrowing behavior this exercise teaches.
Why the order changes
The executor polls one queued task at a time. A poll returns control on
either Pending or Ready. An .await only suspends the task if the
awaited future returns Pending; awaiting an already-ready future can
continue within the same poll.
The supplied yield_now() requests another poll and returns Pending on
its first poll. Its task goes to the back of the queue. On the next poll,
that yield completes and the task can append its next entry. This produces
the alternating order shown above for this executor.
In block mode, there is no suspension inside the task body. One poll runs
all its iterations and returns Ready. Other tasks in this executor wait
their turn. The current task is still working; the whole program has not
stopped. This mode demonstrates scheduling order, not a fixed amount of CPU
time. Optimizers may transform the arithmetic in spin.
In an application, long CPU work or a blocking I/O call can similarly delay other tasks assigned to the same worker. Async syntax does not move that work to another thread automatically. Use asynchronous I/O, bounded work followed by a yield, or an appropriate worker pool. A clean lint run alone does not establish that a task is responsive.
Release the borrow before yielding
The starter keeps a RefMut guard alive across the await. In yield mode with
at least two tasks and one unit each, the first task suspends while holding
that guard. The next task calls borrow_mut() on the same RefCell and
panics. A single task does not have that competing borrow.
End the guard's scope before reaching the await. For example, this helper scopes the mutable borrow to one synchronous call:
use std::cell::RefCell;
fn append(log: &RefCell<Vec<usize>>, index: usize) {
log.borrow_mut().push(index);
}
fn main() {
let log = RefCell::new(Vec::new());
append(&log, 0);
append(&log, 1);
assert_eq!(*log.borrow(), vec![0, 1]);
}
The same one-line push works directly in the task body: its temporary guard
is dropped at the semicolon. Clippy's
await_holding_refcell_ref
flags the starter, and this site's lint gate rejects it even for inputs that
would not panic.
How mutexes differ
RefCell checks borrowing at runtime; it is not a thread-synchronization
mutex. With a blocking mutex, a competing lock attempt may wait instead of
panicking. If it blocks the only executor thread while the lock owner is
suspended, that owner cannot resume to release the lock.
A std::sync::MutexGuard also makes a future non-Send when retained across
suspension. Such a future cannot use an API that requires Send, though
local-task APIs may accept it. This exercise already uses non-Send Rc
values and runs tasks locally.
Short, synchronous critical sections can use a standard mutex in async code. An async mutex allows a contended acquisition to yield; its guard is designed for use across awaits. That does not eliminate dependency cycles or the cost of holding shared access for a long time. See Tokio's mutex guidance.
A related concern: cancellation
Dropping an unfinished future releases the resources it owns, but does not automatically undo work it already performed. For example, a read operation might consume part of a message and keep those bytes only in its own buffer. Dropping that operation would discard the buffer; a replacement read could no longer reconstruct the message from the remaining bytes alone.
Cancellation safety depends on the operation and how it is used. When an operation is repeatedly cancelled and restarted, check whether partial progress survives. Keeping that progress in a longer-lived object can help, provided the next operation resumes it correctly. Check each API's documented guarantees; an async function is not automatically safe to restart.
Tokio's select! documentation
gives examples of cancellation-safe and unsafe operations. This exercise
grades interleaving and borrow lifetimes; it does not test cancellation or
require Tokio.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Rust reference solution
Sign in to track your attempts toward unlocking the reference solution.