Crabrix blog
Rust futures: bounded work, wakeups and cooperative cancellation
Build a small, runnable future driver to understand polling, wakeups, fairness and cancellation. Learn why async code can still freeze an interface.
· 14 min read
Crabrix blog
Build a small, runnable future driver to understand polling, wakeups, fairness and cancellation. Learn why async code can still freeze an interface.
· 14 min read
A button starts some work, a spinner appears, and then scrolling stops. Adding async to the function often feels like the obvious repair. Yet an asynchronous function can spend several seconds parsing a large document before it reaches a meaningful suspension point. During those seconds, its caller has gained no opportunity to run something else on the same execution thread. The syntax describes a computation that can suspend; it does not make each instruction independently scheduled.
This article investigates that boundary with a small Rust program that needs no downloaded crates. It performs an intentionally simple sum in bounded pieces and lets a caller decide how many polls one turn may consume. The result is a teaching instrument for reasoning about responsiveness. It is deliberately missing networking, operating-system event registration, timers, and a production task scheduler. Those omissions keep the experiment small enough to inspect rather than hiding scheduling decisions behind a runtime configuration.

The standard Future interface exposes a poll method that receives a pinned mutable reference and a task context. Its result says either that a value is ready or that more progress must wait. The context provides a waker through which the computation can request another opportunity to be polled. These are protocol pieces, not a promise that the operating system has created a thread for each future.
An executor decides which task to poll next. A runtime may also provide services that arrange wakeups when a socket, clock or other resource changes state. A thread is an execution resource on which that machinery runs. Keep the names separate when investigating a freeze: an application may have hundreds of futures, one executor thread, and one particularly expensive poll that prevents all the others from being considered. Counting futures therefore tells you little about available parallel execution.
Our workload adds consecutive integers. A real application might scan records, tokenize source, transform image tiles or inspect dependency metadata. In each case, begin by identifying a piece of work that can end in a valid intermediate state. If stopping after a piece leaves half a record installed in shared state, the boundary is poorly chosen even if its elapsed time looks attractive.
The demonstration processes at most 128 integers per poll. That number is an experimental work quota, not a timing guarantee. On one machine the quota may take microseconds; a more expensive operation with the same iteration count could take much longer. Start with a logical unit because it is easy to test, then measure elapsed duration on the devices that matter. If individual items vary dramatically in cost, introduce a time-aware outer budget or smaller internal operations rather than assuming every item costs the same.
A useful driver needs more vocabulary than success and failure. One outcome means that the future produced its value. Another means it returned pending without asking for an immediate new poll: the caller should stop and let an external event make progress possible. The third means that the caller used its poll budget while the computation still had immediately schedulable work. These states require different decisions even though two of them contain no final value.
The Turn enum preserves that distinction. Stalled does not mean that the future has failed, and BudgetUsed does not mean that work should be discarded. Both leave the future alive in the caller. The caller can arrange another bounded turn later, while still servicing input or other work. Keeping ownership with the caller also makes cancellation concrete: the caller may drop the future rather than leaving an invisible background computation running without an owner.

Save this program as main.rs and run it with a Rust toolchain supporting edition 2021. It uses only the standard library. The first assertion spends one poll on a thousand-item sum and expects to use the turn budget. The next call resumes the same pinned value and completes it. The pending future demonstrates a separate stalled outcome, so the example cannot accidentally pass by spinning until every possible workload becomes ready.
The example uses an atomic notice because the safe Wake abstraction permits wake handles to cross threads. The demonstration itself creates no worker thread. Release and acquire operations provide an explicit signal boundary; they do not turn this helper into an event loop. In particular, a wake arriving after Stalled is returned needs an enclosing scheduler to arrange a later call. That scheduler is outside the example, and replacing it with a tight retry loop would defeat the purpose of the exercise.
use std::{future::Future, pin::Pin, sync::{Arc, atomic::{AtomicBool, Ordering}}, task::{Context, Poll, Wake, Waker}};
#[derive(Default)]
struct Notice(AtomicBool);
impl Wake for Notice {
fn wake(self: Arc<Self>) { self.0.store(true, Ordering::Release); }
fn wake_by_ref(self: &Arc<Self>) { self.0.store(true, Ordering::Release); }
}
#[derive(Debug, PartialEq)]
enum Turn<T> { Ready(T), Stalled, BudgetUsed }
// A bounded demonstration driver, not a runtime or an I/O event loop.
fn drive<F: Future>(mut future: Pin<&mut F>, polls: usize) -> Turn<F::Output> {
let notice = Arc::new(Notice::default());
let waker = Waker::from(notice.clone());
let mut cx = Context::from_waker(&waker);
for _ in 0..polls {
notice.0.store(false, Ordering::Release);
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return Turn::Ready(value),
Poll::Pending if !notice.0.load(Ordering::Acquire) => return Turn::Stalled,
Poll::Pending => {}
}
}
Turn::BudgetUsed
}
struct Sum { next: u32, limit: u32, total: u64 }
impl Future for Sum {
type Output = u64;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u64> {
for _ in 0..128 {
if self.next == self.limit { return Poll::Ready(self.total); }
self.total += u64::from(self.next);
self.next += 1;
}
if self.next == self.limit { Poll::Ready(self.total) }
else { cx.waker().wake_by_ref(); Poll::Pending }
}
}
fn main() {
let mut sum = std::pin::pin!(Sum { next: 0, limit: 1_000, total: 0 });
assert_eq!(drive(sum.as_mut(), 1), Turn::BudgetUsed);
assert_eq!(drive(sum.as_mut(), 16), Turn::Ready(499_500));
let mut pending = std::pin::pin!(std::future::pending::<()>());
assert_eq!(drive(pending.as_mut(), 4), Turn::Stalled);
println!("bounded turns: ok");
}The Sum value stores three facts: where the next piece starts, where the workload ends, and the total accumulated so far. A poll changes those facts only while processing a complete integer. It returns ready immediately when the final boundary is reached. Otherwise it requests another turn and returns pending. There is no hidden stack of partially completed loop iterations that the caller has to reconstruct.
The integer choices are intentional. The cursor and limit use u32; widening the cursor before addition allows the largest supported sum to fit in u64. The loop increments the cursor only while it is below the limit, so reaching the maximum possible limit does not require incrementing beyond that value. Such boundary reasoning matters more than the particular arithmetic example. A cooperative parser or importer likewise needs an invariant that remains true after every return to its caller.
The Notice object records whether the polled computation asked to run again. It never calls poll recursively from inside wake. Recursive polling could reenter a computation while its earlier poll still owns mutable access, and it would make the caller's budget meaningless. The driver instead observes the notice after the current poll returns, then decides whether its own quota permits another poll.
That separation becomes more valuable as a scheduler grows. Several wakeups may be combined into one ready-queue entry, and a task may be polled after a different task has already changed shared state. Code should inspect the actual resource again when polled rather than treating a wakeup as proof that the operation must now succeed. A well-designed waiting future stores or updates the relevant waker and rechecks readiness in a way that avoids losing a notification during registration.
Sources: Rust standard library: Waker and scheduling notifications
Consider a loop that transforms a million records and awaits a helper after each record. If that helper is always ready, the outer future can continue through the whole loop inside one poll. The source contains many .await expressions, but none necessarily returns pending to the executor. Counting await points is therefore a weak way to assess fairness. The useful observation is how long a poll actually holds control.
Make a deliberately expensive record in a test fixture and observe whether input handling continues while it is processed. If the transform is fundamentally CPU-bound, moving it to an appropriate worker may be the correct design on platforms that support that execution model. If it must remain cooperative, break it into valid resumable pieces. Either approach requires an actual scheduling boundary. Decorating an existing synchronous routine with an async wrapper changes its type without changing the cost of the synchronous work inside it.
Dropping an ordinary future stops future polls of that value. It does not automatically undo a file already written or retract a request already sent by some external component. In our sum, cancellation simply discards a private cursor and accumulator. An importer that has already changed a project's files needs a stronger protocol: write into a temporary area, validate the complete result, then commit it in one controlled operation.
A cancellation flag is useful when a computation cannot be dropped immediately because it is executing elsewhere. Check that flag at boundaries that leave state valid, then report cancellation distinctly from a compiler or data error. Avoid making cancellation depend on the user producing more input. If a task is blocked waiting for a resource, the mechanism that owns that wait should be able to wake or interrupt it when cancellation occurs. Otherwise the interface can show Stop while the underlying operation remains alive indefinitely.
A responsive application usually has a narrow place where visible state is changed. Heavy work reports small events to that place rather than running its parser, decompressor or compiler there. For a Rust service this might be a channel into a state owner. For a native application it may be a platform-specific interface executor. The architectural principle is the same: move facts about progress across the boundary, not the expensive operation itself.
Design those events so they can be safely ignored after the user switches projects. Include an operation identity and the relevant project revision, then discard late events that belong to an older operation. Otherwise a correctly scheduled background task can still corrupt the visible story by announcing completion for a project that is no longer open. Responsiveness and result ownership are separate requirements, and both deserve tests with deliberately slow and out-of-order fixtures.
A worker can avoid blocking the interface and still overwhelm it by reporting every byte or instruction. A thousand queued interface updates per second can consume more visible time than the original task. Prefer meaningful stage changes, aggregate counts and throttled measurements. A dependency download might report archive acquisition, checksum verification, extraction and readiness. A compiler might report package compilation, project compilation and program execution.
Keep a bounded history for the current operation, and separate that history from the program's own output. Users should be able to find a build error in the diagnostics view while still seeing which earlier stages completed. Cached work should be labeled as cached rather than presented as a fresh compilation. These choices make the event stream useful for debugging and for explaining speed improvements; they also prevent a large project's history from growing without limit during a long editing session.
The happy-path sum checks only part of the contract. Add a future that wakes itself twice before returning pending and verify that the driver remains within its poll quota. Add one that returns pending without a wakeup and verify that the driver returns promptly. Try a zero poll budget: it should consume no work and report that the turn allowance has ended. None of these tests needs a real network connection or wall-clock sleep.
Do not poll an ordinary future again after it has returned ready unless that future explicitly documents support for doing so. Our driver returns the completed value to its owner; the owner should retire or replace the task. If an application needs a persistent operation that produces many values, model that behavior explicitly as repeated requests or a stream-like interface. Treating a completed single-result future as a reusable service leaves its post-completion behavior unspecified and makes failures difficult to reproduce.
Suppose a background parser can produce progress records much faster than the interface can display them. An unbounded queue initially looks convenient because the producer never waits. Over a long operation, however, the queue becomes a second representation of the workload, holding strings, timestamps and state snapshots that may already be obsolete. The original computation can finish while the user is still watching the interface replay its old progress.
Use different delivery rules for different kinds of information. A latest-value slot is often enough for a percentage or current-package label. A bounded queue can preserve significant transitions such as a failed checksum or completed compilation. Program output may require a byte limit and an explicit truncation or stop policy because silently dropping arbitrary lines can alter what an exercise appears to have printed. One queue policy does not serve all three purposes.
In a test, make the consumer intentionally slow and observe memory use as well as completion time. Confirm that an essential terminal event still arrives and that obsolete intermediate progress does not delay it indefinitely. A producer that respects backpressure is cooperating with the whole application, rather than simply yielding within its own internal loop.
A caller may set a deadline before polling a future, but ordinary Rust code does not become preemptible merely because a deadline exists. If a poll enters a long synchronous routine that never returns, the caller cannot inspect its clock again on that same thread. Checking elapsed time between polls is therefore useful only after the workload has acceptable internal boundaries. A timeout wrapper cannot manufacture those boundaries inside arbitrary CPU work.
For an interpreter, an instruction or fuel counter can provide an additional observation point during guest execution. For a decompressor, cancellation may be checked between bounded input chunks. For a filesystem import, check between entries and before committing the completed project. Each mechanism belongs close enough to the expensive operation that it can actually interrupt or stop further work, while leaving a valid result or a clearly discarded temporary state.
Test the deadline with an adversarial workload, not only a slow network response. A computation that repeatedly makes progress but never completes is a better way to discover whether the timeout is enforced inside the relevant loop. Report the stopping reason separately from an ordinary guest-program error so users can decide whether to reduce input, change a resource budget, or fix their code.
A quota of one hundred items sounds fair until one item takes a thousand times longer than the rest. Imagine a source browser where most files are tiny but one generated module is several megabytes. Yielding after every file still permits the generated module to cause a long pause. The item-count quota is useful bookkeeping, but the expensive item needs its own smaller boundary or a different execution resource.
This is also why progress by package count can be misleading. Finishing nine small dependencies does not imply that a tenth large dependency will take the same fraction of time. Show the actual current stage and package name, then treat the count as completed units rather than an estimated time remaining. A precise count with a modest claim is more useful than a smooth percentage that suggests knowledge the application does not have.
When a task repeatedly dominates the schedule, profile that task before changing the global scheduler. It may be repeatedly allocating a large buffer, reparsing identical data, or hashing the same verified artifact. Removing that duplicated work can improve both throughput and the longest visible turn, whereas simply increasing a quota may improve one number by making the interface less responsive.
Total completion time and responsiveness answer different questions. A transform can finish sooner overall while causing a worse pause in the middle. Record the longest poll or uninterrupted chunk alongside total duration and output correctness. Test small ordinary inputs, a large valid input, a malformed input discovered late, and cancellation during the expensive part. A progress spinner that updates only before and after the operation does not establish that intermediate interaction remained possible.
On a phone, repeat the experiment after a cold launch and while the device is doing ordinary background work. Compare the same input and build configuration, and report whether dependency artifacts or parsed modules were already cached. A faster warm run does not prove a faster cold compiler. Likewise, a shorter turn obtained by doing less validation is not an improvement unless the validation contract was deliberately changed and the resulting behavior remains acceptable.
The program is useful precisely because it refuses to be a complete runtime. It lets you point to the moment a computation yields, the signal that permits another poll, and the budget that returns control to the caller. Once those decisions are visible, moving to an established runtime becomes easier to reason about: identify which parts it supplies, which work remains synchronous, and which resources the target environment actually offers.
For an offline learning application, start with small resumable computations and standard-library examples, then add runtime-specific exercises with explicit requirements. For a server, use the runtime's documented task, I/O and cancellation facilities and measure blocking work separately. In both cases, keep the central question concrete: after this piece of work begins, what allows the rest of the application to run again, and what evidence shows that the opportunity arrives soon enough?