Chapter 1 Swift concurrency for GCD hands

async/await for GCD hands: suspension, tasks and the pool you must not block

What happens at an await, who runs an async function, and why a semaphore that was harmless under GCD can hang a Swift concurrency app.

I learned concurrency on Grand Central Dispatch, and I’m moving to Swift concurrency now. It is natural to read async/await through GCD habits, and several of those habits are wrong in ways the compiler won’t always point out. This series is the set of notes I wanted when I started: each idea in Swift concurrency, beginning with how the same thing looked in GCD, and then what the new code actually does.

This first chapter covers async/await, tasks and the thread pool underneath them. The second is about actors and isolation.

Where a claim comes from running code rather than from documentation, I say so. The experiments ran on a Mac, in Swift 5 language mode with -strict-concurrency=complete.

The GCD starting point

A task you have probably written: load a user, then their avatar, then show the profile.

Swift
func showProfile(id: Int) {
    loadUser(id: id) { result in
        switch result {
        case .failure(let error):
            DispatchQueue.main.async { print("error: \(error)") }
        case .success(let user):
            loadAvatar(url: user.avatarURL) { result in
                switch result {
                case .failure(let error):
                    DispatchQueue.main.async { print("error: \(error)") }
                case .success(let data):
                    DispatchQueue.main.async { print("show \(user.id), \(data.count) bytes") }
                }
            }
        }
    }
}

showProfile hands a closure to loadUser and returns at once: the function is over before the work has started. Later a pool thread runs the first callback, which starts the second download and returns too. Another thread runs the second callback, and only main.async gets the result on screen. One logical sequence, load then load then show, is cut into three pieces that run at different times on different threads. The familiar problems follow from that:

  1. The shape of the code doesn’t match the logic. Every step nests one level deeper, and errors are handled at every level, because throw doesn’t cross a closure boundary.
  2. The completion contract is unchecked. “Call the handler exactly once on every path” is a convention. Miss it in one guard branch and the caller waits forever.
  3. The pieces of work aren’t related. When the user leaves the screen, there is no way to cancel “the whole profile load”. Each block lives on its own.
  4. Blocking is expensive. When a block on a concurrent queue blocks, GCD brings up more threads to keep the queue draining. Under load that turns into thread explosion: many more threads than cores, with the memory and scheduling cost that comes with them.
  5. Races are invisible. Closures capture shared state, and only you remember which queue is allowed to touch it.

A function that can wait

The same code with Swift concurrency:

Swift
@MainActor func showProfile(id: Int) async {
    do {
        let user = try await loadUser(id: id)
        let avatar = try await loadAvatar(url: user.avatarURL)
        print("show \(user.id), \(avatar.count) bytes")
    } catch {
        print("error: \(error)")
    }
}

It reads top to bottom, errors go through try and catch, and the compiler checks that every path returns or throws. Problems 1 and 2 are gone. (@MainActor puts the function on the main actor. For now, read it as “runs on the main thread”.)

async in the signature means the function can suspend partway through. await marks a place where it may do so: a suspension point. At an await:

  1. If the result isn’t ready yet, the function suspends.
  2. Its state, the local variables and the place it stopped, is kept in an async frame on the heap rather than on the thread’s stack.
  3. The thread is released and goes off to run other work. It doesn’t sleep and it doesn’t wait.
  4. When the result is ready, the function resumes where it stopped, possibly on a different thread.

Three consequences are worth absorbing right away.

The thread after an await may be a different one. “Which thread am I on?” stops being a useful question. Thread-identity APIs such as Thread.current are marked unavailable from async code (SE-0340): a warning in Swift 5 mode and an error in Swift 6. Instead of threads, you reason about isolation, meaning which actor a piece of code belongs to.

The world can change across an await. While the function is suspended, other code can modify shared state. Anything you checked before an await may be stale after it. This is where actor reentrancy bugs come from, which is the subject of the next chapter.

await is also a note to the reader. You can see every place where the function may give up control. With GCD, those places hid inside calls that took closures.

One more rule: an async function can only be called from async code. So where does the first one come from? To answer that, we need to know who runs an async function.

Tasks: who runs an async function

A synchronous function owns its thread from start to finish; its locals live on that thread’s stack. An async function lets go of the thread at every await, so something else has to carry it from thread to thread. That something is a task.

Apple’s documentation calls a task “a unit of asynchronous work”, and every async function runs as part of one. A task holds the function’s state, a priority, a cancellation flag and task-local values (context such as a request ID that all code inside the task can read).

A task runs in stretches. Each stretch ends at a suspension point or at completion, and the runtime puts each one on whichever thread of its pool is free. Thousands of tasks can share a handful of threads, and that turns out to matter a great deal later on.

The task tree

Work started with DispatchQueue.async outlives the code that started it. Structured concurrency brings the rule that structured programming brought to sequential code: work doesn’t outlive the scope that started it.

A task can start child tasks. They run concurrently with the parent and with each other, and together they form a tree with four guarantees (SE-0304):

  1. A scope waits for its children. It doesn’t end until every child task has finished, so there is no forgotten background work.
  2. Cancellation flows down. Cancel a parent and all of its descendants are cancelled.
  3. Errors flow up to the parent at the point where it awaits the child.
  4. Children inherit priority and task-local values.

Children do not inherit the parent’s actor isolation. They run concurrently with the parent, so they can’t share its actor: the body of a child task is nonisolated and @Sendable (SE-0317).

Child tasks form a tree under the task that started them: the scope waits for them, cancellation, priority and task-local values flow down, errors and results flow up. Task { } and Task.detached { } start new top-level tasks outside the tree, even when created inside it.

Child tasks come from async let, when you know up front how many operations you need, and from withTaskGroup, when the number is only known at run time. Task { } and Task.detached { } create unstructured tasks outside the tree; we’ll get to them below.

async let: concurrent, but scoped

The two loads in showProfile have to run one after the other: the avatar URL comes from the user. Often operations are independent, though:

Swift
func loadDashboard(id: Int) async throws -> (User, Stats) {
    async let user = loadUser(id: id)
    async let stats = loadStats(id: id)
    return try await (user, stats)
}

async let starts a child task immediately and moves on without waiting, so both loads run at the same time. We wait only where we read the values, in try await (user, stats). In my test, two 100 ms loads took about 104 ms together.

Two independent 100 ms loads. Awaited one after the other, they take 200 ms. With async let both start at once, and the caller suspends a single time, at the await that reads both values.

What if a value is never read? The tree’s guarantee applies: when the scope ends, an unread async let is cancelled and then awaited (I checked). The same happens when the scope is left by throwing. If loadUser throws, loadStats is cancelled, and loadDashboard returns only after it has finished.

Compare DispatchGroup with notify: it doesn’t stop the function from returning while the blocks are still running, and it doesn’t cancel them. async let can’t let work escape its scope.

Cancellation is a flag

Why not simply kill a task? Stopping code at an arbitrary point leaves the world in an unknown state: a file half written, a lock never released. GCD doesn’t interrupt running blocks either. Swift is just as honest about it, only more convenient.

In the words of the Task documentation, tasks have a shared mechanism for indicating cancellation but not a shared implementation for handling it, and it is the job of the code running in the task to check for it. task.cancel() sets a flag. Nothing more.

There are two ways to respond:

Swift
// Throw: the work makes no sense without a complete result.
func checkAll(_ urls: [URL]) async throws -> Int {
    var done = 0
    for _ in urls {
        try Task.checkCancellation()   // throws CancellationError if cancelled
        try await Task.sleep(for: .milliseconds(10))
        done += 1
    }
    return done
}

// Return what's ready: a partial result is still useful.
func checkSome(_ urls: [URL]) async -> Int {
    var done = 0
    for _ in urls {
        if Task.isCancelled { break }
        done += 1
    }
    return done
}

In my tests, cancelling checkAll midway threw CancellationError, and Task.sleep threw as soon as its task was cancelled. Many system async APIs check for you, so code that mostly awaits system calls often reacts to cancellation without a single explicit check. A long synchronous loop without checks, on the other hand, ran to the end.

Because cancellation flows down the tree, closing a screen can cancel one task and with it the entire load. That was problem 3.

From synchronous code: Task and Task.detached

Back to the question of where the first async function comes from. There are ready-made entry points, such as @main with an async main() and SwiftUI’s .task modifier. From ordinary synchronous code, like a button handler, you create a task yourself:

Swift
@MainActor final class ProfileScreen {
    var task: Task<Void, Never>?

    func openTapped() {            // synchronous
        task = Task {              // a new task that inherits the main actor
            await showProfile(id: 1)
        }
    }

    func closeTapped() { task?.cancel() }
}

What the documentation says about Task { }, and what I saw in tests:

  • It starts on its own as soon as it’s created. There is no start() and nothing to schedule.
  • It runs even if you don’t keep a reference to it. You need the reference only to await its result (await task.value) or to cancel it.
  • It’s unstructured: not a child of the code that created it. Cancelling the surrounding task doesn’t reach it, so cancelling it is your job, as in closeTapped.
  • Its closure is released as soon as the task completes, so [weak self] is rarely needed: any reference cycle breaks when the task ends.

Task { } inherits actor isolation, task-local values and priority from the place where it’s created. Task.detached { } inherits none of them (SE-0304).

The GCD trap: it’s tempting to read Task { } as DispatchQueue.global().async { }, “move this to the background”. It isn’t that. Created in @MainActor code, the body of Task { } runs on the main actor; I checked the thread. Heavy synchronous work inside it freezes the UI just as it would without the task.

To move work off the main actor, put it in an async function that isn’t isolated to any actor:

Swift
@MainActor final class ReportScreen {
    var items: [Item] = []

    func exportTapped() {
        Task {
            let pdf = await ReportRenderer.render(items)  // runs on the global pool
            share(pdf)                                    // back on the main actor
        }
    }
}

enum ReportRenderer {
    static func render(_ items: [Item]) async -> Data {
        // lay out and draw every page
    }
}

Under SE-0338 (Swift 5.7), a nonisolated async function doesn’t run on its caller’s actor: it switches to the global concurrent pool. Called from the main actor, it ran off the main thread in my test, while a synchronous call to the same work stayed on the main thread.

That rule can now be switched. Swift 6.2 adds an upcoming feature, NonisolatedNonsendingByDefault (SE-0461), under which nonisolated async functions run in the caller’s execution context, main actor included. With it on, you ask for the old behaviour explicitly with @concurrent:

Swift
enum ReportRenderer {
    @concurrent static func render(_ items: [Item]) async -> Data {
        // lay out and draw every page
    }
}

Swift 6.2 can also make code isolated to the main actor by default. Before relying on either rule, check which settings your target uses.

Task.detached is rarely the right tool. Use it when a task deliberately must not inherit isolation, priority or task-local values.

Never block the pool

Problem 4 was that GCD answers blocked threads with more threads. Swift concurrency takes the opposite approach: it runs tasks on a cooperative pool with as many threads as there are CPU cores (WWDC21, “Swift concurrency: Behind the scenes”). Thousands of tasks share those threads, and that works because a task gives its thread back at every await.

The flip side is that the pool assumes nobody holds on to a thread. A task that blocks the old way, with semaphore.wait(), keeps its thread doing nothing, and the pool won’t grow to make up for it.

I tested this. Start a number of tasks that each wait on a semaphore, then one more task that signals it. With one waiting task fewer than the Mac has CPU cores, the signalling task found a free thread and everything finished. With as many waiting tasks as cores, every thread in the pool was blocked, the signalling task never got to run, and the program hung for good. The same code on GCD wouldn’t hang: the pool would simply grow by one thread.

Each task blocked in semaphore.wait() holds a pool thread. With N waiting tasks on N threads, the task that would signal never gets one. GCD would start another thread here; the cooperative pool doesn’t.

Hence the rule: in async code, don’t block threads. No semaphore.wait(), no group.wait(), no synchronous waiting for the network. Wait with await. Then the thread is free, and the runtime knows what the task is waiting for.

That second part matters on its own. When a task awaits another task, the runtime sees the dependency, and when a high-priority task waits for a lower-priority one, the lower-priority task can be escalated (SE-0304). A semaphore hides that dependency: the runtime can’t tell who will signal it, so it can’t help.

The compiler catches the obvious cases. In my test, semaphore.wait() called directly in an async function was flagged as unavailable from asynchronous contexts, with a hint to await a task instead, and group.wait() got a hint to use a task group. To run the experiment at all, I had to hide the wait inside a synchronous helper function, and then the compiler can’t see it. That is exactly how it hides in real code, deep inside a library.

Cheat sheet

GCDSwift concurrencyThe difference
completion handlerasync functionresult via return or throw, checked by the compiler
DispatchGroupasync let, withTaskGroupwork can’t outlive the scope; cancellation flows down
global().async { }async function off any actor (@concurrent under SE-0461)“in the background” is a property of the function, not of the call site
main.async { }@MainActorthe compiler sees the isolation
semaphore.wait()awaitnever block: the pool has a fixed size
workItem.cancel()task.cancel()also a flag, but it reaches the children
async let, task groupTask { }Task.detached { }
Part of the task treeyesnono
Inherits actor isolationnoyesno
Inherits priority and task-localsyesyesno
Cancelled with its parentyesnono

Check yourself

Try to answer in your own words before you open each answer.

Why can Task { heavyLoop() }, started from a SwiftUI button, freeze the UI? How do you fix it?

The button’s action runs on the main actor, and Task { } inherits the actor isolation of the place where it’s created. So the task’s body, and the synchronous heavyLoop() inside it, run on the main thread and block it. Move the heavy work into an async function that isn’t isolated to any actor and await it from the task. With NonisolatedNonsendingByDefault enabled, mark that function @concurrent.

In loadDashboard, loadUser throws. What happens to loadStats, and when does the function return?

The error is thrown from try await (user, stats), so the scope is left by throwing. Before the function can exit, the unread async let for stats is cancelled and then awaited. loadDashboard throws only after loadStats has finished. How quickly that is depends on how soon loadStats notices the cancellation.

Why can’t you rely, after an await, on something you checked before it?

At an await the function may suspend, and while it’s suspended other code runs and can change shared state. When the function resumes, possibly on a different thread, the world may not be the one you checked. Check again after the await, or keep the check and the work that depends on it in one stretch of code with no await between them.

Name three ways a task differs from a block submitted to a GCD queue.

A block runs from start to finish on one thread; a task can suspend at every await and resume on another thread. A task carries a priority, a cancellation flag and task-local values, and child tasks form a tree with the parent: the scope waits for its children and cancellation flows down. And the runtime sees what a task is waiting for, so it can escalate the priority of the work it awaits; a block waiting on a semaphore hides that.

Why does the semaphore experiment hang with N waiting tasks, when the same code on GCD would not?

The cooperative pool has one thread per CPU core and doesn’t grow. Each task blocked in semaphore.wait() holds one of those threads, so N waiting tasks take all N, and the task that would call signal() never gets a thread to run on. GCD reacts to blocked threads by starting more, so the signalling block would eventually run.

Sources