Concurrency cheat sheet

The 17 Concurrency interview answers in 30 seconds each, plus quick facts. Printable, free.

One page per topic: print it or save it as PDF from your browser's print menu. Practise these questions

Sequential await vs async let

Plain await runs calls one after another, and so does a tuple of awaited calls. async let starts each call as a child task straight away, so independent work overlaps and the total time is close to the slowest call rather than the sum.

async/await vs completion handlers

async/await lets asynchronous code read top to bottom like synchronous code. The compiler checks that every path returns a value or throws, errors use normal try/catch, and cancellation flows through structured tasks. With completion handlers, forgetting to call the callback on one path is a silent bug the compiler can't catch.

func loadUser(id: String) async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        api.fetchUser(id) { result in
            continuation.resume(with: result)   // exactly once
        }
    }
}

async let vs TaskGroup

Use async let for a fixed, small number of independent calls you know when writing the code. Use a TaskGroup when the number of child tasks depends on runtime data, such as one fetch per ID. Both are structured: child tasks finish or are cancelled before the scope exits.

Cooperative cancellation

Cancellation in Swift is cooperative. cancel() only sets a flag on the task and its children; nothing is stopped by force. Code must check with Task.isCancelled or try Task.checkCancellation(), and many system APIs such as Task.sleep and URLSession throw CancellationError when they see the flag.

func process(_ items: [Item]) async throws {
    for (index, item) in items.enumerated() {
        if index.isMultiple(of: 50) { try Task.checkCancellation() }
        try await handle(item)
    }
}

Task {} vs Task.detached

Task {} inherits the current actor, priority and task-local values, so inside a @MainActor view model it runs on the main actor. Task.detached inherits none of them. Detached tasks are rarely needed: to run heavy work off the main actor, call a @concurrent or nonisolated async function from a normal Task instead.

@MainActor @Observable
final class PhotoModel {
    var thumbnail: UIImage?
    func load(_ data: Data) {
        Task { thumbnail = await Self.decode(data) }
    }
    @concurrent
    static func decode(_ data: Data) async -> UIImage? {
        UIImage(data: data)?.preparingThumbnail(of: CGSize(width: 200, height: 200))
    }
}

What is an actor?

An actor is a reference type that protects its mutable state by running one piece of its code at a time. Code outside the actor can't touch that state synchronously; it must await a call, because the call may have to wait its turn. This removes data races at compile time without manual locks.

Actor reentrancy: check, await, then act

No. Actors are reentrant: while a method is suspended at an await, other calls into the same actor can run. A check made before the await may no longer be true after it. Do the state change before suspending (reserve first), or re-check after the await.

func withdraw(_ amount: Int) async throws {
    guard balance >= amount else { throw BankError.insufficientFunds }
    balance -= amount                  // reserve before suspending
    do {
        try await bank.confirm(amount)
    } catch {
        balance += amount              // roll back
        throw error
    }
}

@MainActor view models

A view model's state drives the UI, and UI state must only change on the main thread. Marking the class @MainActor makes the compiler enforce that: every property and method is main-actor isolated, and slow work runs in async calls that suspend instead of blocking. In Xcode 26 app targets, MainActor is the default isolation.

@MainActor @Observable
final class FeedModel {
    var posts: [Post] = []
    func refresh() async {
        do { posts = try await API.posts() }
        catch { /* show error state */ }
    }
}

Sendable and Swift 6 strict checking

Sendable marks types that are safe to share across concurrency domains. Value types made of Sendable parts, actors, and final classes with only immutable Sendable state qualify. In the Swift 6 language mode, the compiler rejects sending non-Sendable values across actors, which turns data races into compile errors.

Swift 6.2: default MainActor and @concurrent

In Xcode 26 app targets, code is MainActor-isolated by default, so most UI code needs no annotations. Nonisolated async functions now run on the caller's actor instead of hopping to the background (nonisolated(nonsending)). When you really want work on the concurrent pool, mark the function @concurrent.

struct ThumbnailMaker {
    @concurrent
    func make(from data: Data) async -> UIImage? {
        UIImage(data: data)?.preparingThumbnail(of: .init(width: 300, height: 300))
    }
}

Mutex vs actor

Use an actor when callers can await and the state is accessed from async code. Use Mutex (Synchronization, iOS 18+) or OSAllocatedUnfairLock (iOS 16+) when callers must stay synchronous, the critical section is tiny, and you never await while holding the lock. Both make a type safely Sendable.

import Synchronization

final class HitCounter: Sendable {
    private let count = Mutex(0)
    func hit() { count.withLock { $0 += 1 } }
    var value: Int { count.withLock { $0 } }
}

Serial vs concurrent queues

Grand Central Dispatch runs closures on queues backed by a shared thread pool. A serial queue runs one item at a time, in order. A concurrent queue can run several at once. async returns immediately; sync waits until the item finishes. New code mostly uses Swift concurrency, but GCD remains common in existing apps.

DispatchQueue.main.sync on the main thread

It deadlocks. sync waits for the closure to finish, but the closure can only run on the main thread, which is busy waiting. libdispatch usually detects this and crashes the app. main.async just queues the closure and returns, so it is safe from any thread.

Reader-writer with a barrier vs an actor

On a concurrent queue, reads run with sync so many can happen at once, and writes run with async(flags: .barrier) so each write waits for running reads and runs alone. It gives fast concurrent reads with safe writes. In new code an actor is simpler and the compiler checks it, though callers must await.

func value(for key: String) -> Int? {
    queue.sync { values[key] }
}
func set(_ value: Int, for key: String) {
    queue.async(flags: .barrier) { self.values[key] = value }
}

Bridging callbacks with AsyncStream

An AsyncSequence produces values over time that you consume with for await. AsyncStream is the easiest way to build one: yield values from a callback with the continuation, call finish() when done, and use onTermination to stop the source when the consumer goes away. Use AsyncThrowingStream if the source can fail.

OperationQueue vs GCD

OperationQueue is built on GCD but adds dependencies between operations, a max concurrency limit, cancellation of individual operations, and observable state. It suits pipelines of long-lived work in older code. In new code, task groups and structured concurrency usually cover the same needs.

Single-flight token refresh

Put the token in an actor that stores the in-flight refresh as a Task. The first caller starts it; every other caller awaits the same task. When it completes, each original request retries once with the new token. If the refresh itself fails, sign the user out once, not once per request.

Quick facts

QoS classes
userInteractive, userInitiated, default, utility, background. Higher QoS gets more CPU time and energy.
withCheckedContinuation
Resume exactly once. Resuming twice crashes; never resuming leaks the task, and checked continuations log a warning.
Task.yield()
Suspends briefly so other tasks can run. Useful inside long CPU loops.
Task-local values
@TaskLocal values flow to child tasks and Task {}, but not to Task.detached.
DispatchGroup
Tracks a set of GCD work items; notify runs when all have finished. TaskGroup is the async equivalent.
sending
A parameter or result marked sending can cross isolation domains once, if the sender never uses it again (Swift 6).