Home

Concurrency

A type system for time

Swift concurrency is not a threading library with nicer syntax. It is a type system for time: the compiler tracks which values may cross between concurrent contexts and refuses the ones that would race. Once that clicks, the errors stop looking arbitrary.

async/await is not “on a background thread”

func loadProfile(id: String) async throws -> Profile {
    let data = try await network.fetch("/profile/\(id)")
    return try decoder.decode(Profile.self, from: data)
}

await is a suspension point: the function may pause here and give the thread back to the system, resuming later — possibly on a different thread. It does not mean “go to the background”. A function marked async runs wherever its caller’s context says, and the two lines above may execute on two different threads with any amount of time between them.

That gap is the thing to hold in your head. Any state you read before an await may have changed after it.

Structured concurrency

Child tasks belong to a parent, and the parent cannot finish before its children do. That guarantee is what makes cancellation and error propagation work.

func loadDashboard() async throws -> Dashboard {
    async let profile = loadProfile(id: currentUserID)
    async let feed = loadFeed(page: 0)
    return try await Dashboard(profile: profile, feed: feed)
}

Both loads start immediately and run concurrently; await collects them. If either throws, the other is cancelled automatically. For a dynamic number of children, use a task group:

func loadAll(ids: [String]) async throws -> [Profile] {
    try await withThrowingTaskGroup(of: Profile.self) { group in
        for id in ids {
            group.addTask { try await loadProfile(id: id) }
        }
        return try await group.reduce(into: []) { $0.append($1) }
    }
}

Warning

Task { } creates an unstructured task: it does not inherit cancellation from the surrounding scope and outlives the function that started it. It is the right tool at the boundary between synchronous and async code, and the wrong one everywhere else. Reach for async let or a group first.

Cancellation is cooperative

Cancelling a task sets a flag. Nothing is interrupted; code that never checks keeps running to completion.

func process(_ items: [Item]) async throws {
    for item in items {
        try Task.checkCancellation()   // throws CancellationError if cancelled
        await handle(item)
    }
}

Standard-library async functions check for you, so a task blocked in URLSession or Task.sleep does react. Your own loops do not, unless you ask.

Actors

An actor protects its state by serialising access to it:

actor ImageCache {
    private var storage: [URL: Image] = [:]

    func image(for url: URL) -> Image? { storage[url] }

    func store(_ image: Image, for url: URL) {
        storage[url] = image
    }
}

Every call from outside is awaited, and only one runs at a time. No locks to forget.

The subtlety that catches everyone is actor reentrancy. An actor does not hold its lock across an await; it lets another call in while the first is suspended:

actor Downloader {
    private var cache: [URL: Data] = [:]

    func data(for url: URL) async throws -> Data {
        if let cached = cache[url] { return cached }
        let downloaded = try await fetch(url)   // ← another call can run here
        cache[url] = downloaded
        return downloaded
    }
}

Two concurrent calls for the same URL both miss the cache and both download. The state is not corrupted — that is what the actor guarantees — but the logic is still wrong. The fix is to store the in-flight Task rather than only the result:

actor Downloader {
    private var tasks: [URL: Task<Data, Error>] = [:]

    func data(for url: URL) async throws -> Data {
        if let existing = tasks[url] { return try await existing.value }
        let task = Task { try await fetch(url) }
        tasks[url] = task
        return try await task.value
    }
}

Sendable

Sendable marks a type as safe to hand between concurrency domains. Value types of Sendable parts conform automatically; classes do not, unless they are immutable or you take responsibility with @unchecked Sendable and your own locking.

struct Settings: Sendable { let theme: Theme }        // automatic
final class Logger: @unchecked Sendable {             // your promise, your lock
    private let lock = NSLock()
    private var lines: [String] = []
}

Under Swift 6 language mode these are errors, not warnings. The migration is tedious once and then permanent: the compiler has proven the absence of a whole class of bug that used to be found only by users.

@MainActor

UI work belongs on the main thread, and @MainActor states that in the type system:

@MainActor
final class ProfileModel {
    private(set) var profile: Profile?

    func load(id: String) async throws {
        profile = try await loadProfile(id: id)   // hops back to main automatically
    }
}

The await suspends, the network work happens elsewhere, and the assignment resumes on the main actor. No DispatchQueue.main.async, and no way to forget it — calling a @MainActor method from another context is a compile error unless you await it.