Lesson 07 / 19
Asynchronous Functions
A measurement showing that when two operations that can suspend are awaited in sequence, the remaining stock depends on the order of reservation, while a read operation does not see the order.
Published Updated
In this lesson
- Distinguishes an asynchronous function as one that waits at a suspension point and returns its result later
- Actually measures that two reservation operations leave different stock when run in two orders
- Verifies that a read operation that does not change state gives the same result in both orders
- States that the lesson, not the runtime, sets the order in the measurement, and that interleaving is not seen
Contents
In the previous lesson, Background Transfers, we saw that when the app reopened and the download list was requeued without being checked, the same file was downloaded twice. The opening measurement of the networking layer had distributed six operations over four classes: one robust, two repeat-sensitive, two order-sensitive, one doubly sensitive. This topic carries the same question into concurrent code: if two operations are repeated or reordered, does the result change? The order is no longer only the order the programmer wrote; while one operation waits, another piece of work can run.
The measurement uses the core file. This file is the same in every lesson of the topic.
// Replay.swift — classifies an operation's sensitivity to repetition and order
struct Trial<State: Equatable & CustomStringConvertible> {
let label: String
let once: State
let twice: State
let forward: State
let backward: State
var sensitivity: String {
switch (once != twice, forward != backward) {
case (false, false): return "robust"
case (true, false): return "repeat-sensitive"
case (false, true): return "order-sensitive"
case (true, true): return "doubly-sensitive"
}
}
func report() -> String {
print("\(label) | once=\(once) twice=\(twice) ab=\(forward) ba=\(backward) -> \(sensitivity)")
return sensitivity
}
}
func replay<State>(_ label: String, from start: State, _ a: (State) -> State, then b: (State) -> State) -> Trial<State> {
Trial(label: label, once: a(start), twice: a(a(start)), forward: b(a(start)), backward: a(b(start)))
}
func countSensitivities(_ results: [String]) {
for name in ["robust", "repeat-sensitive", "order-sensitive", "doubly-sensitive"] {
print("\(name): \(results.filter { $0 == name }.count)")
}
}
The core runs an operation four times: once, twice, a then b, and b then a. A difference between once and twice shows repeat sensitivity; a difference between the two orders shows order sensitivity. replay is not used for an asynchronous operation, because the functions passed to it cannot await. The four runs are built by hand with await, and the results go straight into Trial. The model builds what the operation does to the state; it is not the network or the runtime itself.
A Function That Can Suspend
An asynchronous function (an async function) is a function that, while running, accepts that it may stop at some point and wait for a result. The caller calls it with await. Every place where await is written is a suspension point: the function may stop there, give up control, and continue from where it left off once the result is ready. While it is suspended, the thread is not blocked; the system can run other work in the meantime.
On the SOURCE side, a suspension point is typically on the network. The fragment below is not measured, because a real request goes out to the network.
import Foundation
func fetchStock(for sku: String) async throws -> Int {
let url = URL(string: "https://example.com/stock/\(sku)")!
let (data, _) = try await URLSession.shared.data(from: url)
return Int(String(decoding: data, as: UTF8.self)) ?? 0
}
The single await in the fragment waits for the result of the request; try says that an error can propagate to the caller. The measured layer does not build this call. Instead it looks at what happens to the state: one shelf, two reservation requests, and the rule that no reservation is made when stock is insufficient.
// Stock.swift — shelf stock: no reservation is made when stock is insufficient
actor StockShelf {
private(set) var quantity: Int
private(set) var accepted: [Int] = []
init(_ quantity: Int) { self.quantity = quantity }
func reserve(_ amount: Int) -> Int {
if quantity >= amount {
quantity -= amount
accepted.append(amount)
}
return quantity
}
func remaining() -> Int { quantity }
}
func runReserve(_ amounts: [Int]) async -> Int {
let shelf = StockShelf(10)
var left = 10
for amount in amounts { left = await shelf.reserve(amount) }
return left
}
func runReads(_ times: Int) async -> Int {
let shelf = StockShelf(10)
var seen = 0
for _ in 0..<times { seen = await shelf.remaining() }
return seen
}
StockShelf is an actor; actors are the subject of later lessons, and here one is used only as a state holder that can suspend. The shelf holds ten units. runReserve starts from a fresh shelf on every call and reserves the given amounts in sequence, awaiting each one. The number it returns is the stock left on the shelf.
The Call Chain
await can be written only in an async context. A function that calls an async function must itself be async; this chain extends upward and at the very top meets ordinary, synchronous code. The bridge there is a Task: it starts asynchronous work from a synchronous context and does not wait itself. A button’s tap handler is the typical example.
// Crossing from a synchronous context to asynchronous work: a tap handler
func refreshTapped() {
Task {
do {
let left = try await fetchStock(for: "sku-1")
print("remaining: \(left)")
} catch {
print("error: \(error)")
}
}
}
The Task body returns immediately; refreshTapped does not wait for the result. So two taps start two independent pieces of work, and both can reach the same shelf. This is why the two orders of the measurement are real: the person writing the function does not know which request will reach the server first or which response will return first. The propagation of the error is also cut off here. Nobody sees an error that is not caught inside a Task, because nobody is waiting for the task’s result. This is why error handling has to be set up inside the task.
Reservation Order
Two requests arrive at the same shelf: six units and five units. Together they come to more than ten units, so the two do not fit. Which one is reserved first determines the result. Four runs are built separately: six only, six twice, six then five, five then six.
// main.swift — two reservations in two orders, one read let single = await runReserve([6]) let repeated = await runReserve([6, 6]) let sixThenFive = await runReserve([6, 5]) let fiveThenSix = await runReserve([5, 6]) let reservation = Trial(label: "stock-reservation", once: single, twice: repeated, forward: sixThenFive, backward: fiveThenSix).report() let readOnce = await runReads(1) let readTwice = await runReads(2) let reading = Trial(label: "stock-read", once: readOnce, twice: readTwice, forward: readTwice, backward: readTwice).report() countSensitivities([reservation, reading])
stock-reservation | once=4 twice=4 ab=4 ba=5 -> order-sensitive stock-read | once=10 twice=10 ab=10 ba=10 -> robust robust: 1 repeat-sensitive: 0 order-sensitive: 1 doubly-sensitive: 0
In the reservation line, once=4: six units were reserved and four remained. twice=4: the second six did not fit and was rejected, so repetition did not change the stock. ab=4 and ba=5: when six was reserved first, five did not fit and four remained; when five was reserved first, six did not fit and five remained. The two orders left two different stocks, so the operation is order-sensitive. In the read line, all four values are ten: because a read does not touch the state, neither repetition nor order changes anything, and the operation is robust. The count is one robust, one order-sensitive.
CC1. This measurement assumes that no other work comes between the two awaits. Each run starts from its own shelf and the requests are processed in the order they are written; whether the runtime would change the order was not measured.
Separate Shelves
Order sensitivity is a property not only of the operation but of the state the operations share. To see this, we send the same two requests, six and five units, to two separate shelves this time. The pair gives which shelf each request goes to.
// main.swift — the same two requests, two separate shelves
func runShelves(_ requests: [(shelf: Int, amount: Int)]) async -> [Int] {
let shelves = [StockShelf(10), StockShelf(10)]
for request in requests { _ = await shelves[request.shelf].reserve(request.amount) }
let firstLeft = await shelves[0].remaining()
let secondLeft = await shelves[1].remaining()
return [firstLeft, secondLeft]
}
let apartOnce = await runShelves([(0, 6)])
let apartTwice = await runShelves([(0, 6), (0, 6)])
let apartForward = await runShelves([(0, 6), (1, 5)])
let apartBackward = await runShelves([(1, 5), (0, 6)])
let apart = Trial(label: "separate-shelves", once: apartOnce, twice: apartTwice, forward: apartForward, backward: apartBackward).report()
countSensitivities([apart])
separate-shelves | once=[4, 10] twice=[4, 10] ab=[4, 5] ba=[4, 5] -> robust robust: 1 repeat-sensitive: 0 order-sensitive: 0 doubly-sensitive: 0
The second six is again rejected because it does not fit on the first shelf, and once and twice stay the same. ab and ba leave the same two shelves in the same state, because neither request changes something the other would see. The operation is robust. When the same six-and-five pair went to a single shelf it was order-sensitive; the difference is not in the requests but in whether they touch a shared state. In practice this tells you where to pay the cost of fixing the order: only for state that is truly shared. The count is one robust.
CC2. The separate shelves were treated as completely independent. If there is a common warehouse or a total stock limit behind the two shelves, the separation would be misleading; no such common limit was measured.
What Happens at a Suspension Point
What the measurement does not see happens inside await. When a function is suspended, it is possible for another piece of work to reach the same actor or the same data. The work that ran first may have changed the state that the work running later sees; code that reads state before an await and uses it after the await needs care for this reason. In which order this interleaving happens depends on the system’s scheduling, and it does not have to be the same in every run. This lesson does not run it: two sequential calls, with no interleaving, make a deterministic experiment.
The practical rule is this: code that reads state and decides on it should not leave an await between the decision and the write. Otherwise the decision may no longer be true by the time it is written. In the shelf example, the reserve function does the check and the write in the same step and there is no await in between; so it is consistent on its own. Being consistent does not mean that the order of the two requests does not affect the result; the measurement showed exactly that.
The Gap Between Decision and Write
A setup that is not measured but is often seen is doing the check and the write with two separate awaits. The function below first asks how many units are left on the shelf, then asks for the reservation.
// Wrong setup: there is an await between the decision and the write
func reserveCarelessly(_ amount: Int, on shelf: StockShelf) async -> Bool {
let available = await shelf.remaining()
guard available >= amount else { return false }
_ = await shelf.reserve(amount)
return true
}
Between the two awaits the shelf may have been changed by another request. Then available is no longer true, reserve silently rejects the request, and the function still returns true. The caller thinks the reservation was made, yet the stock never dropped. The fix is to gather the decision and the write in a single routine of the actor and return the result from that routine.
// Routine added to StockShelf: decision and write in one step
func tryReserve(_ amount: Int) -> Bool {
guard quantity >= amount else { return false }
quantity -= amount
accepted.append(amount)
return true
}
The routine is not async and has no await inside; the actor finishes it without splitting it into another piece of work. The returned value says that the reservation was actually made. In sequential runs the two setups give the same result, so the difference does not show in the measurement; it shows only when interleaving occurs. Reading an unmeasured difference from the shape of the code is important for this reason.
In a Real Application
This pattern is met constantly in cart and booking screens. The user taps the “reserve” button twice, two screens look at the same product, or a refresh coming from the server races with a reservation the user has just made. In each case the operations themselves are correct; the problem is which one finishes first. The order in which network responses return does not have to match the order of the requests, and asynchronous code puts nothing on top of that order.
So two questions are always asked together. What happens if the operation is repeated? What happens if two operations swap places? The answer to the first gives repeat sensitivity, and the answer to the second gives order sensitivity. Operations that do not change state, such as a read, are immune to both; this is why most screens share reads freely and gather writes in a single place.
Common Mistakes
- Assuming that writing
awaitguarantees the order of the operation.awaitonly says to call and wait; it does not determine the order of other calls. - Counting a read and a write as a single step. If there is a suspension point between them, the state may have changed in the meantime.
- Forgetting error propagation. An
asyncfunction markedthrowsrequirestrynext toawait; iftryis omitted, the code does not compile. - Taking the order of the measurement as the real order. The four runs are two orders built by hand; they are not the order the runtime chose.
The Bounding Measurement
The rule compares whatever is in the state. A rule that looks only at the remaining stock does not see which requests were accepted. Below, six and four units are reserved; both fit and empty the shelf completely.
// main.swift — the same two requests, two different state choices
func runLedger(_ amounts: [Int]) async -> [Int] {
let shelf = StockShelf(10)
for amount in amounts { _ = await shelf.reserve(amount) }
return await shelf.accepted
}
let leftSingle = await runReserve([6])
let leftRepeated = await runReserve([6, 6])
let leftForward = await runReserve([6, 4])
let leftBackward = await runReserve([4, 6])
let leftover = Trial(label: "remaining-stock", once: leftSingle, twice: leftRepeated, forward: leftForward, backward: leftBackward).report()
let ledgerSingle = await runLedger([6])
let ledgerRepeated = await runLedger([6, 6])
let ledgerForward = await runLedger([6, 4])
let ledgerBackward = await runLedger([4, 6])
let ledger = Trial(label: "acceptance-ledger", once: ledgerSingle, twice: ledgerRepeated, forward: ledgerForward, backward: ledgerBackward).report()
countSensitivities([leftover, ledger])
remaining-stock | once=4 twice=4 ab=0 ba=0 -> robust acceptance-ledger | once=[6] twice=[6] ab=[6, 4] ba=[4, 6] -> order-sensitive robust: 1 repeat-sensitive: 0 order-sensitive: 1 doubly-sensitive: 0
The remaining stock is zero in both orders; the rule sees no difference and the operation comes out robust. The acceptance ledger, however, holds [6, 4] and [4, 6] in the two orders; the same two requests are this time order-sensitive. Both requests were the same; what changed is the state being compared. The count is one robust, one order-sensitive.
CC3. This measurement counts the order of the acceptance ledger as part of the state. Whether the order carries meaning of its own, for example whether an invoice has a line order, was not measured.
Reading Order and Expectation
When reading the measurements in this lesson, it is enough to keep a single question in mind: if the same request runs twice, or two requests run in two orders, does the final state change? In the shelf example, requests made to different shelves do not affect each other, so order and repetition do not matter. Two requests coming to the same shelf change a shared state, and the result depends on the order of the calls. An asynchronous function does not remove this distinction; it only lengthens the interval in which two calls can get mixed up. Every await is a point where the function can be suspended and other work can come in between, and in code that touches shared state, knowing where these points are is the shortest way to predict in advance the sensitivity the measurement shows.
Summary
- An asynchronous function can wait at a suspension point; the caller calls it with
await. - Two reservations that make no reservation on insufficient stock left different stock in two orders; the operation is order-sensitive.
- A read operation that does not change state gave the same result in both orders and came out robust.
- The rule sees only what is in the state: the remaining stock came out robust, the acceptance ledger order-sensitive.
- Interleaving was not measured; the order is the order the lesson built.
- This lesson’s sensitivity: order-sensitive.
Next Step
If a function can wait while other work runs, there needs to be a way to start several pieces of work together. In the next lesson, it will be measured that the total stays robust while the results of a task group are collected, and that arranging the results in arrival order is order-sensitive.
To keep your progress and take notes, Log in
My notes
Log in to take notes.