| name | swift-concurrency |
| description | Expert guidance on Swift Concurrency concepts. Use when working with async/await, Tasks, actors, MainActor, Sendable, isolation domains, or debugging concurrency compiler errors. Helps write safe concurrent Swift code. |
Swift Concurrency Skill
This skill provides expert guidance on Swift's concurrency system based on the mental models from Fucking Approachable Swift Concurrency.
Core Mental Model: The Office Building
Think of your app as an office building where isolation domains are private offices with locks:
- MainActor = Front desk (handles all UI interactions, only one exists)
- actor types = Department offices (Accounting, Legal, HR - each protects its own data)
- nonisolated code = Hallways (shared space, no private documents)
- Sendable types = Photocopies (safe to share between offices)
- Non-Sendable types = Original documents (must stay in one office)
You can't barge into someone's office. You knock (await) and wait.
Async/Await
An async function can pause. Use await to suspend until work finishes:
func fetchUser(id: Int) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
For parallel work, use async let:
async let avatar = fetchImage("avatar.jpg")
async let banner = fetchImage("banner.jpg")
return Profile(avatar: try await avatar, banner: try await banner)
Tasks
A Task is a unit of async work you can manage:
// SwiftUI - cancels when view disappears
.task { avatar = await downloadAvatar() }
// Manual task creation
Task { await saveProfile() }
// Parallel work with TaskGroup
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { avatar = try await downloadAvatar() }
group.addTask { bio = try await fetchBio() }
try await group.waitForAll()
}
Child tasks in a group: cancellation propagates, errors cancel siblings, waits for all to complete.
Isolation Domains
Swift asks "who can access this data?" not "which thread?". Three isolation domains:
1. MainActor
For UI. Everything UI-related should be here:
@MainActor
class ViewModel {
var items: [Item] = [] // Protected by MainActor
}
2. Actors
Protect their own mutable state with exclusive access:
actor BankAccount {
var balance: Double = 0
func deposit(_ amount: Double) { balance += amount }
}
await account.deposit(100) // Must await from outside
3. Nonisolated
Opts out of actor isolation. Cannot access actor's protected state:
actor BankAccount {
nonisolated func bankName() -> String { "Acme Bank" }
}
let name = account.bankName() // No await needed
Approachable Concurrency (Swift 6.2+)
Two build settings that simplify the mental model:
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor: Everything runs on MainActor unless you say otherwise
- SWIFT_APPROACHABLE_CONCURRENCY = YES: nonisolated async functions stay on caller's actor
// Runs on MainActor (default)
func updateUI() async { }
// Runs on background (opt-in)
@concurrent func processLargeFile() async { }
Sendable
Marks types safe to pass across isolation boundaries:
// Sendable - value type, each gets a copy
struct User: Sendable {
let id: Int
let name: String
}
// Non-Sendable - mutable class state
class Counter {
var count = 0
}
Automatically Sendable:
- Structs/enums with only Sendable properties
- Actors (protect their own state)
- @MainActor types (MainActor serializes access)
For thread-safe classes with internal synchronization:
final class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Data] = [:]
}
Isolation Inheritance
With Approachable Concurrency, isolation flows from MainActor through your code:
- Functions: Inherit caller's isolation unless explicitly marked
- Closures: Inherit from context where defined
- Task { }: Inherits actor isolation from creation site
- Task.detached { }: No inheritance (rarely needed)
Common Mistakes to Avoid
1. Thinking async = background
// Still blocks main thread!
@MainActor func slowFunction() async {
let result = expensiveCalculation() // Synchronous = blocking
}
// Fix: Use @concurrent for CPU-heavy work
2. Creating too many actors
Most things can live on MainActor. Only create actors when you have shared mutable state that can't be on MainActor.
3. Making everything Sendable
Not everything needs to cross boundaries. Step back and ask if data actually moves between isolation domains.
4. Using MainActor.run unnecessarily
// Unnecessary
await MainActor.run { self.data = data }
// Better - annotate the function
@MainActor func loadData() async { self.data = await fetchData() }
5. Blocking the cooperative thread pool
Never use DispatchSemaphore, DispatchGroup.wait() in async code. Risks deadlock.
6. Creating unnecessary Tasks
// Bad - unstructured
Task { await fetchUsers() }
Task { await fetchPosts() }
// Good - structured concurrency
async let users = fetchUsers()
async let posts = fetchPosts()
await (users, posts)
Quick Reference
| Keyword | Purpose |
|---|---|
async |
Function can pause |
await |
Pause here until done |
Task { } |
Start async work, inherits context |
Task.detached { } |
Start async work, no context |
@MainActor |
Runs on main thread |
actor |
Type with isolated mutable state |
nonisolated |
Opts out of actor isolation |
Sendable |
Safe to pass between isolation domains |
@concurrent |
Always run on background (Swift 6.2+) |
async let |
Start parallel work |
TaskGroup |
Dynamic parallel work |
When the Compiler Complains
Trace the isolation: Where did it come from? Where is code trying to run? What data crosses a boundary?
The answer is usually obvious once you ask the right question.
Further Reading
- Matt Massicotte's Blog - The source of these mental models
- Swift Concurrency Documentation
- WWDC21: Meet async/await
- WWDC21: Protect mutable state with actors