Lesson 24 / 24
Concurrency Safety
Measures that the lazy property of an actor is set up once under concurrent reads and that an async let whose result is never awaited is still evaluated; covers the Sendable and @MainActor boundaries and closes the course.
Published Updated
In this lesson
- Explains what actor isolation provides and how await crosses the actor boundary
- Measures that a lazy property in an actor is evaluated once even when it is read from several concurrent tasks
- Tells apart how Sendable and @MainActor stop a data race at compile time
- Shows that an async let binding whose result is never awaited is still evaluated
Contents
The previous lessons measured the evaluation moments of code that runs in a single flow. When several jobs touch the same memory at the same time, another question arises: what happens if a value is written from two places at once. This is called a data race, and its result cannot be predicted. Swift 6 does not leave this question to run time; strict concurrency checking rejects code that could lead to a race at compile time.
This lesson describes three tools and measures two things. The tools are actor isolation, Sendable and @MainActor. The measurements are when the lazy property of an actor is set up and when the work started with async let runs. The counters inside the actor are plain Int values: Probe is a class, it is not Sendable, and it cannot cross the actor boundary. Because the order in which tasks run is undefined, the results are collected, sorted and then printed; only numbers are printed.
// Probe.swift — records when an expression is evaluated
final class Probe {
let label: String
private(set) var evaluations = 0
private(set) var uses = 0
private var evaluatedBeforeUse = false
init(_ label: String) {
self.label = label
}
func eval<T>(_ value: T) -> T {
evaluations += 1
if uses == 0 { evaluatedBeforeUse = true }
return value
}
func use<T>(_ access: () -> T) -> T {
uses += 1
return access()
}
var moment: String {
if evaluations == 0 { return "never" }
if evaluations > 1 { return "on-every-use" }
return evaluatedBeforeUse ? "immediately" : "on-first-use"
}
func report() {
print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
}
}
func tally(_ probes: [Probe]) {
for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
let count = probes.filter { $0.moment == moment }.count
print("\(moment): \(count)")
}
}
Actor Isolation
An actor is a type that keeps its state inside itself and allows only one task at a time to touch that state. It resembles a class, but its mutable state and isolated methods cannot be reached from outside without writing await; immutable let properties and nonisolated members are the exception. When a task outside calls a method of the actor, it writes await: the call enters the actor’s queue, and if the actor is busy at that moment, the task waits. This waiting point is called a suspension point, and the mutable state of the actor cannot be reached without writing await.
The actor below keeps two counters and carries a lazy property named total. The expression of the property calls a method; the method counts in builds how many times it ran.
// Ledger.swift — an actor with a lazy property
actor Ledger {
private(set) var builds = 0
private(set) var reads = 0
lazy var total: Int = makeTotal()
private func makeTotal() -> Int {
builds += 1
return 42
}
func read() -> Int {
reads += 1
return total
}
}
The measurement first looks at the counters without touching the property, then starts four tasks that call read at the same time.
// main.swift — a lazy property in an actor is set up once under concurrent reads
let ledger = Ledger()
print("builds before \(await ledger.builds)")
let values = await withTaskGroup(of: Int.self) { group in
for _ in 1...4 {
group.addTask { await ledger.read() }
}
var all: [Int] = []
for await value in group {
all.append(value)
}
return all
}
print(values.sorted())
print("reads \(await ledger.reads), builds \(await ledger.builds)")
builds before 0 [42, 42, 42, 42] reads 4, builds 1
Four tasks read the same property, but the setup happened once. Before the property was touched, the build count was zero: the lazy expression ran not on the line where it is defined but on the first read. Actor isolation does two jobs here at once. It queues the reads and does not let the second begin before the setup of the first has finished. So it is impossible for two tasks to run the lazy expression at the same time and corrupt the counter. This lesson’s moment is on first use.
It is worth stating what this measurement does not say. An actor does not stop jobs from running at the same time; it only queues the part that touches the state of the actor. The four tasks start at the same time, but they enter the read method one by one. The single setup of the lazy property is a direct result of this ordering: when the second task enters, the property is already set up.
A suspension point has a cost. While an actor method waits for other work with await, the actor becomes free and the next task can enter. For this reason, a value read before await is not assumed to stay the same afterwards. Isolation prevents two tasks from being inside at the same time; it does not guarantee that the state will not change while waiting. The methods in this lesson’s measurements contain no await, so this case was not measured.
The Transferable Type and the Main Actor
If a value travels between tasks, it is crossing an isolation boundary. Types that can cross the boundary are called Sendable; in plain terms, a type that can be transferred. Numbers, strings and non-public value types that store only transferable types carry this property automatically; in public types the conformance is written explicitly. Actors carry it too, because they already protect their own insides. An ordinary class that holds mutable state does not: two tasks could write to the same instance at the same time.
For this reason Probe cannot cross an actor boundary. The fragment below is source only and is not run: a Probe is captured by the closure of another task. Swift 6 rejects this at compile time; the error text is not printed here.
// Source fragment: not run
let moved = Probe("moved")
let work = Task.detached { moved.eval(1) }
print(await work.value)
Why it is rejected is clear: the work started with Task.detached runs independently of the main actor, while moved is state of the main actor. The same instance could be touched from two places at once, and this is exactly the definition of a data race. The compiler shows on which line the instance leaks into two isolation regions. One of the solutions is not to move the value, or to put it under isolation: placing the counter inside an actor, as the measurements in this lesson do. Because the compiler catches the problem before the program runs, the error is seen on the day it is written and not on a day when the program runs wrongly.
The main actor (@MainActor) is a single predefined isolation region. Only code running on the main actor can touch the state of a type marked with @MainActor; a call from anywhere else requires await. The top-level code of a script runs on the main actor as well. The class below carries this marking and keeps a counter.
// Screen.swift — a class bound to the main actor
@MainActor
final class Screen {
private(set) var taps = 0
func tap() {
taps += 1
}
}
The measurement starts five tasks in the background; each calls the tap method with await.
// main.swift — a class bound to the main actor is reached from other tasks with await
let screen = Screen()
await withTaskGroup(of: Void.self) { group in
for _ in 1...5 {
group.addTask { await screen.tap() }
}
}
print("taps \(screen.taps)")
taps 5
Five calls gave five increments, with no loss. The calls entered the queue of the main actor and were handled one by one. Although screen is a class, it could be captured by the tasks: classes bound to the main actor count as transferable, because their state is touched from a single isolation region only.
Task Groups
The measurements above used a task group. A group starts child tasks whose number can be decided at run time and waits for all of them to finish; with for await the results are collected as they finish. The order in which results arrive is undefined, which is why the measurements of this lesson sort them before printing. For a few jobs whose number is known at compile time there is async let; each binding is a single child task, and its result is read by awaiting the name of the binding. An async let binding starts the expression on its right side as a child task; the result is read with await.
The Bounding Measurement
The question is: when does async let start. The answer that comes to mind is the line where the result is requested, that is, the place where await is written. To test this, the result of one binding is read and the second is never read. The actor counts whether both bindings called the bump method.
// Counter.swift — an actor that keeps the number of calls
actor Counter {
private(set) var calls = 0
func bump() -> Int {
calls += 1
return calls
}
}
// main.swift — async let starts on the line where it is set up, even if it is not awaited
let counter = Counter()
do {
async let waited = counter.bump()
async let ignored = counter.bump()
_ = await waited
}
print("calls \(await counter.calls)")
calls 2
Two calls were counted. ignored was never awaited, but the work still ran before the scope ended: not awaiting the result did not stop the work from running. On scope exit Swift cancels the child task that was not awaited and waits for it to finish; because bump does not look at cancellation, it completes its run. This measurement shows a limit. In an ordinary call, the expression is evaluated where the call is written and with the caller waiting; async let instead starts a child task without waiting. The two rules belong to different areas: an async let is not an expression but a task, and its side effect is independent of whether the result is read. For this reason, evaluation for async let does not wait for use.
MA6. Actor methods that contain a suspension point were not measured. The measurements were made with a single actor and four or five concurrent tasks; it was not shown that the same rule holds in much larger groups. The order in which the tasks ran was not measured, only the numbers were read. The case where a cancelled task is left half done in a method that looks at cancellation was not measured. The measurement shows only that the work runs even if it is not awaited; whether the work started on the binding line or later cannot be separated by this measurement.
Summary
- An actor protects its state inside itself and queues outside access with
await; the mutable state of an actor cannot be reached without writingawait. - A
lazyproperty in an actor is evaluated once even when it is read from several concurrent tasks, and it is set up on the first read. - Moving a value that is not
Sendablebetween tasks is rejected at compile time; one of the solutions, the one measured in this lesson, is to put the value under isolation. - An
async letbinding starts a child task; even if its result is never awaited, the work runs before the scope ends. - This lesson’s moment: on first use. The lazy property of an actor is set up once, on the first read, under concurrent reads.
Course Wrap-Up
The twenty-four lessons of this course answered a single question: when is an expression evaluated. Each lesson measured one expression and stated its moment with one of four names. In the table, the lesson, the measured expression, the moment and the limit of that lesson sit side by side.
| Lesson | Measured expression | Moment | Limit |
|---|---|---|---|
| Swift’s Design Goals | The right side of a let constant |
immediately |
If the same expression is bound twice to a single Probe, the rule says on-every-use |
| Constants and Variables | The initial value of a local constant | immediately |
A global constant in another file is set up on first access |
| Basic Types | The type conversion on a let line |
immediately |
An unread tuple element is evaluated too |
| String Operations | The description call in an interpolation |
on-every-use |
A string stored in a let is built once |
| Operators | The right side of the || operator when the left side is true |
never |
A function that does not take its right side with @autoclosure evaluates it before the call |
| Control Flow | The where condition after a match |
never |
In a loop the condition is asked on every pass, and while asks once more on exit |
| Functions | A default argument expression | on-every-use |
A supplied argument is evaluated before the call even if the body does not read it |
| Closures | The closure body | on-every-use |
An expression in the capture list is evaluated once when the closure is set up |
| Optionals | The map transformer on an empty box |
never |
In a .some(nil) box whose outer layer is filled, the transformer runs |
| Unwrapping Optionals | The right side of if let |
immediately |
Everything after the empty link of a chain is skipped, an independent expression on the same line is not |
| Structures and Classes | The copy of a document made on write | on-first-use |
In a document with a single owner or in a class field, the copy is never made |
| Enumerations | The expression of an associated value | immediately |
A computed property of the enumeration runs on every read |
| Properties | The initial value of a lazy property |
on-first-use |
In a structure each copy sets up its own lazy value separately |
| Protocols | The default implementation in a protocol extension | on-every-use |
The moment is decided not by the protocol but by the type that satisfies the requirement |
| Extensions | A computed property and a method added by an extension | on-every-use |
A static let in an extension is set up once on first access |
| Generics | The lazy.map transformer |
on-every-use |
An unread element of a lazy sequence is never transformed |
| Error Handling | The expression after throw |
never |
A defer block runs on every exit |
| Access Control | A private lazy cache |
on-first-use |
The moment is decided not by the access level but by the kind of the property |
| Automatic Reference Counting | The deinit body |
on-every-use |
Releasing while a second reference exists does not run deinit |
| Strong, Weak and Unowned References | The deinit body in a retain cycle |
never |
An unowned read of a gone instance cannot be measured, it stops the program |
| Capturing in Closures | An expression in the capture list | immediately |
A closure that is not stored builds no cycle |
| Property Wrappers | The wrappedValue getter |
on-every-use |
A wrapper that takes @autoclosure turns the moment into on-first-use |
| Result Builders | The expression of an unchosen branch | never |
Which branch is chosen is decided at run time |
| Concurrency Safety | A lazy property in an actor |
on-first-use |
An async let whose result is not awaited still runs |
The distribution of the twenty-four lessons is as follows: six are immediately, four are on-first-use, eight are on-every-use, six are never. This count comes from the moment column of the table. The distribution of the eight core expressions of the course is 2, 1, 3 and 2. The two are not the same thing: the core counts eight expressions, the table counts twenty-four lessons. The moment of a lesson belongs to the expression that lesson measured.
What the course finishes is teaching how to read an expression together with its rule. The same expression can give each of the four moments depending on how it is written: a local constant is immediately, a computed property is on-every-use, a lazy property is on-first-use, an unchosen branch is never. The habit the course leaves is, on seeing an expression, to ask which rule it stands under instead of trying to guess its moment from how it is written. This course measured the language itself.
To keep your progress and take notes, Log in
My notes
Log in to take notes.