iOS path · Doing the work · Topic 5 of 11

Swift concurrency: tasks, actors, Sendable

Not started

Swift's concurrency model, available since Swift 5.5, has three parts that fit together: **async functions** that can pause without blocking a thread, **tasks** that run them and form a tree, and **actors** that protect mutable state by letting only one piece of code touch it at a time. The Swift 6 language mode adds the compiler checks that make data races a build error instead of a crash in production.

Why it matters: Swift 6 turns data races into compile errors. Knowing what isolation means is the difference between fixing those errors and fighting them.

  1. 1await is a pause point

    An async function can suspend at each await and free its thread; between awaits it runs without interruption.

  2. 2Tasks form a tree

    Child tasks from async let and task groups are cancelled with their parent and must finish before it does.

  3. 3Actors guard state

    An actor runs one piece of its code at a time; @MainActor is the actor for the UI.

  4. 4Sendable crosses borders

    Only values that are safe to share may be passed between isolation domains; Swift 6 checks this.

1

How it works

The explanation, with examples you can run.

async and await

func loadProfile(id: User.ID) async throws -> Profile {    let (data, _) = try await URLSession.shared.data(from: Profile.url(for: id))    return try JSONDecoder().decode(Profile.self, from: data)}

Each await marks a point where the function may suspend. While it waits, its thread goes back to the pool to run other work; nothing is blocked. Code between two awaits runs straight through. The function may resume on a different thread than it started on, which is why isolation, not threads, is what you reason about.

Tasks: structured first

func loadDashboard() async throws -> Dashboard {    async let profile = loadProfile(id: me)    // child task 1    async let feed = loadFeed()                // child task 2, runs in parallel    return try await Dashboard(profile: profile, feed: feed)}

async let and withTaskGroup create child tasks: they can't outlive the function, if the function exits with an error the children still running are cancelled, and cancelling the parent cancels them all. Cancellation is cooperative: a task is only marked, and your code (or the API it awaits) decides when to stop.

Task { ... } starts an unstructured task that inherits the current actor and priority; Task.detached inherits neither. In SwiftUI, prefer .task { } on a view, which is cancelled automatically when the view goes away.

Actors and the main actor

actor ImageCache {    private var images: [URL: UIImage] = [:]    func image(for url: URL) -> UIImage? { images[url] }    func insert(_ image: UIImage, for url: URL) { images[url] = image }} let cached = await cache.image(for: url)       // outside callers must await

An actor serialises access to its state, so callers from outside must await. Actors are re-entrant: while one of its methods is suspended at an await, the actor can run other calls, so don't assume state is unchanged after an await.

@MainActor is a global actor tied to the main thread. SwiftUI's View is declared @MainActor, so your views and their body are too, and marking a view model @MainActor makes the compiler check that UI state is only touched there.

Sendable and Swift 6

A value is Sendable when it is safe to share across isolation domains: value types made of Sendable parts, immutable final classes, actors, and classes that guard their own state. In the Swift 6 language mode (opt-in per target, introduced with Swift 6 in 2024), passing a non-Sendable value between actors is a compile error, where Swift 5 mode only warned, if you turned the checks on.

Swift 6.2 (2025) made this easier to adopt. A target can choose to be main-actor isolated by default, which suits app code that is mostly UI, and an upcoming-feature setting makes nonisolated async functions run on the caller's actor instead of always hopping to the thread pool. You then mark code that should run in parallel with @concurrent. Check your target's build settings: the same code can be isolated differently depending on these options.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.An actor method reads a balance, awaits a network call, then writes the balance. What can happen during the await?
  2. 2.What happens to the child tasks created with async let when the parent task is cancelled?
  3. 3.Which type can safely be sent from a background task to the main actor without extra work?

0 of 3 right so far. Get all three to finish this topic.