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.
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.
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.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.
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 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.
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.
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.
| Tool | Use when | Watch out |
|---|---|---|
| async/await | Sequential asynchronous work | Not automatically parallel |
| async let | Known independent tasks | Child lifetime bound to scope |
| TaskGroup | Dynamic parallel work | Completion order, cancellation, bounded concurrency |
| actor | Protect shared mutable state | Reentrancy across await |
| @MainActor | UI state and actor-bound APIs | Avoid synchronous expensive work |
| Task.detached | Intentional independent context | No inherited isolation; manage lifecycle |
| DispatchQueue | Interop with callback / queue APIs | No 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.
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.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.
Personal study notes. Check details against your target Swift/iOS version and official documentation.