Skip to content

← Async From First Principles step 22 of 25

Hard End-to-End

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.