Lesson 10 / 19
Main Thread Isolation
A measurement showing that the operation updating the list the interface shows depends on arrival order without a version number and becomes robust with one.
Published Updated
In this lesson
- Explains that interface state is tied to the main thread and the main actor
- Measures that an unversioned list update is sensitive to arrival order
- Measures that the same update becomes robust with a version check
- Shows that who hands out the version number changes the class
Contents
In the previous lesson, actor isolation prevented a data race but did not fix the order of two requests. The list a screen shows is also shared state of this kind: new requests go out as the user types in the search box, and the responses are written to the screen in whatever order they arrive. In this lesson we measure this situation.
The core 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)")
}
}
In this lesson the operations only write a list to the state and are not asynchronous; so they are measured with replay. The model builds what the operation does to the state; it is not the network or the screen itself. Which thread runs which piece of work is not measured, because the measurement cannot see it.
Main Thread and Main Actor
The interface is updated only on the main thread. In concurrent code this rule is expressed with the main actor (@MainActor): the state of a type tied to the main actor is reached only from the main actor, and the compiler checks this. When a piece of work in the background wants to write its result to the screen, it moves to the main actor with await.
This protects the consistency of the interface but does not solve the race. If two search requests are running in the background, the results of both eventually arrive at the main actor, and whichever arrives later is the one that stays on screen. The order of responses can differ from the order of requests.
// Shown.swift — the list shown on screen and two ways of updating it
struct ShownList: Equatable, CustomStringConvertible {
var version: Int
var items: [String]
var description: String { "v\(version) \(items)" }
func overwriting(items newItems: [String]) -> ShownList {
ShownList(version: version, items: newItems)
}
func applying(version newVersion: Int, items newItems: [String]) -> ShownList {
newVersion > version ? ShownList(version: newVersion, items: newItems) : self
}
}
overwriting writes the incoming list to the screen without looking at the version. applying writes only if the incoming version is greater than the shown version; if a response with an old version arrives, it ignores it.
Unversioned and Versioned Updates
There are two responses: the result of the old query (version 1, old) and the result of the new query (version 2, new). a writes the old response and b writes the new one. Unversioned and versioned writes are measured separately in the two orders.
// main.swift — the same two responses, unversioned and versioned writes
let start = ShownList(version: 0, items: [])
let blind = replay("unversioned-write", from: start,
{ $0.overwriting(items: ["old"]) },
then: { $0.overwriting(items: ["new"]) }).report()
let checked = replay("versioned-write", from: start,
{ $0.applying(version: 1, items: ["old"]) },
then: { $0.applying(version: 2, items: ["new"]) }).report()
countSensitivities([blind, checked])
unversioned-write | once=v0 ["old"] twice=v0 ["old"] ab=v0 ["new"] ba=v0 ["old"] -> order-sensitive versioned-write | once=v1 ["old"] twice=v1 ["old"] ab=v2 ["new"] ba=v2 ["new"] -> robust robust: 1 repeat-sensitive: 0 order-sensitive: 1 doubly-sensitive: 0
In the unversioned write, repeating changes nothing: once and twice are the same. But ab is new and ba is old: the last to arrive stays on screen. If the response of the old query arrives late, it overwrites the new result. The operation is order-sensitive. In the versioned write, the old response is ignored even if it arrives after the new one; both orders give v2 ["new"], and repeating changes nothing either. The operation is robust. The count is one robust, one order-sensitive.
CC11. The measurement assumes that responses are not lost, only that their order can change. The case where a response never arrives or is an error was not measured.
CC12. The version numbers are assumed to be given as 1 and 2 in request order. Who produces the number is dealt with in the bounding measurement below.
Why the Screen Is Updated in One Place
The system that draws the screen works on a single flow: while deciding what will appear where in a frame, the data being shown must not change at that moment. If another piece of work added an element to the list while the list was being drawn, the drawing would produce either the old, the new, or a half-finished picture. For this reason every operation that touches the interface is gathered in a single place, the main thread, and runs in order. The main actor is the form of this rule checked by the compiler: touching a type marked @MainActor from elsewhere without await is a compile error.
What the main actor gains is briefly this: the code that writes the screen’s state always runs in the same place, one piece at a time. What it loses comes from the same rule: every long piece of work done on the main actor keeps the screen waiting. This is why the work is split. The network request, parsing, and sorting are done in the background; only the last step, writing the ready list to the screen state, happens on the main actor. The measurements in this lesson model that last step.
Updating on the Main Actor
The counterpart of the same rule in real code is the following. The fragment is not measured, because screen state tied to the main actor gives no meaningful output in the sandbox.
import Foundation
@MainActor
final class SearchResults {
private(set) var items: [String] = []
private var shownVersion = 0
func apply(version: Int, items newItems: [String]) {
guard version > shownVersion else { return }
shownVersion = version
items = newItems
}
}
apply accepts only a newer version. The version number is produced in the code that starts the request: every time a search starts, a counter goes up by one and the number is tied to the request. When the response arrives, it carries its number.
The Loading Indicator
Search screens also have a spinning indicator: it is visible while the request is going out and disappears when the response arrives. Holding the indicator with a single Bool is the setup that comes to mind first, and it works correctly when there is one request. When two requests overlap, however, the first response turns the indicator off, and the screen looks finished while the second request is still running. The alternative is to hold the number of requests in flight: it goes up by one at the start and down by one at the response, and the indicator is visible as long as the number is greater than zero.
The two setups are applied to the same pair, that is, to the start and finish operations separately. The measurement tests a single start with a single finish; it does not build two overlapping requests directly.
// main.swift — loading indicator: flag and counter
let flag = replay("flag", from: false,
{ _ in true },
then: { _ in false }).report()
let counter = replay("counter", from: 0,
{ $0 + 1 },
then: { max($0 - 1, 0) }).report()
countSensitivities([flag, counter])
flag | once=true twice=true ab=false ba=true -> order-sensitive counter | once=1 twice=2 ab=0 ba=1 -> doubly-sensitive robust: 0 repeat-sensitive: 0 order-sensitive: 1 doubly-sensitive: 1
In the flag line, repeating changes nothing, because the second start finds the indicator already on. But ab and ba split: if the finish comes before the start, the indicator stays on. The flag is order-sensitive. The counter line came out doubly sensitive, and this is not a defect but the correct result of what a counter is: every start really means one more request, so two starts differ from one start. The correct reading of sensitivity is this: a class is not “good” or “bad”, it shows what the operation claims. The counter counts two starts as two requests, and it has to; the error appears when the same start notification is repeated and applied twice. Repeated notifications are seen in network layers and event publishers; an indicator that works with a counter stays on forever in this case.
CC13. The flag and counter measurement builds a single start and a single finish. The four-operation orderings of two overlapping requests (start, start, finish, finish) were not measured.
In a Real Application
On a search screen a request goes out each time the user presses a letter. The most common setup is to start an independent task at every change and write the result directly to the screen state.
// Wrong: every letter opens a new task, the old task is not cancelled
@MainActor
final class SearchModel {
private(set) var results: [String] = []
func queryChanged(_ text: String) {
Task {
let found = await search(text)
results = found
}
}
}
This code is the unversioned write itself. For a user who types fast, the requests a, ab, and abc go out one after another; on a slow network, if the ab response arrives after the abc response, the screen shows the ab results while it says abc. On the developer’s machine the network is fast and regular and the bug is not visible; in production, on a mobile connection, it is.
There are two ways to fix it, and most of the time both are used together. The first is to cancel the previous task: the task’s handle is stored, the old one is cancelled when a new letter arrives, and the old task checks whether it has been cancelled before writing the result. The second is the version number, as this lesson measured: the number is given when the request starts, that is, on the main actor and in order, it returns with the response, and apply goes on discarding the older number. Cancellation stops unnecessary network work; the version discards a response that reached the screen despite cancellation. A task that does not check for cancellation can carry the mark and finish its work, so counting cancellation alone as enough is also a setup mistake.
This fragment was not measured: the timing of a real Task, network latency, and at which point of the task the cancellation shows cannot be built in a sequential run. What is measured is the writing rule itself.
Common Mistakes
- Trying to change the screen state directly from background work. You need to move to the main actor with
await. - Confusing the moment the result is written to the screen with the moment the request starts. The write happens when the response arrives.
- Writing an old response on top of the new one. Nothing prevents this without a version or cancellation.
- Making the main actor the workplace of a long computation. The screen stays unresponsive during that time.
The Bounding Measurement
For the version to work depends on who gives the number. If the code that starts the request gives the number, the old response carries the old number and is discarded. If the screen gives the number when the response arrives, every response looks newer than the previous one.
// main.swift — if the version number is given on arrival
let stampedStart = ShownList(version: 0, items: [])
let stamped = replay("stamped-on-arrival", from: stampedStart,
{ $0.applying(version: $0.version + 1, items: ["old"]) },
then: { $0.applying(version: $0.version + 1, items: ["new"]) }).report()
countSensitivities([stamped])
stamped-on-arrival | once=v1 ["old"] twice=v2 ["old"] ab=v2 ["new"] ba=v2 ["old"] -> doubly-sensitive robust: 0 repeat-sensitive: 0 order-sensitive: 0 doubly-sensitive: 1
The same two responses, the same applying rule; only the number changed. Because every response got a number one higher on arrival, none was discarded. once=v1 and twice=v2 differ: the second application increased the version again and the operation became repeat-sensitive. ab and ba differ too. The operation is doubly sensitive. The rule does not see the cause, it sees the inequality; there was a version check, but the code that gave the number in the wrong place made it ineffective. The count is one doubly sensitive.
CC14. This measurement is a model that increases the version with a single step on the arrival of a response. The case where the number is produced not from a counter but from a timestamp was not measured.
When No Response Arrives
The measurements assume that every response arrives in the end. On a real network a response may never arrive or may return with an error, and this is a separate decision for the screen. The most common setup mistake is swallowing the error and leaving the indicator on: the user looks at an indicator that spins forever. The error state must also carry a version, because a late error from an old request must not write an error message on top of the successful result of the new request. The rule is the same: every path that writes to the screen state asks, before writing, whether its own request is still current.
The practical consequence of this is to pass the screen state through a single writing gate. Success, error, and cancellation come to the same function, and the function checks the version once. Code that writes three separate checks in three separate places will one day forget one of them; the forgotten place is usually the path seen most rarely, that is the error path, and nobody tries it during development. This is why our measurements also start with a simple model of two responses: first make the basic rule visible, then leave to the reader the question of which paths the rule will be applied to.
The way to test the screen’s resilience is to send the responses deliberately in reverse order and with a delay. A developer setting that slows down the network, or a fake server that changes the response order, reveals the unversioned write within minutes. This test does not replace the measurement; the measurement shows why the rule works, and the test shows that the app really applies the rule.
Keeping the Main Actor Light
The main actor is a single road, and the more work is done on it, the later the screen responds. A practical measure is that the time a piece of work spends on the main actor stays shorter than the duration of one frame; that duration depends on the refresh rate of the screen. Every piece of work that goes above this shows up as stutter during scrolling. Sorting a large list, decoding an image, or parsing a response exceeds this threshold quickly; these jobs are done in the background and only the ready result is carried to the main actor.
There is a subtle point here: when the result is carried to the main actor, the version must be carried too. The background work takes the version number when it starts, returns to the main actor with this number when it finishes, and the main actor compares the number with the current number. Producing the number at the moment the background work finishes is the same mistake as in the last measurement of the lesson. So the source of the number is always the place where the work started. The number is taken once at the start, does not change during the work, and returns with the result; this way the code writing to the screen always knows which request’s response it carries. Without this information no check would be reliable.
Summary
- Interface state is tied to the main thread; in concurrent code this is expressed with the main actor.
- An unversioned write is sensitive to arrival order; an old response can overwrite the new one.
- A version check discards the old response and makes the write robust.
- Giving the number on the arrival of the response leaves the check ineffective and makes the operation doubly sensitive.
- Which thread runs which piece of work was not measured.
- This lesson’s sensitivity: order-sensitive (unversioned) and robust (versioned).
Next Step
The main actor and actors are the building blocks of new code; but old code is still written with dispatch queues. In the next lesson we will measure adding work to a serial queue and blocks that assign values, and look at what the queue puts in order.
To keep your progress and take notes, Log in
My notes
Log in to take notes.