12. Concurrency
Swift’s concurrency has two halves. One is async/await, which lets a
function wait for something without blocking a thread, and reads like ordinary
sequential code. The other is a compile-time check that no two pieces of code
can touch the same mutable state at the same time. The first half is pleasant.
The second half is why Swift 6 exists, and it is the half that will argue with
you.
12.1 async and await
A function that waits for something is marked async, and calling one is marked
await. The await is a suspension point: the function gives up its thread
there and resumes when the answer arrives, and nothing else in your function
runs in between.
// async let starts the work now and waits at the first use of the result.
func slowSquare(_ n: Int) async -> Int {
try? await Task.sleep(for: .milliseconds(100))
return n * n
}
func sequential() async -> Int {
var total = 0
for n in 1...3 {
total += await slowSquare(n)
}
return total
}
func concurrent() async -> Int {
async let a = slowSquare(1)
async let b = slowSquare(2)
async let c = slowSquare(3)
return await a + b + c
}
let clock = ContinuousClock()
let oneAfterAnother = await clock.measure { _ = await sequential() }
let allAtOnce = await clock.measure { _ = await concurrent() }
print("same answer:", await sequential() == concurrent())
print("sequential waited three times:", oneAfterAnother > .milliseconds(250))
print("concurrent waited once:", allAtOnce < .milliseconds(250))
same answer: true
sequential waited three times: true
concurrent waited once: true
sequential awaits each call before starting the next, so three hundred-
millisecond waits take three hundred milliseconds. async let starts the work
immediately and the await happens where the value is used, so the three waits
overlap and the whole thing takes a hundred.
One await can cover several calls in an expression, which is why
await sequential() == concurrent() has a single one. It marks the statement as
containing a suspension, not each individual call.
An async function can only be called from another async function, or from a
Task, which is the bridge from synchronous code:
Task {
let answer = await slowSquare(9)
print(answer)
}
The examples here get away without one because a single file compiled by
swiftc is top-level code, and top-level code may await directly. A real
program uses @main on a type with a static func main() async, in a file not
named main.swift, which is what swift build expects.
12.2 Structured Concurrency
async let handles a fixed number of children known when you write the code.
When the number depends on the data, use a task group.
// A task group runs a variable number of children and collects their results.
func measure(_ word: String) async -> (word: String, length: Int) {
try? await Task.sleep(for: .milliseconds(20))
return (word, word.count)
}
let words = ["ada", "grace", "alan", "edsger", "barbara"]
var results: [(word: String, length: Int)] = []
await withTaskGroup(of: (word: String, length: Int).self) { group in
for word in words {
group.addTask { await measure(word) }
}
for await result in group {
results.append(result)
}
}
// Results arrive in whatever order they finish, so sort before printing.
for result in results.sorted(by: { $0.word < $1.word }) {
print(result.word, result.length)
}
print("children:", results.count)
ada 3
alan 4
barbara 7
edsger 6
grace 5
children: 5
Both forms are structured: the children cannot outlive the scope that started
them. withTaskGroup does not return until every child has finished, and a
function with async let awaits or cancels its children before it returns. That
is what makes the lifetimes something you can reason about by reading, rather
than something you track with handles and hope.
Cancellation travels down the same tree. Canceling a parent cancels its
children, and cancellation is cooperative: a task is asked to stop and must
notice, by checking Task.isCancelled or by calling something like
Task.checkCancellation() that throws. Work that never checks never stops.
for await walks an AsyncSequence, which is the asynchronous cousin of
Chapter 10’s Sequence: a group is one, and so are the lines of a file and the
values of a stream.
An unstructured Task { } is the escape hatch, for work that genuinely must
outlive its caller. It gives you a handle you can await or cancel, and it is
your responsibility rather than the scope’s. Prefer the structured forms.
12.3 Actors
An actor is a reference type that protects its own mutable state. Only one task executes inside an actor at a time, so its properties cannot be read and written simultaneously from two places.
// An actor owns its state. Only one task is inside it at a time.
actor Tally {
private var counts: [String: Int] = [:]
func add(_ key: String) {
counts[key, default: 0] += 1
}
func snapshot() -> [String: Int] {
counts
}
}
let tally = Tally()
await withTaskGroup(of: Void.self) { group in
for i in 0..<1000 {
group.addTask {
await tally.add(i.isMultiple(of: 2) ? "even" : "odd")
}
}
}
let final = await tally.snapshot()
print("even", final["even"] ?? 0)
print("odd ", final["odd"] ?? 0)
print("total", final.values.reduce(0, +))
even 500
odd 500
total 1000
A thousand tasks increment a shared dictionary and the answer is right, with no
lock written anywhere. Calls from outside the actor are awaited, because they
may have to wait for whoever is inside; calls between an actor’s own methods are
not.
@MainActor is a global actor, a single actor that a whole set of declarations
belongs to. Marking a type or a function @MainActor means it runs on the main
thread, which is what user-interface code requires. In Swift 6 the top level of
a program is @MainActor by default, which is why a global var in a script
cannot be touched from a detached task without saying more.
The rule that catches people: an actor protects its state, not the objects its state refers to. Handing out a reference to a mutable class from inside an actor hands out the race you were avoiding. Keep actor state in value types and this does not arise.
12.4 Sendable
A value that crosses from one concurrency domain to another must be safe to
send, which is what Sendable means. Value types made of Sendable parts are
Sendable automatically, as are actors and immutable classes. A mutable class
is not, and Swift 6 refuses to let one cross:
data_race.swift:10:13: error: non-Sendable type 'Counter' of let 'counter' cannot exit main actor-isolated context [#NonSendableExitingActor]
1 | final class Counter {
| `- note: class 'Counter' does not conform to the 'Sendable' protocol
9 | group.addTask {
10 | counter.value += 1
[#NonSendableExitingActor]: <https://docs.swift.org/compiler/documentation/diagnostics/getting-non-sendable-values-out-of-actors>
In Swift 5 that program compiles and produces a number slightly under a hundred, different on each run, for reasons that take an afternoon to find. In Swift 6 it does not compile. That is the trade the language is making, and it is the reason strict concurrency was worth a major version.
The fixes, in the order to try them. Make the type a struct, so each task gets
its own copy and there is nothing to share. Make it an actor, so the sharing
is mediated. Make the class immutable — let properties all the way down — and
mark it Sendable. Only when none of those fit, mark it @unchecked Sendable
and take responsibility for the locking yourself, which is a promise to the
compiler that you have done what it could not verify.
This is where Chapter 7 pays off. A program built out of values has very little
that needs to be Sendable in the first place, because there is nothing shared
to race over. A program built out of mutable objects has to solve this problem
everywhere at once, and that is what porting an old codebase to Swift 6 feels
like.
Exercise 12-1. Rewrite taskgroup.swift with async let and find out why
you cannot, without changing the shape of the input.
Exercise 12-2. Make measure throw for an empty word, switch to
withThrowingTaskGroup, and decide what should happen to the other children
when one fails.
Exercise 12-3. Replace the actor in actors.swift with a final class and
read the error. Then fix it three ways: a struct, an actor, and
@unchecked Sendable with a lock.
Exercise 12-4. Start a Task that loops forever, cancel it after a moment,
and make it actually stop.
12.5 What This Book Left Out
Property wrappers and result builders, which is how @State and SwiftUI’s view
syntax are built. Macros, which is what #expect is. Unsafe pointers and manual
memory, which you need for talking to C. Objective-C interoperation. The whole
of SwiftUI, UIKit, and the Apple frameworks, which are a larger subject than the
language and are documented by the people who wrote them.
What you have is the language: its type system, its two kinds of type and the
reason for choosing between them, the absence of null, the exhaustive switch,
protocols and generics, and a concurrency model that checks its own promises.
Everything above is written in terms of those, so you now have the vocabulary to
read it.
The next thing to write is a program you want. Pick one that reads something and prints something, keep the logic in a module and the world at the edges, and let the compiler argue with you. It is usually right.