concurrency-patterns
Concurrency Patterns — Expert Decisions
Expert decision frameworks for Swift concurrency choices. Claude knows async/await syntax — this skill provides judgment calls for pattern selection and isolation boundaries.
Decision Trees
async let vs TaskGroup
Is the number of concurrent operations known at compile time?
├─ YES (2-5 fixed operations)
│ └─ async let
│ async let user = fetchUser()
│ async let posts = fetchPosts()
│ let (user, posts) = await (try user, try posts)
│
└─ NO (dynamic count, array of IDs)
└─ TaskGroup
try await withThrowingTaskGroup(of: User.self) { group in
for id in userIds { group.addTask { ... } }
}
async let gotcha: All async let values MUST be awaited before scope ends. Forgetting to await silently cancels the task — no error, just missing data.
Task vs Task.detached
Does the new task need to inherit context?
├─ YES (inherit priority, actor, task-locals)
│ └─ Task { }
│ Example: Continue work on same actor
│
└─ NO (fully independent execution)
└─ Task.detached { }
Example: Background processing that shouldn't block UI
The trap: Task { } inside @MainActor runs on MainActor. For truly background work, use Task.detached(priority:).
Actor vs Class with Lock
Is the mutable state accessed from async contexts?
├─ YES → Actor (compiler-enforced isolation)
│
└─ NO → Is it performance-critical?
├─ YES → Class with lock (less overhead)
│ └─ Consider @unchecked Sendable if crossing boundaries
│
└─ NO → Actor (safer, cleaner)
When actors lose: High-contention scenarios where lock granularity matters. Actor methods are fully isolated — can't lock just part of the state.
Sendable Conformance
Is the type crossing concurrency boundaries?
├─ NO → Don't add Sendable
│
└─ YES → What kind of type?
├─ Struct with only Sendable properties
│ └─ Implicit Sendable (or add explicit)
│
├─ Class with immutable state
│ └─ Add Sendable, make let-only
│
├─ Class with mutable state
│ └─ Is it manually thread-safe?
│ ├─ YES → @unchecked Sendable
│ └─ NO → Convert to actor
│
└─ Closure
└─ Mark @Sendable, capture only Sendable values
NEVER Do
Task & Structured Concurrency
NEVER create unstructured tasks for parallel work that should be grouped:
// ❌ No way to wait for completion, handle errors, or cancel
func loadData() async {
Task { try? await fetchUsers() }
Task { try? await fetchPosts() }
// Returns immediately, tasks orphaned
}
// ✅ Structured — errors propagate, cancellation works
func loadData() async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await fetchUsers() }
group.addTask { try await fetchPosts() }
}
}
NEVER assume Task.cancel() stops execution immediately:
// ❌ Assumes cancellation is synchronous
task.cancel()
let result = task.value // Task may still be running!
// ✅ Cancellation is cooperative — code must check
func longOperation() async throws {
for item in items {
try Task.checkCancellation() // Or check Task.isCancelled
await process(item)
}
}
NEVER forget that async let bindings auto-cancel if not awaited:
// ❌ profileImage is SILENTLY CANCELLED
func loadUser() async throws -> User {
async let user = fetchUser()
async let profileImage = fetchImage() // Never awaited!
return try await user // profileImage cancelled, no error
}
// ✅ Await all async let bindings
func loadUser() async throws -> (User, UIImage?) {
async let user = fetchUser()
async let profileImage = fetchImage()
return try await (user, profileImage) // Both awaited
}
Actor Isolation
NEVER ignore actor reentrancy:
// ❌ State can change during suspension
actor BankAccount {
var balance: Double = 100
func transferAll() async throws {
let amount = balance // Capture balance
try await sendMoney(amount) // Suspension point!
balance = 0 // Balance might have changed since capture!
}
}
// ✅ Check state AFTER suspension
actor BankAccount {
var balance: Double = 100
func transferAll() async throws {
let amount = balance
try await sendMoney(amount)
// Re-check or use atomic operation
guard balance >= amount else {
throw BankError.balanceChanged
}
balance -= amount
}
}
NEVER expose actor state as reference types:
// ❌ Array reference escapes actor isolation
actor Cache {
var items: [Item] = []
func getItems() -> [Item] {
items // Returns reference that can be mutated outside!
}
}
// ✅ Return copy or use value types
actor Cache {
private var items: [Item] = []
func getItems() -> [Item] {
Array(items) // Explicit copy
}
}
NEVER use nonisolated to bypass safety without understanding implications:
// ❌ Dangerous — defeats actor protection
actor DataManager {
var cache: [String: Data] = [:]
nonisolated func unsafeAccess() -> [String: Data] {
cache // DATA RACE — accessing actor state without isolation!
}
}
// ✅ nonisolated only for immutable or independent state
actor DataManager {
let id = UUID() // Immutable — safe
nonisolated var identifier: String {
id.uuidString // Safe — accessing immutable state
}
}
@MainActor
NEVER access @Published from background without MainActor:
// ❌ Undefined behavior — may crash, may corrupt
Task.detached {
viewModel.isLoading = false // Background thread!
}
// ✅ Explicit MainActor
Task { @MainActor in
viewModel.isLoading = false
}
// Or mark entire ViewModel as @MainActor
NEVER block MainActor with synchronous work:
// ❌ UI freezes during heavy computation
@MainActor
func processData() {
let result = heavyComputation(data) // Blocks UI!
display(result)
}
// ✅ Offload to detached task
@MainActor
func processData() async {
let result = await Task.detached {
heavyComputation(data)
}.value
display(result) // Back on MainActor
}
Continuations
NEVER resume continuation more than once:
// ❌ CRASHES — continuation resumed twice
func fetchAsync() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetch { result in
continuation.resume(returning: result)
}
fetch { result in // Oops, called again!
continuation.resume(returning: result) // CRASH!
}
}
}
// ✅ Ensure exactly-once resumption
func fetchAsync() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
var hasResumed = false
fetch { result in
guard !hasResumed else { return }
hasResumed = true
continuation.resume(returning: result)
}
}
}
NEVER forget to resume continuation:
// ❌ Task hangs forever if error path doesn't resume
func fetchAsync() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetch { data, error in
if let data = data {
continuation.resume(returning: data)
}
// Missing else! Continuation never resumed if error
}
}
}
// ✅ Handle all paths
func fetchAsync() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetch { data, error in
if let error = error {
continuation.resume(throwing: error)
} else if let data = data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: FetchError.noData)
}
}
}
}
Essential Patterns
Task-Local Values
enum RequestContext {
@TaskLocal static var requestId: String?
@TaskLocal static var userId: String?
}
// Set context for entire task tree
func handleRequest() async {
await RequestContext.$requestId.withValue(UUID().uuidString) {
await RequestContext.$userId.withValue(currentUserId) {
await processRequest() // All child tasks inherit context
}
}
}
// Access anywhere in task tree
func logEvent(_ message: String) {
let requestId = RequestContext.requestId ?? "unknown"
logger.info("[\(requestId)] \(message)")
}
Cancellation-Aware Loops
func processItems(_ items: [Item]) async throws {
for item in items {
// Check at start of each iteration
try Task.checkCancellation()
// Or handle gracefully without throwing
guard !Task.isCancelled else {
await saveProgress(items: processedItems)
return
}
await process(item)
}
}
AsyncStream from Delegate
func locationUpdates() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
let delegate = LocationDelegate { location in
continuation.yield(location)
}
continuation.onTermination = { @Sendable _ in
delegate.stop()
}
delegate.start()
}
}
Quick Reference
Concurrency Pattern Selection
| Pattern | Use When | Gotcha |
|---|---|---|
async let |
2-5 known parallel operations | Must await all bindings |
TaskGroup |
Dynamic number of operations | Results arrive out of order |
Task { } |
Fire-and-forget with context | Inherits actor isolation |
Task.detached |
True background work | No context inheritance |
actor |
Shared mutable state | Reentrancy on suspension |
Sendable Quick Check
| Type | Sendable? |
|---|---|
| Value types with Sendable properties | ✅ Implicit |
let-only classes |
✅ Add conformance |
| Mutable classes with internal locking | ⚠️ @unchecked Sendable |
| Mutable classes without locking | ❌ Use actor instead |
| Closures | ✅ If marked @Sendable |
Red Flags
| Smell | Problem | Fix |
|---|---|---|
Task { } everywhere |
Losing structured concurrency | Use TaskGroup |
@unchecked Sendable on mutable class |
Potential data race | Use actor or add locking |
nonisolated accessing mutable state |
Data race | Remove nonisolated |
| Continuation without all-paths handling | Potential hang | Handle every code path |
Task.detached for everything |
Losing priority/cancellation | Use structured Task { } |