← All daysDay 32 of 49 · Part 9: Swift concurrency
Day 32

Structured concurrency · Actors and @MainActor

These are the lesson notes for this day. Its build-along tutorial and flash cards have not been written yet.

Lesson 36

Structured concurrency

async/await lets a function pause without blocking a thread. Structured concurrency adds a parent–child relationship between pieces of asynchronous work. async let starts child tasks that run in parallel and must finish before the enclosing scope ends. withTaskGroup does the same for a number of tasks decided at run time. Because the children belong to a parent, an error thrown by one reaches the parent automatically, and cancelling the parent cancels all the children.

Cancellation is cooperative. A cancelled task keeps running until it checks Task.isCancelled, or calls something that throws CancellationError. Task { } creates an unstructured task that nothing owns; you have to manage it yourself, so prefer the structured forms. Swift 6.4 adds withTaskCancellationShield for work that must finish even when its task is cancelled.

Coming from GCD, this replaces DispatchGroup and nested completion handlers.

Example
func loadHome() async throws -> (Profile, [Photo], [Friend]) {
    async let profile = fetchProfile()     // all three start now and run in parallel
    async let photos  = fetchPhotos()
    async let friends = fetchFriends()

    // Waits for all three. If one throws, the others are cancelled.
    return try await (profile, photos, friends)
}
Exercise

Fetch three resources in parallel with async let. Cancel the parent task partway through and prove, with print statements, that all three children stop.

After this lesson you should be able to answer
  1. What is the difference between async let and Task { }?
  2. What happens to child tasks when their parent is cancelled?
  3. Why is cancellation described as cooperative?
Resources
Lesson 37

Actors and @MainActor

An actor is a reference type that protects its own mutable state. Only one task at a time can run code inside it, and calls from outside have to be awaited. This replaces locks and serial queues. @MainActor is a particular actor that stands for the main thread: code marked with it always runs there, which is where all user-interface state must be read and changed.

Swift 6.2 changed the defaults to make this easier to live with. An app target can set its default isolation to MainActor, so that your code is on the main actor unless you say otherwise. And an async function now runs on the actor of whoever called it, unless you mark it @concurrent to send it to the background.

Two things to remember. An actor can start running another call each time it reaches an await — this is called reentrancy — so anything you checked before an await may no longer be true after it. And actors prevent data races; they do not prevent every mistake in logic.

Example
actor Cache {                              // only one task at a time may touch `storage`
    private var storage: [String: Data] = [:]

    func data(for key: String) -> Data? { storage[key] }
    func store(_ data: Data, for key: String) { storage[key] = data }
}

@MainActor                                 // everything in this class runs on the main thread
@Observable
final class AvatarModel {
    var image: Data?
    private let cache = Cache()

    func load(_ key: String) async {
        image = await cache.data(for: key)     // `await`: hop to the actor, then back
    }
}
Exercise

Take a class that protects a dictionary with a lock or a serial queue and turn it into an actor. Delete the lock. Then find an await inside it and ask what might have changed while it was suspended.

After this lesson you should be able to answer
  1. What does an actor guarantee, and what does it not guarantee?
  2. What does @MainActor mean when it is written on a class?
  3. What is actor reentrancy, and why does it matter?
Resources