← Knowledge hub
Concurrency · ORIGINAL STUDY NOTE

Swift 6 concurrency: async/await, groups, actors & GCD

A senior-level review of structured tasks, actor isolation, reentrancy, Sendable, cancellation and trade-offs with GCD.

1. async/await expresses suspension, not a new thread

An async function can suspend without blocking the current thread. await is a possible suspension point, not a guarantee of concurrent execution. Errors propagate with try await. Prefer URLSession's async APIs rather than hand-written queues for I/O.

Basic async networking
import Foundation
struct User: Decodable, Sendable { let id: Int }
func loadUser(from url: URL) async throws -> User {
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode(User.self, from: data)
}

2. Parallel child tasks: async let and TaskGroup

Use async let when the number of independent child operations is known. Use a task group for dynamic fan-out. Structured children stay within the scope; task groups propagate cancellation, and their scope waits for children to finish. Results from a group arrive in completion order, so sort or index when input order matters.

Parallel known tasks
func loadDashboard() async throws -> (User, [String]) {
    async let user = loadCurrentUser()
    async let accounts = loadAccountNames()
    return try await (user, accounts)
}
// loadCurrentUser and loadAccountNames are async functions.
Dynamic fan-out with throwing task group
func fetchTitles(ids: [Int]) async throws -> [String] {
    try await withThrowingTaskGroup(of: String.self) { group in
        for id in ids {
            group.addTask { try await fetchTitle(id: id) }
        }
        var titles: [String] = []
        for try await title in group { titles.append(title) }
        return titles
    }
}
// Titles arrive in completion order, not necessarily IDs order.

What is an actor, and when should you use one?

An actor is a reference type that protects its mutable state through actor isolation. It serializes access to actor-isolated code so unrelated tasks cannot race on that state. Calling an isolated method from another actor context usually requires await. This is a language-level isolation model, not a manually managed lock or a dedicated thread.

Basic actor protecting shared state
actor DownloadCounter {
    private var downloads = 0

    func recordDownload() {
        downloads += 1
    }

    func total() -> Int {
        downloads
    }
}

func report(counter: DownloadCounter) async -> Int {
    await counter.recordDownload()
    return await counter.total()
}

An actor is not a dedicated thread or DispatchQueue

Actor-isolated work runs through an executor; it is not guaranteed to remain on a single thread. Within actor-isolated synchronous code, calls can be made without suspension. From another isolation domain, accessing actor-isolated state requires crossing that boundary. Use nonisolated only for operations that do not rely on isolated state.

Reentrancy: awaits split isolated operations

Once an isolated method suspends at await, another task can enter the actor. Two callers can both try to refresh a token unless the first records its in-flight Task before suspending. Isolation protects access, not cross-await atomicity. Re-check assumptions after suspension if another call could change relevant state.

Trade-offs and interview pitfalls

Actors are excellent for genuinely shared mutable state, but actor hopping costs something and isolation alone does not guarantee cancellation, cross-actor transactions, or response ordering. An actor should not be a default replacement for every class or for an ordinary immutable value.

3. Actor isolation and reentrancy

An actor serializes isolated access to its mutable state, but it does not make a multi-await workflow atomic. Once an isolated method suspends, another call may enter the actor and observe its intermediate state. For single-flight token refresh, keep an in-flight Task reference before awaiting it.

Actor with single-flight refresh
actor TokenRefresher {
    private var inFlight: Task<String, any Error>?
    private let refresh: @Sendable () async throws -> String
    init(refresh: @escaping @Sendable () async throws -> String) {
        self.refresh = refresh
    }
    func token() async throws -> String {
        if let inFlight { return try await inFlight.value }
        let task = Task { try await refresh() }
        inFlight = task
        defer { inFlight = nil }
        return try await task.value
    }
}
// Consider cancellation and overlapping requests before resetting inFlight.

4. MainActor and Sendable

@MainActor isolates UI state and mutations; it doesn't guarantee that an expensive synchronous operation suddenly becomes background work. A Task created on the main actor inherits its isolation. Sendable communicates safe values across concurrency boundaries; Swift 6 strict language mode strengthens data-race checks.

UI-facing model isolated on MainActor
import Observation
@MainActor @Observable
final class AccountsModel {
    private(set) var names: [String] = []
    func refresh() async throws {
        let loaded = try await loadAccountNames()
        names = loaded
    }
}

5. Cancellation is cooperative

Cancellation sets a flag and propagates through structured tasks; your work still needs to check or respond to it. Don't rely on cancellation to terminate a blocking loop. Use Task.checkCancellation() and cancellation-aware APIs.

Cooperative cancellation in a loop
func process(ids: [Int]) async throws {
    for id in ids {
        try Task.checkCancellation()
        try await processItem(id)
    }
}

6. GCD vs structured concurrency

GCD expresses queue scheduling and closure-based callbacks; Swift concurrency expresses asynchronous operations, task lifetimes, typed errors and isolation. Use GCD when integrating legacy APIs or low-level queue work, but don't wrap async functions in DispatchQueue unnecessarily. Detached tasks opt out of the inherited context and require explicit thought about lifetime, priority, isolation and cancellation.

Concurrency trade-offs
ToolUse whenWatch out
async/awaitSequential asynchronous workNot automatically parallel
async letKnown independent tasksChild lifetime bound to scope
TaskGroupDynamic parallel workCompletion order, cancellation, bounded concurrency
actorProtect shared mutable stateReentrancy across await
@MainActorUI state and actor-bound APIsAvoid synchronous expensive work
Task.detachedIntentional independent contextNo inherited isolation; manage lifecycle
DispatchQueueInterop with callback / queue APIsNo structured task tree

Swift 6 migration nuance

Compiler version and Swift language mode are different. Swift 6 language mode enables stricter safety diagnostics, and default actor isolation and approachable concurrency settings vary by toolchain and project configuration. Always check the target's Swift language mode and build settings before making global claims about default isolation.

7. Unstructured Task, detached Task and GCD bridge

Task { } starts an unstructured task and inherits surrounding actor context; it is not a child of withTaskGroup and you must manage its lifetime. Task.detached deliberately does not inherit the surrounding actor isolation. Prefer structured tasks when a parent should own cancellation and results. DispatchQueue callbacks from older APIs can be bridged with checked continuations if they reliably call the completion exactly once.

Unstructured UI task and explicit isolation
import SwiftUI
@MainActor
final class AccountsController {
    func refreshTapped() {
        Task {
            // Inherits MainActor isolation here.
            let accounts = try? await loadAccountNames()
            if let accounts { updateUI(with: accounts) }
        }
    }
    func updateUI(with accounts: [String]) { /* update state */ }
}
// loadAccountNames() is an async API implemented elsewhere.
GCD-style callback bridged to async
import Foundation
func waitForCallback() async -> Int {
    await withCheckedContinuation { continuation in
        DispatchQueue.global().async {
            let result = 42
            continuation.resume(returning: result)
        }
    }
}
// Every execution path must resume the checked continuation exactly once.

Practical caution: cancellation of the awaiting Task does not automatically cancel the legacy GCD block. A continuation must never be resumed twice; use a dedicated cancellation strategy when wrapping cancellable callback APIs.

SOURCES & DOCUMENTATION

Personal study notes. Check details against your target Swift/iOS version and official documentation.