22. Rust async, await and Future¶
An async function returns a future; it does not create a thread or run its body immediately. Awaiting a pending future suspends the enclosing future, while an executor decides when to poll it again. This lesson separates language syntax, standard-library contracts and runtime responsibilities.
Prerequisites and outcome¶
Complete channels, atomics and shutdown. You will observe lazy execution, Pending/Ready transitions, wake notification, borrowed versus owned captures, and destruction of a cancelled future. The fixtures need no async dependency, network connection or sensor.
Build the complete Rust async example¶
Keep edition = "2024" in Cargo.toml. Replace src/main.rs with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | |
Expected binary output:
Nine tests exercise lazy execution, empty/invalid fixtures, wake-before-wait, pending cancellation, non-Send local state and async closures. The executor is deliberately limited to one root future on the calling thread. It has no I/O driver, timer service, task queue, fairness policy or cancellation handle.
async creates a stateful future; await does not spawn¶
Calling report constructs a future holding its input borrow. The body starts when polled, and execution can suspend at an await whose child is Pending. An await whose child is immediately Ready need not suspend. The compiler manages the state needed across suspension; this is not a separate stackful operating-system thread. See async blocks.
The loop awaits one reading at a time, so this report is sequential. Writing async alone does not make three reads concurrent. A runtime or combinator can schedule multiple futures, but syntax does not select that policy. async move takes ownership of captures; moving a borrowed reference still does not extend its referent's lifetime.
? after await operates on the child's Result output. It returns an error from the enclosing async computation; the caller obtains that Result when its future completes. return inside an async block returns from that block's future, not an outer synchronous function. See await expressions.
Future, Poll and Waker form the execution contract¶
Future has an associated Output and a poll method receiving Pin<&mut Self> and Context. Ready provides the result; Pending means not yet complete. Wake requests another poll, not a guaranteed successful result. A pending operation must arrange notification when progress becomes possible, using the current task's waker. See Future.
YieldOnce stores one Boolean. Its first poll changes state, requests a wake and returns Pending; its next poll returns Ready. It demonstrates protocol transitions, not a real timer or a fair scheduler yield. Some real futures observe work started elsewhere, such as spawned tasks; the lazy async-body rule does not imply that all underlying external operations wait for this future to be polled.
Do not poll a completed future again. The trait does not promise a useful repeated result; a particular implementation may panic or otherwise reject it. That restriction is not permission for safe polling to cause undefined behaviour.
Async closures provide async callback interfaces¶
The final test uses async || to capture a mutable counter and an AsyncFnMut bound to call it twice. Each call creates a future that can borrow from the closure; the first call is awaited to completion before requesting the next mutable call. Calling an async closure still does not run its body immediately. See async closures.
AsyncFn, AsyncFnMut and AsyncFnOnce describe async-aware call capabilities. Stable callable-bound syntax, such as the test's impl AsyncFnMut() -> usize, does not imply that directly naming every associated future type or calling the trait's low-level methods is stable. The AsyncFnMut documentation marks those individual APIs separately. An ordinary closure returning an async block, || async { ... }, is not interchangeable in every borrowing design with an async closure that lends its captures.
The teaching executor avoids a lost notification¶
Wake is a safe way to construct a Waker from Arc-backed state. Our implementation sets a Boolean under a mutex and signals a condition variable. block_on clears the flag before polling, then checks it under the same mutex before sleeping. A wake during poll is therefore retained; a wake between the check and sleep is coordinated by the condition-variable wait. See Wake.
Condvar::wait_while checks its predicate after wakeups, including spurious ones. The executor releases its notification mutex before polling user code. It does not run a tight polling loop or reuse an unrelated thread-park token. These choices explain this fixture's wake-before-wait behaviour; they are not a production-runtime endorsement. See Condvar.
If a future never becomes ready and never notifies, this executor waits indefinitely. It cannot make arbitrary futures correct, enforce timeouts or drive runtime-specific I/O. Nested independent calls also do not create a shared scheduler. Use an established runtime for application-level async services rather than expanding this teaching example into one by accident.
Pin is an API boundary, not a Send requirement¶
poll needs a pinned mutable reference. We use pin! for a local owned future and Box::pin when a test must explicitly destroy the owning allocation at a chosen point. No unsafe projection is needed here. YieldOnce contains only movable fields and can be mutated through its Pin because it is Unpin; do not generalise that permission to every generated async future.
Pin and Unpin concern movement under a pinning contract, not thread safety. Our block_on has no Send or 'static bound: it stays on the calling thread and accepts local borrows and Rc state. A multi-threaded runtime's spawn API often imposes Send + 'static, but those bounds are not required by Future itself. The next lesson develops pinning and projection carefully.
Cancellation drops state, not history¶
The cancellation test polls once. A local guard is constructed and a visible effect changes to 1, then execution suspends. Dropping the owning future destroys that guard; code after await never runs, and the previous effect is not rolled back. Dropping an unpolled async body skips its body, although captured owned values still have their ordinary destruction rules.
An async operation may already have sent a message, written bytes or started external work. Dropping its future is not a universal rollback or a guarantee that separately spawned work stops. Some runtime handles detach tasks when dropped. Define cancellation safety for each operation and protocol; do not equate a future's local Drop with successful external cleanup. Destructors cannot simply await asynchronous finalization.
Blocking code still blocks an executor thread¶
std::sleep, blocking recv, file reads and long CPU loops remain blocking work when placed inside an async body or poll. await does not transform them into nonblocking operations. Production runtimes provide their own I/O, timer and blocking-work facilities; those are library APIs, not Rust language features.
Holding a standard mutex guard across suspension can also obstruct progress and affect whether a future is Send. End unnecessary guards before awaiting, and choose synchronization according to the runtime's requirements. Async is useful for suitable concurrency, not an automatic performance improvement for this tiny parser.
Deliberately failing: await outside an async context¶
In a separate scratch project, replace src/main.rs with:
cargo check reports E0728. await belongs in an async context. Making main async without a runtime entry point is not an ordinary executable repair; put the await inside an async computation and drive it with an executor, as in the working program.
Exercises and troubleshooting¶
- Wrap the scratch await in an async block and execute it with this lesson's block_on. Expect 46700. Explain which code creates the future and which code polls it.
- Add another invalid input after a valid one: report returns Err rather than a partial successful report. State whether retaining partial progress is a desired alternate API before changing error policy.
- Remove the wake request from YieldOnce in a scratch test using CountWake. The first poll is still Pending but the observed notification count becomes zero. Do not execute the broken future with block_on: it has no reason to be repolled and can hang.
- Explain the cancellation test's retained effect. Move the effect after await and predict that cancellation before resumption leaves it untouched; compare with successful completion.
- Attempt to send an async move future owning Rc<Cell<_>> to a thread by requiring its type to be Send. Expect a diagnostic that the future cannot be sent between threads safely. async move changes ownership, not the captured type's thread capability.
- Construct a future borrowing a local String inside a block, then try to drive it outside that block. Expect E0597. Either drive it while the owner is alive or deliberately move owned data into the future.
- Contrast dropping Box::pin(future) with dropping a temporary Pin<&mut Future>. Only the former owns and destroys that future; a borrowed pin wrapper is not a cancellation owner.
Verification and next step¶
On October 10, 2026, this lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, nine debug/release tests, formatting, debug/release outputs and five further output comparisons passed, including the lending async closure and AsyncFnMut bound. The async-block repair and effect-after-await variant passed, including an additional successful-completion test. Await outside async and an escaping borrow produced E0728 and E0597; requiring Send rejected the Rc-capturing future. The notification-omission variant compiled but failed its single-poll notification assertion without running a hanging executor. These checks cover the fixtures, not production I/O, scheduler fairness, every wakeup interleaving or performance.
Continue with Pin, Unpin and async boundaries: distinguish a movable owning handle from its pinned pointee and design a safe future wrapper.