
Axiom Concurrency
- 692 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-concurrency is an Axiom Claude skill that structures Swift concurrency with actors, tasks, async sequences, and MainActor boundaries for developers who need data-race-free async UI and backend Swift code.
About
axiom-concurrency is a skill from charleswiltgen/axiom focused on Swift concurrency patterns for Apple platform development. It guides structuring async work with actors, structured tasks, async sequences, and MainActor boundaries to avoid data races and UI jank in SwiftUI and concurrent Swift services. Developers reach for axiom-concurrency when refactoring callback-based code to async/await, isolating mutable state in actors, or ensuring UI updates stay on the MainActor during parallel work.
- async/await task composition
- Actor isolation and Sendable conformance
- Structured cancellation and task groups
- MainActor UI update boundaries
- Debugging priority inversion and hangs
Axiom Concurrency by the numbers
- 692 all-time installs (skills.sh)
- Ranked #265 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-concurrencyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 692 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you structure Swift concurrency without data races?
Structure async work with Swift concurrency: actors, tasks, async sequences, and MainActor boundaries without data races or UI jank.
Who is it for?
Swift and SwiftUI developers refactoring to structured concurrency who need actor isolation and MainActor discipline.
Skip if: Non-Swift backends, web frontend frameworks, or teams not using Swift concurrency features.
When should I use this skill?
The user works on Swift async/await, actors, async sequences, MainActor UI updates, or data-race warnings in Apple platform code.
What you get
Actor-isolated Swift code with correct MainActor UI boundaries and structured async task hierarchies.
- Actor-isolated Swift modules
- MainActor-safe UI update patterns
- Structured concurrency refactor
Files
Concurrency
You MUST use this skill for ANY concurrency, async/await, threading, or Swift 6 concurrency work.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| async/await patterns, @MainActor, actors | See skills/swift-concurrency.md |
| Data race errors, Sendable conformance | See skills/swift-concurrency.md |
| Swift 6 migration, @concurrent attribute | See skills/swift-concurrency.md |
| Actor definition, reentrancy, global actors | See skills/swift-concurrency-ref.md |
| Task/TaskGroup/cancellation API | See skills/swift-concurrency-ref.md |
| AsyncStream, continuations | See skills/swift-concurrency-ref.md |
| DispatchQueue → actor migration | See skills/swift-concurrency-ref.md |
| Mutex (iOS 18+), OSAllocatedUnfairLock | See skills/synchronization.md |
| Atomic types, lock vs actor decision | See skills/synchronization.md |
| MainActor.assumeIsolated | See skills/assume-isolated.md |
| @preconcurrency protocol conformances | See skills/assume-isolated.md |
| Legacy delegate callbacks | See skills/assume-isolated.md |
Warning-free build crashes with _dispatch_assert_queue_fail | See skills/isolation-inheritance-diag.md |
Crash signature _swift_task_checkIsolatedSwift | See skills/isolation-inheritance-diag.md |
Core Data context.perform runtime crash inside @MainActor class | See skills/isolation-inheritance-diag.md |
Combine .map/.sink crash from receive(on:) placement | See skills/isolation-inheritance-diag.md |
| Delegate method crash from isolation inheritance (CLLocationManager, NSDocument, AVAudioPlayerDelegate, WKNavigationDelegate) | See skills/isolation-inheritance-diag.md |
| Actor reentrancy / stale state across await | See skills/isolation-inheritance-diag.md |
| Swift Concurrency Instruments template | See skills/concurrency-profiling.md |
| Actor contention diagnosis | See skills/concurrency-profiling.md |
| Thread pool exhaustion | See skills/concurrency-profiling.md |
Decision Tree
digraph concurrency {
start [label="Concurrency task" shape=ellipse];
what [label="What do you need?" shape=diamond];
start -> what;
what -> "skills/swift-concurrency.md" [label="async/await, actors,\nSendable, data races,\nSwift 6 migration"];
what -> "skills/swift-concurrency-ref.md" [label="API syntax lookup\n(TaskGroup, AsyncStream,\ncontinuations, migration)"];
what -> "skills/synchronization.md" [label="Mutex, locks,\natomic types"];
what -> "skills/assume-isolated.md" [label="assumeIsolated,\n@preconcurrency"];
what -> "skills/isolation-inheritance-diag.md" [label="warning-free build crashes\n_dispatch_assert_queue_fail\n_swift_task_checkIsolatedSwift"];
what -> "skills/concurrency-profiling.md" [label="profile async perf,\nactor contention"];
}1. Data races / actor isolation / @MainActor / Sendable / Swift 6 migration? → skills/swift-concurrency.md 1a. Need specific API syntax (actor definition, TaskGroup, AsyncStream, continuations)? → skills/swift-concurrency-ref.md 2. Writing async/await code? → skills/swift-concurrency.md 3. assumeIsolated / @preconcurrency? → skills/assume-isolated.md 3a. Warning-free Swift 6 build that crashes in production with _dispatch_assert_queue_fail or _swift_task_checkIsolatedSwift? → skills/isolation-inheritance-diag.md 4. Mutex / lock / synchronization? → skills/synchronization.md 5. Profile async performance / actor contention? → skills/concurrency-profiling.md 6. Value type / ARC / generic optimization? → See axiom-performance (skills/swift-performance.md) 7. borrowing / consuming / ~Copyable? → See axiom-swift (skills/ownership-conventions.md) 8. Combine / @Published / AnyCancellable / reactive streams? → See axiom-uikit (skills/combine-patterns.md) 9. Want automated concurrency scan? → concurrency-auditor (Agent)
Concurrency in practice
- HealthKit queries with Swift Concurrency (canonical bridging example) → See axiom-health (skills/queries.md)
Conflict Resolution
concurrency vs axiom-performance: When app freezes or feels slow: 1. Try concurrency FIRST — Main thread blocking is the #1 cause of UI freezes. Check for synchronous work on @MainActor before profiling. 2. Only use axiom-performance if concurrency fixes don't help — Profile after ruling out obvious blocking. 3. To pin a specific freeze to app code, see the Hang Window Workflow in axiom-performance (skills/hang-diagnostics.md) — re-scope xcprof analyze to the hang window with --start-ms/--end-ms --user-binary to surface the app-owned frame on the main thread.
concurrency vs axiom-build: When seeing Swift 6 concurrency errors:
- Use concurrency, NOT axiom-build — Concurrency errors are CODE issues, not environment issues.
concurrency vs axiom-data: When concurrency errors involve Core Data or SwiftData:
- Core Data threading (NSManagedObjectContext thread confinement) → use axiom-data first
- SwiftData + @MainActor ModelContext → use concurrency
- General "background saves losing data" → use axiom-data first
- GRDB Sendable patterns (struct records,
databaseSelectionas computed property, Swift 6 conformance) → See axiom-data (skills/grdb-performance.md) §8
Critical Patterns
Swift Concurrency (skills/swift-concurrency.md):
- Progressive journey: single-threaded → async → concurrent → actors
- @concurrent attribute for forced background execution
- Isolated conformances, main actor mode
- 12 copy-paste patterns including delegate value capture, weak self in Tasks
- Comprehensive decision tree for 7 common error messages
API Reference (skills/swift-concurrency-ref.md):
- Actor definition, reentrancy, global actors, nonisolated
- Sendable patterns, @unchecked Sendable, sending parameter
- Task/TaskGroup/cancellation, async let, withDiscardingTaskGroup
- AsyncStream, continuations, buffering policies
- Isolation patterns (#isolation, @preconcurrency, nonisolated(unsafe))
- DispatchQueue/DispatchGroup/completion handler migration
Synchronization (skills/synchronization.md):
- Mutex (iOS 18+), OSAllocatedUnfairLock (iOS 16+), Atomic types
- Lock vs actor decision tree
- Danger patterns: locks across await, semaphores in async context
Profiling (skills/concurrency-profiling.md):
- Swift Concurrency Instruments template
- Diagnosing main thread blocking, actor contention, thread pool exhaustion
- Safe vs unsafe primitives for cooperative pool
Runtime Isolation Crashes (skills/isolation-inheritance-diag.md):
_dispatch_assert_queue_failand_swift_task_checkIsolatedSwiftsignatures- Closure isolation inheritance (Core Data
perform, Combine.map, NotificationCenter.sink) - Delegate method isolation inheritance (CLLocationManager, NSDocument, AVAudioPlayerDelegate, WKNavigationDelegate)
MainActor.assumeIsolatedmisuse- Actor reentrancy state staleness
Automated Scanning
Concurrency audit → Launch concurrency-auditor agent or /axiom:audit concurrency (5-phase semantic audit: maps isolation architecture, detects 8 anti-patterns, reasons about missing concurrency patterns, correlates compound risks, scores Swift 6.4 readiness)
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Just add @MainActor and it'll work" | @MainActor has isolation inheritance rules. skills/swift-concurrency.md covers all patterns. |
| "I'll use nonisolated(unsafe) to silence the warning" | Silencing warnings hides data races. skills/swift-concurrency.md shows the safe pattern. |
| "It's just one async call" | Even single async calls have cancellation and isolation implications. |
| "I know how actors work" | Actor reentrancy and isolation rules changed in Swift 6.2. |
| "I'll fix the Sendable warnings later" | Sendable violations cause runtime crashes. Fix them now. |
| "My Swift 6 build has zero warnings, so isolation is correct" | Static checking can't see SDK callbacks. Runtime checks crash anyway. skills/isolation-inheritance-diag.md. |
"I'll wrap the crash in MainActor.assumeIsolated" | assumeIsolated is a runtime trap, not a silencer. Wrong assumption = crash. |
| "Combine is dead, just use async/await" | Combine has no deprecation notice. Rewriting working pipelines wastes time. See See axiom-uikit (skills/combine-patterns.md). |
| "I'll use @unchecked Sendable to silence this" | You're hiding a data race from the compiler. It will crash in production. |
| "This async function runs on a background thread" | async suspends without blocking but resumes on the same actor. Use @concurrent to force background. |
Example Invocations
User: "I'm getting 'data race' errors in Swift 6" → Read: skills/swift-concurrency.md
User: "How do I use @MainActor correctly?" → Read: skills/swift-concurrency.md
User: "How do I create a TaskGroup?" → Read: skills/swift-concurrency-ref.md
User: "What's the AsyncStream API?" → Read: skills/swift-concurrency-ref.md
User: "How do I use assumeIsolated?" → Read: skills/assume-isolated.md
User: "Should I use Mutex or actor?" → Read: skills/synchronization.md
User: "My async code is slow, how do I profile it?" → Read: skills/concurrency-profiling.md
User: "My warning-free Swift 6 build crashes in production with _dispatch_assert_queue_fail" → Read: skills/isolation-inheritance-diag.md
User: "Core Data context.perform crashes inside an @MainActor view model" → Read: skills/isolation-inheritance-diag.md
User: "CLLocationManager delegate method is crashing with _swift_task_checkIsolatedSwift" → Read: skills/isolation-inheritance-diag.md
User: "My app is slow due to unnecessary copying" → See axiom-performance (skills/swift-performance.md)
User: "Check my code for Swift 6 concurrency issues" → Invoke: concurrency-auditor agent
assumeIsolated — Synchronous Actor Access
Synchronously access actor-isolated state when you know you're already on the correct isolation domain.
When to Use
✅ Use when:
- Testing MainActor code synchronously (avoiding Task overhead)
- Legacy delegate callbacks documented to run on main thread
- Performance-critical code avoiding async hop overhead
- Protocol conformances where callbacks are guaranteed on specific actor
❌ Don't use when:
- Uncertain about current isolation (use
awaitinstead) - Already in async context (you have isolation)
- Cross-actor calls needed (use async)
- Callback origin is unknown or untrusted
API Reference
MainActor.assumeIsolated
static func assumeIsolated<T>(
_ operation: @MainActor () throws -> T,
file: StaticString = #fileID,
line: UInt = #line
) rethrows -> T where T: SendableBehavior: Executes synchronously. Crashes if not on MainActor's serial executor.
Custom Actor assumeIsolated
func assumeIsolated<T>(
_ operation: (isolated Self) throws -> T,
file: StaticString = #fileID,
line: UInt = #line
) rethrows -> T where T: SendableTask vs assumeIsolated
| Aspect | Task { @MainActor in } | MainActor.assumeIsolated |
|---|---|---|
| Timing | Deferred (next run loop) | Synchronous (inline) |
| Async support | Yes (can await) | No (sync only) |
| Context | From any context | Must be sync function |
| Failure mode | Runs anyway | Crashes if wrong isolation |
| Use case | Start async work | Verify + access isolated state |
Patterns
Pattern 1: Testing MainActor Code
@Test func viewModelUpdates() {
MainActor.assumeIsolated {
let vm = ViewModel()
vm.update()
#expect(vm.state == .updated)
}
}Pattern 2: Legacy Delegate Callbacks
From WWDC 2024-10169 — When documentation guarantees main thread delivery:
@MainActor
class LocationDelegate: NSObject, CLLocationManagerDelegate {
var location: CLLocation?
// CLLocationManager created on main thread delivers callbacks on main thread
nonisolated func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
MainActor.assumeIsolated {
self.location = locations.last
}
}
}Pattern 3: @preconcurrency Shorthand
@preconcurrency is equivalent shorthand — wraps in assumeIsolated automatically:
// ❌ Manual approach (verbose)
extension MyClass: SomeDelegate {
nonisolated func callback() {
MainActor.assumeIsolated {
self.updateUI()
}
}
}
// ✅ Using @preconcurrency (equivalent, cleaner)
extension MyClass: @preconcurrency SomeDelegate {
func callback() {
self.updateUI() // Compiler wraps in assumeIsolated
}
}When protocol adds isolation: @preconcurrency becomes unnecessary and compiler warns.
Pattern 4: Thread Check Before assumeIsolated
When caller context is unknown (e.g., library code):
func getView() -> UIView {
if Thread.isMainThread {
return createHostingViewOnMain()
} else {
return DispatchQueue.main.sync {
createHostingViewOnMain()
}
}
}
private func createHostingViewOnMain() -> UIView {
MainActor.assumeIsolated {
let hosting = UIHostingController(rootView: MyView())
return hosting.view
}
}Pattern 5: Custom Actor Access
actor DataStore {
var cache: [String: Data] = [:]
nonisolated func synchronousRead(key: String) -> Data? {
// Only safe if called from DataStore's executor
assumeIsolated { isolated in
isolated.cache[key]
}
}
}Common Mistakes
Mistake 1: Silencing Compiler Errors
// ❌ DANGEROUS: Using assumeIsolated to silence warnings
func unknownContext() {
MainActor.assumeIsolated {
updateUI() // Crashes if not actually on main actor!
}
}
// ✅ When uncertain, use proper async
func unknownContext() async {
await MainActor.run {
updateUI()
}
}Mistake 2: Assuming GCD Main Queue == MainActor
They're usually the same, but not guaranteed. Check documentation or use async.
Mistake 3: Using in Async Context
// ❌ Unnecessary — you already have isolation
@MainActor
func updateState() async {
MainActor.assumeIsolated { // Pointless
self.state = .ready
}
}
// ✅ Direct access
@MainActor
func updateState() async {
self.state = .ready
}When @preconcurrency Becomes Unnecessary
If the protocol later adds MainActor isolation:
// Library update:
@MainActor
protocol CaffeineThresholdDelegate: AnyObject {
func caffeineLevel(at level: Double)
}
// Your code — @preconcurrency now warns:
// "@preconcurrency attribute on conformance has no effect"
extension Recaffeinater: CaffeineThresholdDelegate {
func caffeineLevel(at level: Double) {
// Direct access, no wrapper needed
}
}Crash Behavior
Per Apple documentation:
"If the current context is not running on the actor's serial executor... this method will crash with a fatal error."
Trapping is intentional: Better to crash than corrupt user data with a race condition.
Resources
WWDC: 2024-10169
Docs: /swift/mainactor/assumeisolated, /swift/actor/assumeisolated
Skills: See skills/swift-concurrency.md, skills/isolation-inheritance-diag.md (full crash-signature diagnostic — _dispatch_assert_queue_fail, _swift_task_checkIsolatedSwift)
Concurrency Profiling — Instruments Workflows
Profile and optimize Swift async/await code using Instruments.
When to Use
✅ Use when:
- UI stutters during async operations
- Suspecting actor contention
- Tasks queued but not executing
- Main thread blocked during async work
- Need to visualize task execution flow
❌ Don't use when:
- Issue is pure CPU performance (use Time Profiler)
- Memory issues unrelated to concurrency (use Allocations)
- Haven't confirmed concurrency is the bottleneck
Swift Concurrency Template
What It Shows
| Track | Information |
|---|---|
| Swift Tasks | Task lifetimes, parent-child relationships |
| Swift Actors | Actor access, contention visualization |
| Thread States | Blocked vs running vs suspended |
Statistics
- Running Tasks: Tasks currently executing
- Alive Tasks: Tasks present at a point in time
- Total Tasks: Cumulative count created
Color Coding
- Blue: Task executing
- Red: Task waiting (contention)
- Gray: Task suspended (awaiting)
Workflow 1: Diagnose Main Thread Blocking
Symptom: UI freezes, main thread timeline full
1. Profile with Swift Concurrency template 2. Look at main thread → "Swift Tasks" lane 3. Find long blue bars (task executing on main) 4. Check if work could be offloaded
Solution patterns:
// ❌ Heavy work on MainActor
@MainActor
class ViewModel: ObservableObject {
func process() {
let result = heavyComputation() // Blocks UI
self.data = result
}
}
// ✅ Offload heavy work
@MainActor
class ViewModel: ObservableObject {
func process() async {
// `Task.detached` is correct here — `Task {}` would inherit @MainActor
// and run `heavyComputation()` ON the main thread. In Swift 6.2+,
// prefer marking `heavyComputation()` `@concurrent` and calling it directly.
let result = await Task.detached {
heavyComputation()
}.value
self.data = result
}
}Workflow 2: Find Actor Contention
Symptom: Tasks serializing unexpectedly, parallel work running sequentially
1. Enable "Swift Actors" instrument 2. Look for serialized access patterns 3. Red = waiting, Blue = executing 4. High red:blue ratio = contention problem
Solution patterns:
// ❌ All work serialized through actor
actor DataProcessor {
func process(_ data: Data) -> Result {
heavyProcessing(data) // All callers wait
}
}
// ✅ Mark heavy work as nonisolated
actor DataProcessor {
nonisolated func process(_ data: Data) -> Result {
heavyProcessing(data) // Runs in parallel
}
func storeResult(_ result: Result) {
// Only actor state access serialized
}
}More fixes:
- Split actor into multiple (domain separation)
- Use Mutex for hot paths (faster than actor hop)
- Reduce actor scope (fewer isolated properties)
Workflow 3: Thread Pool Exhaustion
Symptom: Tasks queued but not executing, gaps in task execution
Cause: Blocking calls exhaust cooperative pool
1. Look for gaps in task execution across all threads 2. Check for blocking primitives 3. Replace with async equivalents
Common culprits:
// ❌ Blocks cooperative thread
Task {
semaphore.wait() // NEVER do this
// ...
semaphore.signal()
}
// ❌ Synchronous file I/O in async context
Task {
let data = Data(contentsOf: fileURL) // Blocks
}
// ✅ Use async APIs
Task {
let (data, _) = try await URLSession.shared.data(from: fileURL)
}Debug flag:
SWIFT_CONCURRENCY_COOPERATIVE_THREAD_BOUNDS=1Detects unsafe blocking in async context.
Workflow 4: Priority Inversion
Symptom: High-priority task waits for low-priority
1. Inspect task priorities in Instruments 2. Follow wait chains 3. Ensure critical paths use appropriate priority
// ✅ Explicit priority for critical work
Task(priority: .userInitiated) {
await criticalUIUpdate()
}Thread Pool Model
Swift uses a cooperative thread pool matching CPU core count:
| Aspect | GCD | Swift Concurrency |
|---|---|---|
| Threads | Grows unbounded | Fixed to core count |
| Blocking | Creates new threads | Suspends, frees thread |
| Dependencies | Hidden | Runtime-tracked |
| Context switch | Full kernel switch | Lightweight continuation |
Why blocking is catastrophic:
- Each blocked thread holds memory + kernel structures
- Limited threads means blocked = no progress
- Pool exhaustion deadlocks the app
Quick Checks (Before Profiling)
Run these checks first:
1. Is work actually async?
- Look for suspension points (
await) - Sync code in async function still blocks
2. Holding locks across await?
// ❌ Deadlock risk
mutex.withLock {
await something() // Never!
}3. Tasks in tight loops?
// ❌ Overhead may exceed benefit
for item in items {
Task { process(item) }
}
// ✅ Structured concurrency
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask { process(item) }
}
}4. DispatchSemaphore in async context?
- Always unsafe — use
withCheckedContinuationinstead
Common Issues Summary
| Issue | Symptom in Instruments | Fix |
|---|---|---|
| MainActor overload | Long blue bars on main | Task.detached, nonisolated |
| Actor contention | High red:blue ratio | Split actors, use nonisolated |
| Thread exhaustion | Gaps in all threads | Remove blocking calls |
| Priority inversion | High-pri waits for low-pri | Check task priorities |
| Too many tasks | Task creation overhead | Use task groups |
Safe vs Unsafe Primitives
Safe with cooperative pool:
await, actors, task groupsos_unfair_lock,NSLock(short critical sections)Mutex(iOS 18+)
Unsafe (violate forward progress):
DispatchSemaphore.wait()pthread_cond_wait- Sync file/network I/O
Thread.sleep()in Task
Resources
WWDC: 2022-110350, 2021-10254
Docs: /xcode/improving-app-responsiveness
Skills: See skills/swift-concurrency.md, axiom-performance (skills/performance-profiling.md), see skills/synchronization.md, axiom-build (skills/lldb.md) (interactive thread state inspection)
Runtime Isolation Crashes — Diagnostic
Use this when a warning-free Swift 6 build crashes in production with `_dispatch_assert_queue_fail` or `_swift_task_checkIsolatedSwift`.
Strict concurrency catches data races at compile time. It does not catch all of them. The compiler injects runtime isolation assertions at actor and GCD boundaries — these fire in production even with zero warnings.
Crash Signatures
Recognize these in .ips files, MetricKit reports, or xcsym crash output:
| Symbol | Meaning |
|---|---|
_dispatch_assert_queue_fail | Code expected a specific dispatch queue, ran on a different one |
_swift_task_checkIsolatedSwift | Code expected actor isolation (e.g. @MainActor), ran outside it |
swift_task_checkIsolated | Same family — runtime isolation guard tripped |
Both originate from the same root cause: a closure or method inherited actor isolation from its enclosing context, then an SDK called it on a different thread.
Why a Warning-Free Build Still Crashes
Static isolation checking cannot see through framework callbacks, delegate dispatch, or GCD bridges. When isolation is ambiguous, the compiler inserts a runtime check rather than rejecting the code. If the assumption is wrong at runtime, the process traps immediately.
Zero warnings means "the type system is happy", not "the runtime invariants hold."
Swift 5 mode would silently run the offending code on the wrong thread. Swift 6 mode preemptively crashes rather than continuing in an unsafe state.
Red Flag — nonisolated(unsafe) produces a warning-free build that crashes in production
nonisolated(unsafe) and blanket @MainActor do not fix isolation — they delete the compiler's evidence that something is wrong. The build goes green, then the runtime isolation assertion (_swift_task_checkIsolatedSwift / _dispatch_assert_queue_fail) fires on a real device because the actual thread still violates the isolation the runtime expects.
Warning-free ≠ runtime-safe. "Slap @MainActor everywhere and nonisolated(unsafe) to silence it" trades a compile-time error you can see for a production crash you can't. The compiler was the cheap warning; the runtime trap is the expensive one. Every shortcut in this file moves the failure later and more expensive, never away.
nonisolated(unsafe) is legitimate only for a value you can prove is never accessed concurrently (e.g. a let set once before any task spawns) — never as a way to quiet a real cross-actor access.
Diagnosis Decision Tree
digraph diag {
"Crash symbol?" [shape=diamond];
"Closure passed to SDK?" [shape=diamond];
"Delegate method on @MainActor class?" [shape=diamond];
"Combine pipeline?" [shape=diamond];
"Used assumeIsolated?" [shape=diamond];
"Pattern 1: Closure isolation inheritance" [shape=box];
"Pattern 2: Delegate isolation inheritance" [shape=box];
"Pattern 3: assumeIsolated misuse" [shape=box];
"Pattern 4: Actor reentrancy staleness" [shape=box];
"Crash symbol?" -> "Closure passed to SDK?" [label="_dispatch_assert_queue_fail"];
"Crash symbol?" -> "Delegate method on @MainActor class?" [label="_swift_task_checkIsolatedSwift"];
"Closure passed to SDK?" -> "Pattern 1: Closure isolation inheritance" [label="context.perform, .map, .sink"];
"Closure passed to SDK?" -> "Combine pipeline?" [label="not Core Data"];
"Combine pipeline?" -> "Pattern 1: Closure isolation inheritance" [label="yes — receive(on:) placement matters"];
"Delegate method on @MainActor class?" -> "Pattern 2: Delegate isolation inheritance" [label="yes"];
"Delegate method on @MainActor class?" -> "Used assumeIsolated?" [label="no"];
"Used assumeIsolated?" -> "Pattern 3: assumeIsolated misuse" [label="yes"];
"Used assumeIsolated?" -> "Pattern 4: Actor reentrancy staleness" [label="no — check state across await"];
}Pattern 1 — Closures Inherit Actor Isolation
A closure defined inside an @MainActor-isolated context inherits that isolation. The compiler marks it main-actor-isolated and inserts a runtime assertion. If the framework calls it on a background thread, the assertion fires.
Core Data context.perform
// ❌ CRASHES with _dispatch_assert_queue_fail
@MainActor
class ContactsViewModel {
func deleteAll(context: NSManagedObjectContext) {
context.perform {
// Inherits @MainActor from enclosing method.
// Core Data runs it on its private background queue. Trap.
let request = NSFetchRequest<Contact>(entityName: "Contact")
let contacts = try? context.fetch(request)
contacts?.forEach { context.delete($0) }
}
}
}Fix — mark the closure `@Sendable`. A @Sendable closure has no implied actor context, so no runtime assertion is injected.
// ✅ Works — @Sendable opts out of isolation inheritance
context.perform { @Sendable in
let request = NSFetchRequest<Contact>(entityName: "Contact")
let contacts = try? context.fetch(request)
contacts?.forEach { context.delete($0) }
}See axiom-data (skills/core-data.md) for the broader Core Data threading patterns.
Combine .map, .filter, etc.
// ❌ CRASHES — .map closure inherits @MainActor, publisher emits off-main
@MainActor
class SearchViewModel {
func subscribe() {
searchPublisher
.map { value in // inherits @MainActor from subscribe()
value.lowercased()
}
.receive(on: DispatchQueue.main) // too late — .map already crashed
.sink { self.results = $0 }
.store(in: &cancellables)
}
}Fix A — move `.receive(on:)` before any isolated operator. The thread hop happens first, so the closure runs on main where its isolation is satisfied.
// ✅ Works — hop to main before isolated closures
searchPublisher
.receive(on: DispatchQueue.main)
.map { value in value.lowercased() }
.sink { self.results = $0 }
.store(in: &cancellables)Fix B — `@Sendable` if the operator should run off-main.
// ✅ Works — explicit non-isolated transformation
searchPublisher
.map { @Sendable value in value.lowercased() }
.receive(on: DispatchQueue.main)
.sink { self.results = $0 }
.store(in: &cancellables)NotificationCenter .sink
// ❌ CRASHES if notification is posted off-main inside @MainActor class
NotificationCenter.default.publisher(for: .didRefresh)
.sink { [weak self] _ in
self?.reload() // sink inherits @MainActor, fires on poster's thread
}
.store(in: &cancellables)Fix — insert `.receive(on: DispatchQueue.main)` before `.sink`.
// ✅ Works
NotificationCenter.default.publisher(for: .didRefresh)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.reload() }
.store(in: &cancellables)Pattern 2 — Delegate Methods Inherit Isolation Too
When an entire class is @MainActor-isolated, every method inherits that isolation, including delegate overrides. If an SDK calls a delegate method from its own internal queue, the runtime check fires.
NSDocument
// ❌ CRASHES — AppKit calls autosavesInPlace from a background queue
@MainActor
class MyDocument: NSDocument {
override class var autosavesInPlace: Bool { true }
}Fix — mark the specific method `nonisolated`. Leave the rest of the class on the main actor.
// ✅ Works
override nonisolated class var autosavesInPlace: Bool { true }CLLocationManagerDelegate
// ❌ CRASHES — CLLocationManager delivers updates on its own queue
@MainActor
class LocationManager: NSObject, CLLocationManagerDelegate {
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
updateMap(with: locations) // inherits @MainActor, crashes
}
}Fix — `nonisolated` on the delegate method, then `Task { @MainActor in }` for UI work.
// ✅ Works
nonisolated func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
Task { @MainActor in
self.updateMap(with: locations)
}
}Getting off the main actor — Task { @concurrent in }, not Task.detached
When the goal is the opposite direction — leave @MainActor to do real background work — the reflex is Task.detached. It works, but it drops everything the originating task carried: no priority inheritance and no task-local values, which silently severs trace IDs, logging metadata MDC, and any other @TaskLocal context. Crashes vanish; observability quietly does too.
Task { @concurrent in } (Swift 6.2+) is the correct tool: it leaves the actor and runs on the concurrent pool while preserving the originating priority and task-locals.
// ❌ Off-main, but task-locals (trace ID, log context) are gone
Task.detached(priority: .utility) {
await indexDocuments() // logs here have no request context
}
// ✅ Off-main AND keeps priority + @TaskLocal values
Task { @concurrent in
await indexDocuments() // trace ID still flows through
}Reach for Task.detached only when you deliberately want a clean slate (no inherited cancellation, priority, or task-locals). Defaulting to it for "just run this in the background" is the trap.
Other delegates with the same trap
AVAudioPlayerDelegate— audio completion callbacksWKNavigationDelegate— navigation callbacks may arrive off-mainURLSessionDelegate— completion handlers run on session's delegate queue- Any third-party SDK delegate that does not document main-thread delivery
General rule If a delegate protocol does not document main-thread delivery, treat its methods as background-thread by default and mark them nonisolated.
Pattern 3 — MainActor.assumeIsolated Misuse
MainActor.assumeIsolated is a runtime assertion, not a thread hop. It crashes immediately if the code is not actually on the main actor.
Using it as a synchronous alternative to await MainActor.run in arbitrary contexts will crash whenever the assumption fails.
// ❌ DANGEROUS — assumeIsolated used in a context that might not be on main
func handleCallback() {
MainActor.assumeIsolated { // crashes if called off-main
updateUI()
}
}
// ✅ Use only when you KNOW you're on main (legacy delegate documented to deliver on main)
nonisolated func legacyCallback() {
// SDK guarantees main-thread delivery
MainActor.assumeIsolated {
updateUI()
}
}
// ✅ Or use proper async hop when uncertain
func handleCallback() async {
await MainActor.run { updateUI() }
}See skills/assume-isolated.md for the full assumeIsolated decision matrix.
Prefer compiler-checked escape hatches over assumeIsolated
assumeIsolated is a runtime assertion — it's the right tool only when you can prove the code is already on the actor (a legacy delegate documented to deliver on main). For everything else, Swift 6.2 has escape hatches the compiler verifies, so the failure surfaces at build time instead of as a device crash. Try these first:
| Situation | Compiler-checked tool | What it does |
|---|---|---|
Protocol witness can't satisfy a nonisolated requirement from a @MainActor type | Isolated conformance: extension T: @MainActor P (SE-0470) | Pins the conformance to the main actor; the requirement is satisfied without nonisolated or a trap |
| Conforming to an old, un-annotated protocol whose callbacks are actually main-thread | @preconcurrency on the conformance | Suppresses Sendable diagnostics for the legacy declaration without unsafe |
@Sendable closure / API can't capture non-Sendable self once | sending parameter (SE-0430) | Transfers the value across the boundary one time; compiler proves the caller stops using it |
| Async helper needs a non-Sendable delegate to stay on the caller's actor | isolated (any Actor)? = #isolation (SE-0420) | Inherits the caller's isolation so the value never crosses a boundary |
// Witness case: @MainActor type vs a nonisolated protocol requirement.
// ❌ Reflex: mark the witness nonisolated, then assumeIsolated inside → crashes if off-main
// ✅ Isolated conformance — compiler-verified, no runtime trap
@MainActor final class Renderer: @MainActor Drawable {
func draw() { /* main-actor work, no nonisolated, no assumeIsolated */ }
}
// Capture case: hand a non-Sendable value to a @Sendable boundary exactly once.
func enqueue(_ job: sending Job) { // SE-0430
Task { @concurrent in await job.run() } // compiler proves no shared access
}Pattern 4 — Actor Reentrancy State Staleness
Not a hard crash, but a precondition failure or silent corruption. After an await inside an actor method, the actor unlocks and other tasks can mutate state. State captured before suspension may be stale after it.
// ❌ Bug — `cached` may be stale after the await
actor ImageCache {
var images: [URL: UIImage] = [:]
func image(for url: URL) async -> UIImage? {
let cached = images[url] // read before await
if cached == nil {
let downloaded = await download(url) // ← reentrancy point
images[url] = downloaded // stale `cached` no longer relevant
return downloaded
}
return cached
}
}Fix — re-check state after every `await`, or restructure to avoid the gap.
// ✅ Works — re-check after suspension
actor ImageCache {
var images: [URL: UIImage] = [:]
func image(for url: URL) async -> UIImage? {
if let cached = images[url] { return cached }
let downloaded = await download(url)
// Another task may have populated images[url] during await — prefer existing
if let existing = images[url] { return existing }
images[url] = downloaded
return downloaded
}
}Testing Implication
These crashes only surface with real SDK callbacks and background-thread publishers. Unit tests that drive code paths synchronously on the main thread will not trigger the runtime assertions.
Add to your test plan
- Drive Core Data through
context.performfrom background-spawned tasks - Push notifications from
DispatchQueue.global().async { NotificationCenter.default.post(...) } - Exercise location/audio/network delegates on real devices, not just mocks
- Validate Combine pipelines by sending values on non-main schedulers
- Run integration tests on iOS 17.4+ where Swift 6 runtime assertions are strictest
See axiom-testing (skills/swift-testing.md) for testing async code that exercises real SDK callbacks.
Anti-Rationalizations
| Thought | Reality |
|---|---|
| "My build is warning-free, so Swift 6 isolation is correct" | Static checking can't see through SDK callbacks. Runtime assertions fire anyway. |
"Slap @MainActor everywhere + nonisolated(unsafe) to silence it and ship" | That deletes the compiler's evidence, not the bug. Warning-free build crashes in production at _swift_task_checkIsolatedSwift. Warning-free ≠ runtime-safe. |
"Just use Task.detached to get off the main actor" | Task.detached drops priority and all @TaskLocal values — trace/log context silently lost. Use Task { @concurrent in } (Swift 6.2+). |
"assumeIsolated is my escape hatch for isolation errors" | It's a crashing assertion, valid only when provably on-actor. Prefer compiler-checked extension T: @MainActor P, sending, or #isolation. |
"I'll wrap it in MainActor.assumeIsolated to silence the warning" | assumeIsolated is a runtime trap, not a silencer. It crashes when the assumption is wrong. |
"Adding @Sendable is the same as @unchecked Sendable" | @Sendable on a closure breaks isolation inheritance. @unchecked Sendable on a type hides data races. |
"I'll just remove @MainActor from the class" | Now you have data races on UI state. The class-level isolation is correct — fix the specific method/closure. |
"I'll use DispatchQueue.main.async inside the delegate method" | Works, but nonisolated + Task { @MainActor in } is the Swift 6 idiom and integrates with structured concurrency. |
".receive(on:) position doesn't matter — it's still in the pipeline" | Operators run in order. Any isolated closure before .receive(on:) runs on the upstream thread. |
| "Tests pass, so it works" | Mocked tests don't exercise SDK threading. Real-device integration tests do. |
Cross-References
skills/swift-concurrency.md— Core Swift 6 concurrency patterns (isolation rules,@concurrent)skills/assume-isolated.md— FullassumeIsolatedpatterns and when it's safeskills/swift-concurrency-ref.md—nonisolated,@Sendable, isolation syntax reference- axiom-data (skills/core-data.md) — Core Data threading model and
context.performpatterns - axiom-uikit (skills/combine-patterns.md) — Combine schedulers and
.receive(on:)placement - crash-analyzer (Agent) — Recognizes these crash signatures via pattern tags
Resources
WWDC: 2024-10169, 2025-268
Docs: /swift/sendable, /swift/mainactor, /coredata/nsmanagedobjectcontext/perform
Skills: assume-isolated, swift-concurrency, swift-concurrency-ref
External: Khoa Pham — "How to avoid Swift 6 concurrency crashes" (onmyway133.com)
Swift Concurrency API Reference
Complete Swift concurrency API reference for copy-paste patterns and syntax lookup.
Complements skills/swift-concurrency.md (which covers when and why to use concurrency — progressive journey, decision trees, @concurrent, isolated conformances).
Related references: skills/swift-concurrency.md (progressive journey, decision trees), skills/synchronization.md (Mutex, locks), skills/assume-isolated.md (assumeIsolated patterns)
Part 1: Actor Patterns
Actor Definition
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func store(_ image: UIImage, for url: URL) {
cache[url] = image
}
}
// Usage — must await across isolation boundary
let cache = ImageCache()
let image = await cache.image(for: url)All properties and methods on an actor are isolated by default. Callers outside the actor's isolation domain must use await to access them.
Actor Isolation Rules
Every actor's stored properties and methods are isolated to that actor. Access from outside the isolation boundary requires await, which suspends the caller until the actor can process the request.
actor Counter {
var count = 0 // Isolated — external access requires await
let name: String // let constants are implicitly nonisolated
func increment() { // Isolated — await required from outside
count += 1
}
nonisolated func identity() -> String {
name // OK: accessing nonisolated let
}
}
let counter = Counter(name: "main")
await counter.increment() // Must await across isolation boundary
let id = counter.identity() // No await needed — nonisolatednonisolated Keyword
Opt out of isolation for synchronous access to non-mutable state.
actor MyActor {
let id: UUID // let constants are implicitly nonisolated
nonisolated var description: String {
"Actor \(id)" // Can only access nonisolated state
}
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(id) // Only nonisolated properties
}
}nonisolated methods cannot access any isolated stored properties. Use this for protocol conformances (like Hashable, CustomStringConvertible) that require synchronous access.
Actor Reentrancy
Suspension points (await) inside an actor allow other callers to interleave. State may change between any two await expressions.
actor BankAccount {
var balance: Double = 0
func transfer(amount: Double, to other: BankAccount) async {
guard balance >= amount else { return }
balance -= amount
// REENTRANCY HAZARD: another caller could modify balance here
// while we await the deposit on the other actor
await other.deposit(amount)
}
func deposit(_ amount: Double) {
balance += amount
}
}Pattern: Re-check state after every await inside an actor:
actor BankAccount {
var balance: Double = 0
func transfer(amount: Double, to other: BankAccount) async -> Bool {
guard balance >= amount else { return false }
balance -= amount
await other.deposit(amount)
// Re-check invariants after await if needed
return true
}
}Global Actors
A global actor provides a single shared isolation domain accessible from anywhere.
@globalActor
actor MyGlobalActor {
static let shared = MyGlobalActor()
}
@MyGlobalActor
func doWork() { /* isolated to MyGlobalActor */ }
@MyGlobalActor
class MyService {
var state: Int = 0 // Isolated to MyGlobalActor
}@MainActor
The built-in global actor for UI work. All UI updates must happen on @MainActor.
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
let data = await fetchFromNetwork()
items = data // Safe: already on MainActor
}
}
// Annotate individual members
class MixedService {
@MainActor var uiState: String = ""
@MainActor
func updateUI() {
uiState = "Done"
}
func backgroundWork() async -> String {
await heavyComputation()
}
}Subclass inheritance: If a class is @MainActor, all subclasses inherit that isolation.
Actor Init
Actor initializers are NOT isolated to the actor. You cannot call isolated methods from init.
actor DataManager {
var data: [String] = []
init() {
// Cannot call isolated methods here
// self.loadDefaults() // ERROR: actor-isolated method in non-isolated init
}
// Use a factory method instead
static func create() async -> DataManager {
let manager = DataManager()
await manager.loadDefaults()
return manager
}
func loadDefaults() {
data = ["default"]
}
}Actor Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| Actor reentrancy | State changes between awaits | Re-check state after each await |
| nonisolated accessing isolated state | Compiler error | Remove nonisolated or make property nonisolated |
| Calling actor method from sync context | "Expression is 'async'" | Wrap in Task {} or make caller async |
| Global actor inheritance | Subclass inherits @MainActor | Be intentional about which methods need isolation |
| Actor init not isolated | Can't call isolated methods in init | Use factory method or populate after init |
| Actor protocol conformance | "Non-isolated" conformance error | Use nonisolated for protocol methods, or isolated conformance (Swift 6.2+) |
| Using actor for ViewModel | @Published won't work, UI updates require await | Use @MainActor class for UI-facing code, actor only for non-UI shared state |
| GCD queue-hopping inside actor | Breaks isolation guarantees, risks thread explosion | Remove GCD — actor isolation already serializes access |
Custom Executors
Every actor runs on an executor. The default executor schedules the synchronous pieces of a task ("jobs") on the cooperative thread pool. MainActor has its own serial executor that runs jobs on the main thread. You rarely need to think about executors directly.
When you might implement a custom executor:
- You own a thread pool with specific tuning (a custom scheduler for a high-throughput service)
- You need to bridge an actor's work to a specific dispatch queue your team controls
- You're implementing an actor that must run on a particular thread (e.g., a graphics actor pinned to a specific GPU queue)
Conform to SerialExecutor and expose it from your actor via unownedExecutor:
final class CustomExecutor: SerialExecutor {
private let queue: DispatchQueue
init(queue: DispatchQueue) { self.queue = queue }
func enqueue(_ job: UnownedJob) {
queue.async {
job.runSynchronously(on: self.asUnownedSerialExecutor())
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
actor PinnedWorker {
private let executor: CustomExecutor
init(queue: DispatchQueue) {
self.executor = CustomExecutor(queue: queue)
}
nonisolated var unownedExecutor: UnownedSerialExecutor {
executor.asUnownedSerialExecutor()
}
func doWork() { /* runs on `queue` */ }
}For most app code, never reach for custom executors. They're a footgun — get one wrong and you'll see deadlocks, priority inversions, or unbounded thread growth. The default executor is well-tuned for general-purpose work.
---
Part 2: Sendable Patterns
Automatic Sendable Conformance
Value types are Sendable when all stored properties are Sendable.
// Structs: Sendable when all stored properties are Sendable
struct UserProfile: Sendable {
let name: String
let age: Int
}
// Enums: Sendable when all associated values are Sendable
enum LoadState: Sendable {
case idle
case loading
case loaded(String) // String is Sendable
case failed(Error) // ERROR: Error is not Sendable
}
// Fix: use a Sendable error type
enum LoadState: Sendable {
case idle
case loading
case loaded(String)
case failed(any Error & Sendable)
}@Sendable Closures
Closures passed across isolation boundaries must be @Sendable. A @Sendable closure cannot capture mutable local state.
func runInBackground(_ work: @Sendable () -> Void) {
Task.detached { work() }
}
// All captured values must be Sendable
var count = 0
runInBackground {
// ERROR: capture of mutable local variable
// count += 1
}
let snapshot = count
runInBackground {
print(snapshot) // OK: let binding of Sendable type
}@unchecked Sendable
Manual guarantee of thread safety. Use only when you provide synchronization yourself.
final class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
func get(_ key: String) -> Any? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
func set(_ key: String, value: Any) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
}Requirements for @unchecked Sendable
- Class must be
final - All mutable state must be protected by a synchronization primitive (lock, queue, Mutex)
- You are responsible for correctness — the compiler will not check
Conditional Conformance
struct Box<T> {
let value: T
}
// Box is Sendable only when T is Sendable
extension Box: Sendable where T: Sendable {}
// Standard library uses this extensively:
// Array<Element>: Sendable where Element: Sendable
// Dictionary<Key, Value>: Sendable where Key: Sendable, Value: Sendable
// Optional<Wrapped>: Sendable where Wrapped: Sendablesending Parameter Modifier (SE-0430)
Transfer ownership of a value across isolation boundaries. The caller gives up access.
func process(_ value: sending String) async {
// Caller can no longer access value after this call
await store(value)
}
// Useful for transferring non-Sendable types when caller won't use them again
func handOff(_ connection: sending NetworkConnection) async {
await manager.accept(connection)
}Build Settings
Control the strictness of Sendable checking in Xcode:
| Setting | Value | Behavior |
|---|---|---|
SWIFT_STRICT_CONCURRENCY | minimal | Only explicit Sendable annotations checked |
SWIFT_STRICT_CONCURRENCY | targeted | Inferred Sendable + closure checking |
SWIFT_STRICT_CONCURRENCY | complete | Full strict concurrency (Swift 6 default) |
Pulling Sendable Pieces Out of a Non-Sendable Type
When you need data from a non-Sendable type (typically an NSObject subclass or legacy class) across isolation boundaries, you usually only need a few properties — not the whole object. Instead of trying to make the wrapper Sendable, extract the Sendable pieces at the source isolation and send only those.
// ❌ Trying to make the whole legacy type Sendable cascades through the codebase
final class LegacyImageRecord: NSObject { // Inherits from NSObject; non-Sendable
@objc dynamic var title: String
@objc dynamic var url: URL
@objc dynamic var thumbnailCache: NSCache<NSString, UIImage> // Mutable shared state
}
actor ImageCatalog {
func info(for id: String) -> LegacyImageRecord { // ❌ Can't return non-Sendable across actor
...
}
}
// ✅ Send only the Sendable pieces
struct ImageInfo: Sendable {
let title: String
let url: URL
}
actor ImageCatalog {
private var records: [String: LegacyImageRecord] = [:]
func info(for id: String) -> ImageInfo? {
guard let record = records[id] else { return nil }
return ImageInfo(title: record.title, url: record.url) // Sendable snapshot
}
}This pattern lets you keep legacy non-Sendable types encapsulated within their isolation domain while still surfacing useful information to the rest of the program. Foundation uses this extensively during its own concurrency adoption — most consumers of Foundation types only need string/numeric/date pieces, not the whole reference object.
Sendable Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| Class can't be Sendable | "Class cannot conform to Sendable" | Make final + immutable, or @unchecked Sendable with locks |
| Closure captures non-Sendable | "Capture of non-Sendable type" | Copy value before capture, or make type Sendable |
| Protocol can't require Sendable | Generic constraints complex | Use where T: Sendable |
| @unchecked Sendable hides bugs | Data races at runtime | Only use when lock/queue guarantees safety |
| Array/Dictionary conditional | Collection is Sendable only if Element is | Ensure element types are Sendable |
| Error not Sendable | "Type does not conform to Sendable" | Use any Error & Sendable or typed errors |
---
Part 3: Task Management
Task { }
Creates an unstructured task that inherits the current actor context and priority.
// Inherits actor context — if called from @MainActor, runs on MainActor
let task = Task {
try await fetchData()
}
// Get the result
let result = try await task.value
// Get Result<Success, Failure>
let outcome = await task.resultTask.detached { }
Creates a task with no inherited context. Does not inherit the actor or priority.
Task.detached(priority: .background) {
// NOT on MainActor even if created from MainActor
await processLargeFile()
}When to use: Background work that must NOT run on the calling actor. Prefer Task {} in most cases — Task.detached is rarely needed.
Task Cancellation
Cancellation is cooperative. Setting cancellation is a request; the task must check and respond.
let task = Task {
for item in largeCollection {
// Option 1: Check boolean
if Task.isCancelled { break }
// Option 2: Throw CancellationError
try Task.checkCancellation()
await process(item)
}
}
// Request cancellation
task.cancel()Task.sleep
Suspends the current task for a duration. Supports cancellation — throws CancellationError if cancelled during sleep.
// Duration-based (preferred)
try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: .milliseconds(500))
// Nanoseconds (older API)
try await Task.sleep(nanoseconds: 2_000_000_000)Task.yield
Voluntarily yields execution to allow other tasks to run. Use in long-running synchronous loops.
for i in 0..<1_000_000 {
if i.isMultiple(of: 1000) {
await Task.yield()
}
process(i)
}Task Priority
| Priority | Use Case |
|---|---|
.userInitiated | Direct user action, visible result |
.high | Same as .userInitiated |
.medium | Default when not specified |
.low | Prefetching, non-urgent work |
.utility | Long computation, progress shown |
.background | Maintenance, cleanup, not time-sensitive |
Task(priority: .userInitiated) {
await loadVisibleContent()
}
Task(priority: .background) {
await cleanupTempFiles()
}@TaskLocal
Task-scoped values that propagate to child tasks automatically.
enum RequestContext {
@TaskLocal static var requestID: String?
@TaskLocal static var userID: String?
}
// Set values for a scope
RequestContext.$requestID.withValue("req-123") {
RequestContext.$userID.withValue("user-456") {
// Both values available here and in child tasks
Task {
print(RequestContext.requestID) // "req-123"
print(RequestContext.userID) // "user-456"
}
}
}
// Outside scope — values are nil
print(RequestContext.requestID) // nilPropagation rules: @TaskLocal values propagate to child tasks created with Task {}. They do NOT propagate to Task.detached {}.
Task Timeout Pattern
Enforce a deadline on any async operation using a task group race:
func withTimeout<T: Sendable>(
_ duration: Duration,
operation: @Sendable @escaping () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: duration)
throw TimeoutError()
}
guard let result = try await group.next() else {
throw TimeoutError()
}
group.cancelAll() // Cancel the loser — without this it keeps running
return result
}
}group.cancelAll() is critical. Without it, the losing task (either the timeout or the operation) continues running until the group scope exits.
Task Retain Cycles
Tasks capture variables like closures. Stored tasks that reference self create retain cycles.
// ❌ Retain cycle: self → task → self
task = Task {
while true { await self.poll() }
}
// ✅ Weak capture breaks the cycle
task = Task { [weak self] in
while let self, !Task.isCancelled {
await self.poll()
}
}Rule: Use [weak self] when the Task is stored as a property or iterates an infinite async sequence. Short-lived Tasks that complete quickly can use strong captures.
Thread.current in Swift 6
Thread.current is unavailable from async contexts in Swift 6 language mode:
// ❌ Compiler error in Swift 6 mode
func check() async { print(Thread.current) }
// ✅ Workaround for debugging only
extension Thread {
static var currentThread: Thread { Thread.current }
}Don't rely on thread identity for correctness — tasks move between threads at suspension points. Reason about isolation domains instead.
Task Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| Task never cancelled | Resource leak, work continues after view disappears | Store task, cancel in deinit/onDisappear |
| Ignoring cancellation | Task runs to completion even when cancelled | Check Task.isCancelled in loops, use checkCancellation() |
| Task.detached loses actor context | "Not isolated to MainActor" | Use Task {} when you need actor isolation |
| Capturing self in stored Task | Retain cycle, deinit never called | Use [weak self] for long-lived or stored tasks |
| Assuming async = background | Code stays on calling actor | Use @concurrent to force background execution |
| TaskLocal not propagated | Value is nil in detached task | TaskLocal only propagates to child tasks, not detached |
| Task priority inversion | Low-priority task blocks high-priority | System handles most cases; avoid awaiting low-priority from high |
| Thread.current in async context | Compiler error in Swift 6 mode | Don't rely on thread identity — use isolation domains |
---
Part 4: Structured Concurrency
async let
Run a fixed number of operations in parallel. All async let bindings are implicitly awaited when the scope exits.
async let images = fetchImages()
async let metadata = fetchMetadata()
async let config = loadConfig()
// All three run concurrently, await together
let (imgs, meta, cfg) = try await (images, metadata, config)Semantics: If one async let throws, the others are cancelled. All must complete (or be cancelled) before the enclosing scope exits.
TaskGroup — Non-Throwing
Dynamic number of parallel tasks where none throw.
let results = await withTaskGroup(of: String.self) { group in
for name in names {
group.addTask {
await fetchGreeting(for: name)
}
}
var greetings: [String] = []
for await greeting in group {
greetings.append(greeting)
}
return greetings
}TaskGroup — Throwing
Dynamic number of parallel tasks that can throw.
let images = try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
for url in urls {
group.addTask {
let image = try await downloadImage(url)
return (url, image)
}
}
var results: [URL: UIImage] = [:]
for try await (url, image) in group {
results[url] = image
}
return results
}withDiscardingTaskGroup (iOS 17+)
For when you need concurrency but don't need to collect results. More memory-efficient than regular TaskGroup — no result storage.
try await withThrowingDiscardingTaskGroup { group in
for connection in connections {
group.addTask {
try await connection.monitor()
// Results are discarded — useful for long-running services
}
}
// Group stays alive until all tasks complete or one throws
}Real-world pattern — merge multiple notification streams
extension NotificationCenter {
func notifications(named names: [Notification.Name]) -> AsyncStream<Void> {
AsyncStream { continuation in
let task = Task {
await withDiscardingTaskGroup { group in
for name in names {
group.addTask {
for await _ in self.notifications(named: name) {
continuation.yield()
}
}
}
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}TaskGroup Control
await withTaskGroup(of: Data.self) { group in
// Add tasks conditionally
group.addTaskUnlessCancelled {
await fetchData()
}
// Cancel remaining tasks
group.cancelAll()
// Wait without collecting
await group.waitForAll()
// Iterate one at a time
while let result = await group.next() {
process(result)
}
}Task Tree Semantics
Structured concurrency forms a tree:
- Parent cancellation cancels all children — cancelling a task cancels all
async letand TaskGroup children - Child error propagates to parent — in throwing groups, a child error cancels siblings and propagates up
- All children must complete before parent returns — the scope awaits all children, even cancelled ones
// If fetchImages() throws, fetchMetadata() is automatically cancelled
async let images = fetchImages()
async let metadata = fetchMetadata()
let result = try await (images, metadata)Batching — When Task-Per-Item Is Wrong
For large input sets (thousands of files, large API result lists, big migration batches), the naive "spawn one Task per item" pattern is rarely optimal:
- The cooperative thread pool has a fixed size (typically core count). 10,000 tasks don't get 10,000 threads — they queue up and add scheduling overhead.
- Each task allocates context (stack, task-locals, isolation tracking). For trivial work, the overhead dwarfs the actual computation.
- Memory pressure: keeping 10,000 in-flight task contexts alive while results accumulate can spike memory.
Profile first. The naive approach may be fine for your scale. If it's not, batch:
// ✅ Bounded concurrency — N workers process items from a shared queue
func processAll(_ items: [Item], concurrency: Int = 8) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
var iterator = items.makeIterator()
var inFlight = 0
// Prime the pump
while inFlight < concurrency, let item = iterator.next() {
group.addTask { try await process(item) }
inFlight += 1
}
// Replace each finished task with the next item
for try await _ in group {
if let next = iterator.next() {
group.addTask { try await process(next) }
}
}
}
}Match concurrency to the work's profile: 2–4 for CPU-bound work, 8–16 for I/O-bound, much higher (32+) only if you've measured.
Structured Concurrency Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| async let unused | Work still executes but result is discarded silently | Assign all async let results or use withDiscardingTaskGroup |
| TaskGroup accumulating memory | Memory grows with 10K+ tasks | Process results as they arrive, don't collect all |
| Capturing mutable state in addTask | "Mutation of captured var" | Use let binding or actor |
| Not handling partial failure | Some tasks succeed, some fail | Use group.next() and handle errors individually |
| async let in loop | Compiler error — async let must be in fixed positions | Use TaskGroup instead |
| Returning from group early | Remaining tasks still run | Call group.cancelAll() before returning |
---
Part 5: Async Sequences
AsyncStream
Non-throwing stream for producing values over time.
let stream = AsyncStream<Int> { continuation in
for i in 0..<10 {
continuation.yield(i)
}
continuation.finish()
}
for await value in stream {
print(value)
}AsyncThrowingStream
Stream that can fail with an error.
let stream = AsyncThrowingStream<Data, Error> { continuation in
let monitor = NetworkMonitor()
monitor.onData = { data in
continuation.yield(data)
}
monitor.onError = { error in
continuation.finish(throwing: error)
}
monitor.onComplete = {
continuation.finish()
}
continuation.onTermination = { @Sendable _ in
monitor.stop()
}
monitor.start()
}
do {
for try await data in stream {
process(data)
}
} catch {
handleStreamError(error)
}Continuation API
let stream = AsyncStream<Value> { continuation in
// Emit a value
continuation.yield(value)
// End the stream normally
continuation.finish()
// Cleanup when consumer cancels or stream ends
continuation.onTermination = { @Sendable termination in
switch termination {
case .cancelled:
cleanup()
case .finished:
finalCleanup()
@unknown default:
break
}
}
}
// For throwing streams
let stream = AsyncThrowingStream<Value, Error> { continuation in
continuation.yield(value)
continuation.finish() // Normal end
continuation.finish(throwing: error) // End with error
}Buffering Policies
Control what happens when values are produced faster than consumed.
// Keep all values (default) — memory can grow unbounded
let stream = AsyncStream<Int>(bufferingPolicy: .unbounded) { continuation in
// ...
}
// Keep oldest N values, drop new ones when buffer is full
let stream = AsyncStream<Int>(bufferingPolicy: .bufferingOldest(100)) { continuation in
// ...
}
// Keep newest N values, drop old ones when buffer is full
let stream = AsyncStream<Int>(bufferingPolicy: .bufferingNewest(100)) { continuation in
// ...
}| Policy | Behavior | Use When |
|---|---|---|
.unbounded | Keeps all values | Consumer keeps up, or bounded producer |
.bufferingOldest(N) | Drops new values when full | Order matters, older values have priority |
.bufferingNewest(N) | Drops old values when full | Latest state matters (UI updates, sensor data) |
Custom AsyncSequence
struct Counter: AsyncSequence {
typealias Element = Int
let limit: Int
struct AsyncIterator: AsyncIteratorProtocol {
var current = 0
let limit: Int
mutating func next() async -> Int? {
guard current < limit else { return nil }
defer { current += 1 }
return current
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(limit: limit)
}
}
// Usage
for await number in Counter(limit: 5) {
print(number) // 0, 1, 2, 3, 4
}AsyncSequence Operators
Standard operators work on any AsyncSequence:
// Map
for await name in users.map(\.name) { }
// Filter
for await adult in users.filter({ $0.age >= 18 }) { }
// CompactMap
for await image in urls.compactMap({ await tryLoadImage($0) }) { }
// Prefix
for await first5 in stream.prefix(5) { }
// first(where:)
let match = await stream.first(where: { $0 > threshold })
// Contains
let hasMatch = await stream.contains(where: { $0 > threshold })
// Reduce
let sum = await numbers.reduce(0, +)Built-in Async Sequences
// NotificationCenter
for await notification in NotificationCenter.default.notifications(named: .didUpdate) {
handleUpdate(notification)
}
// URLSession bytes
let (bytes, response) = try await URLSession.shared.bytes(from: url)
for try await byte in bytes {
process(byte)
}
// FileHandle bytes
for try await line in FileHandle.standardInput.bytes.lines {
process(line)
}Async Sequence Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| Continuation yielded after finish | Runtime warning, value lost | Track finished state, guard before yield |
| Stream never finishing | for-await loop hangs forever | Always call continuation.finish() in all code paths |
| No onTermination handler | Resource leak when consumer cancels | Set continuation.onTermination for cleanup |
| Unbounded buffer | Memory growth under load | Use .bufferingNewest(N) or .bufferingOldest(N) |
| Multiple consumers | Only first consumer gets values | AsyncStream is single-consumer; create separate streams per consumer |
| for-await on MainActor | UI freezes waiting for values | Use Task {} to consume off the main path |
---
Part 6: Isolation Patterns
@MainActor on Functions
@MainActor
func updateUI() {
label.text = "Done"
}
// Call from async context
func doWork() async {
let result = await computeResult()
await updateUI() // Hops to MainActor
}MainActor.run
Explicitly execute a closure on the main actor from any context.
func processData() async {
let result = await heavyComputation()
await MainActor.run {
self.label.text = result
self.progressView.isHidden = true
}
}MainActor.assumeIsolated (iOS 13+, Swift 5.9 compiler)
Assert that code is already running on the main actor. Crashes at runtime if the assertion is false.
func legacyCallback() {
// We KNOW this is called on main thread (UIKit guarantee)
MainActor.assumeIsolated {
self.viewModel.update() // Access @MainActor state
}
}See skills/assume-isolated.md for comprehensive patterns.
nonisolated
Opt out of the enclosing actor's isolation.
@MainActor
class ViewModel {
let id: UUID // Implicitly nonisolated (let)
nonisolated var analyticsID: String { // Explicitly nonisolated
id.uuidString
}
var items: [Item] = [] // Isolated to MainActor
}nonisolated(unsafe)
Compiler escape hatch. Tells the compiler to treat a property as if it's not isolated, without any safety guarantees.
// Use only when you have external guarantees of thread safety
nonisolated(unsafe) var legacyState: Int = 0
// Common for global constants that the compiler can't verify
nonisolated(unsafe) let formatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
return f
}()Warning: nonisolated(unsafe) provides zero runtime protection. Data races will not be caught. Use only as a last resort for bridging legacy code.
@preconcurrency
Suppress concurrency warnings for pre-concurrency APIs during migration.
// Suppress warnings for entire module
@preconcurrency import MyLegacyFramework
// Suppress for specific protocol conformance
class MyDelegate: @preconcurrency SomeLegacyDelegate {
func delegateCallback() {
// No Sendable warnings for this conformance
}
}#isolation (Swift 6.0+)
Capture the caller's isolation context so a function runs on whatever actor the caller is on.
func doWork(isolation: isolated (any Actor)? = #isolation) async {
// Runs on caller's actor — no hop if caller is already isolated
performWork()
}
// Called from @MainActor — runs on MainActor
@MainActor
func setup() async {
await doWork() // doWork runs on MainActor
}
// Called from custom actor — runs on that actor
actor MyActor {
func run() async {
await doWork() // doWork runs on MyActor
}
}#isolation Capture in Task Closures (SE-0420)
When spawning Task closures that need to work with non-Sendable types, capture the isolation parameter to inherit the caller's context.
func process(
delegate: NonSendableDelegate,
isolation: isolated (any Actor)? = #isolation
) {
Task {
_ = isolation // Forces capture — Task inherits caller's isolation
delegate.doWork() // ✅ Safe: running on caller's actor
}
}Why `_ = isolation` is required: Per SE-0420, Task closures only inherit isolation when a non-optional binding of an isolated parameter is captured by the closure. The _ = isolation statement forces this capture. Without it, the Task runs on the default executor and the non-Sendable capture is a compiler error.
When to use: Spawning Tasks that work with non-Sendable delegate objects, fire-and-forget async work that needs access to caller's state, or bridging callback-based APIs while keeping delegates alive.
Isolation Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| MainActor.run from MainActor | Unnecessary hop, potential deadlock risk | Check context or use assumeIsolated |
| nonisolated(unsafe) data race | Crash at runtime, corrupted state | Use proper isolation or Mutex |
| @preconcurrency hiding real issues | Runtime crashes in production | Migrate to proper concurrency before shipping |
| #isolation not available pre-5.9 | Compiler error | Use traditional @MainActor annotation |
| #isolation not captured in Task | Non-Sendable capture error | Add _ = isolation inside Task closure (SE-0420) |
| nonisolated on actor method | Can't access any isolated state | Only use for computed properties from non-isolated state |
| Thread.current in async context | Compiler error in Swift 6 mode | Don't rely on thread identity — reason about isolation domains |
---
Part 7: Continuations
Bridge callback-based APIs to async/await.
withCheckedContinuation
Non-throwing bridge.
func currentLocation() async -> CLLocation {
await withCheckedContinuation { continuation in
locationManager.requestLocation { location in
continuation.resume(returning: location)
}
}
}withCheckedThrowingContinuation
Throwing bridge.
func fetchUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
api.fetchUser(id: id) { result in
switch result {
case .success(let user):
continuation.resume(returning: user)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}Continuation Resume Methods
// Return a value
continuation.resume(returning: value)
// Throw an error
continuation.resume(throwing: error)
// From a Result type
continuation.resume(with: result) // Result<T, Error>Resume-Exactly-Once Rule
A continuation MUST be resumed exactly once:
- Resuming twice crashes with
"Continuation already resumed"(checked) or undefined behavior (unsafe) - Never resuming causes the awaiting task to hang forever — a silent leak
// DANGEROUS: callback might not be called
func riskyBridge() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
api.fetch { data, error in
if let error {
continuation.resume(throwing: error)
return
}
if let data {
continuation.resume(returning: data)
return
}
// BUG: if both are nil, continuation is never resumed
// Fix: add a fallback
continuation.resume(throwing: BridgeError.noResponse)
}
}
}Bridging Delegates
class LocationBridge: NSObject, CLLocationManagerDelegate {
private var continuation: CheckedContinuation<CLLocation, Error>?
private let manager = CLLocationManager()
func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.delegate = self
manager.requestLocation()
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
continuation?.resume(returning: locations[0])
continuation = nil // Prevent double resume
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}Unsafe Continuations
Skip runtime checks for performance. Same API as checked, but misuse causes undefined behavior instead of a diagnostic crash.
func fastBridge() async -> Data {
await withUnsafeContinuation { continuation in
// No runtime check for double-resume or missing resume
fastCallback { data in
continuation.resume(returning: data)
}
}
}Use checked continuations during development, switch to unsafe only after thorough testing and when profiling shows the check is a bottleneck.
Continuation Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| Resume called twice | "Continuation already resumed" crash | Set continuation to nil after resume |
| Resume never called | Task hangs indefinitely | Ensure all code paths resume — including error/nil cases |
| Capturing continuation | Continuation escapes scope | Store in property, ensure single resume |
| Unsafe continuation in debug | No diagnostics for misuse | Use withCheckedContinuation during development |
| Delegate called multiple times | Crash on second resume | Use AsyncStream instead of continuation for repeated callbacks |
| Callback on wrong thread | Doesn't matter for continuation | Continuations can be resumed from any thread |
---
Part 8: Migration Patterns
Common migrations from GCD and completion handlers to Swift concurrency.
DispatchQueue to Actor
// BEFORE: DispatchQueue for thread safety
class ImageCache {
private let queue = DispatchQueue(label: "cache", attributes: .concurrent)
private var cache: [URL: UIImage] = [:]
func get(_ url: URL, completion: @escaping (UIImage?) -> Void) {
queue.async { completion(self.cache[url]) }
}
func set(_ url: URL, image: UIImage) {
queue.async(flags: .barrier) { self.cache[url] = image }
}
}
// AFTER: Actor
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func get(_ url: URL) -> UIImage? {
cache[url]
}
func set(_ url: URL, image: UIImage) {
cache[url] = image
}
}DispatchGroup to TaskGroup
// BEFORE: DispatchGroup
let group = DispatchGroup()
var results: [Data] = []
for url in urls {
group.enter()
fetch(url) { data in
results.append(data)
group.leave()
}
}
group.notify(queue: .main) { use(results) }
// AFTER: TaskGroup
let results = await withTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { await fetch(url) }
}
var collected: [Data] = []
for await data in group {
collected.append(data)
}
return collected
}
use(results)Completion Handler to async
// BEFORE
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: url) { data, _, error in
if let error { completion(.failure(error)); return }
guard let data else { completion(.failure(FetchError.noData)); return }
completion(.success(data))
}.resume()
}
// AFTER
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}@objc Delegates with @MainActor
@MainActor
class ViewController: UIViewController, UITableViewDelegate {
// @objc delegate methods inherit @MainActor isolation from the class
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// Already on MainActor — safe to update UI
updateSelection(indexPath)
}
}NotificationCenter to AsyncSequence
// BEFORE
let observer = NotificationCenter.default.addObserver(
forName: .didUpdate, object: nil, queue: .main
) { notification in
handleUpdate(notification)
}
// Must remove observer in deinit
// AFTER
let task = Task {
for await notification in NotificationCenter.default.notifications(named: .didUpdate) {
await handleUpdate(notification)
}
}
// Cancel task in deinit — no manual observer removal neededTimer to AsyncSequence
// BEFORE
let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
updateUI()
}
// Must invalidate in deinit
// AFTER
let task = Task {
while !Task.isCancelled {
await updateUI()
try? await Task.sleep(for: .seconds(1))
}
}
// Cancel task in deinitDispatchSemaphore to Actor
// BEFORE: Semaphore to limit concurrent operations
let semaphore = DispatchSemaphore(value: 3)
for url in urls {
DispatchQueue.global().async {
semaphore.wait()
defer { semaphore.signal() }
download(url)
}
}
// AFTER: TaskGroup with limited concurrency
await withTaskGroup(of: Void.self) { group in
var inFlight = 0
for url in urls {
if inFlight >= 3 {
await group.next() // Wait for one to finish
inFlight -= 1
}
group.addTask { await download(url) }
inFlight += 1
}
await group.waitForAll()
}Migration Gotcha Table
| Gotcha | Symptom | Fix |
|---|---|---|
| DispatchQueue.sync to actor | Deadlock potential | Remove .sync, use await |
| Global dispatch to actor contention | Slowdown from serialization | Profile with Concurrency Instruments |
| Legacy delegate + Sendable | "Cannot conform to Sendable" | Use @preconcurrency import or @MainActor isolation |
| Callback called multiple times | Continuation crash | Use AsyncStream instead of continuation |
| Semaphore.wait in async context | Thread starvation, potential deadlock | Use TaskGroup with manual concurrency limiting |
| DispatchQueue.main.async to MainActor | Subtle timing differences | MainActor.run is the equivalent — test edge cases |
| Replacing structured tasks with top-level Tasks | Losing cancellation propagation and error handling | Use async let or TaskGroup for related parallel work |
| Batch @unchecked Sendable to fix warnings | Hiding real data races throughout codebase | Fix one type at a time with proper Sendable, actor, or sending |
---
Coming in Swift 6.4
Three concurrency features accepted for Swift 6.4 that are not yet shipping. Track the Swift Evolution dashboard for status; update this section when 6.4 lands.
Async defer
Swift 6.4 lifts the restriction that prevents await inside defer blocks. No new syntax — defer { await cleanup() } will just work, matching the cooperative-cancellation timing of structured concurrency.
Task.withDeadline (proposal name TBD)
A standard library task API that mirrors the homemade withTimeout pattern earlier in this file: kick off async work, cancel automatically if a duration is exceeded. The proposal is under review; naming is still being debated. When it ships, prefer it over the manual withThrowingTaskGroup race pattern.
Task error-swallowing diagnostic
Currently, Task { try ... } lets thrown errors disappear silently — no warning, no crash, just a dropped failure. Swift 6.4 adds a diagnostic for unstructured Tasks whose body can throw but where the caller never reads task.value or task.result. The two valid responses:
- Handle errors inside the Task body (
do { try ... } catch { ... }). - Store the Task handle and
await task.value(which throws if the body threw).
Once 6.4 ships, expect a wave of warnings on code that follows the "fire and forget" Task pattern with throwing functions.
---
API Quick Reference
| Task | API | Swift Version |
|---|---|---|
| Define isolated type | actor MyActor { } | 5.5+ |
| Run on main thread | @MainActor | 5.5+ |
| Mark as safe to share | : Sendable | 5.5+ |
| Mark closure safe to share | @Sendable | 5.5+ |
| Parallel tasks (fixed) | async let | 5.5+ |
| Parallel tasks (dynamic) | withTaskGroup | 5.5+ |
| Stream values | AsyncStream | 5.5+ |
| Bridge callback | withCheckedContinuation | 5.5+ |
| Check cancellation | Task.checkCancellation() | 5.5+ |
| Task-scoped values | @TaskLocal | 5.5+ |
| Assert isolation | MainActor.assumeIsolated | 5.9+ (iOS 13+) |
| Capture caller isolation | #isolation | 6.0+ |
| Lock-based sync | Mutex | 6.0+ (iOS 18+) |
| Discard results | withDiscardingTaskGroup | 5.9+ (iOS 17+) |
| Transfer ownership | sending parameter | 6.0+ |
| Force background | @concurrent | 6.2+ |
| Isolated conformance | extension: @MainActor Proto | 6.2+ |
Resources
WWDC: 2021-10132, 2021-10134, 2022-110350, 2025-268
Docs: /swift/concurrency, /swift/actor, /swift/sendable, /swift/taskgroup
Skills: See skills/swift-concurrency.md, skills/assume-isolated.md, skills/synchronization.md, skills/concurrency-profiling.md
Mutex & Synchronization — Thread-Safe Primitives
Low-level synchronization primitives for when actors are too slow or heavyweight.
When to Use Mutex vs Actor
| Need | Use | Reason |
|---|---|---|
| Microsecond operations | Mutex | No async hop overhead |
| Protect single property | Mutex | Simpler, faster |
| Complex async workflows | Actor | Proper suspension handling |
| Suspension points needed | Actor | Mutex can't suspend |
| Shared across modules | Mutex | Sendable, no await needed |
| High-frequency counters | Atomic | Lock-free performance |
API Reference
Mutex (iOS 18+ / Swift 6)
import Synchronization
let mutex = Mutex<Int>(0)
// Read
let value = mutex.withLock { $0 }
// Write
mutex.withLock { $0 += 1 }
// Non-blocking attempt
if let value = mutex.withLockIfAvailable({ $0 }) {
// Got the lock
}Properties:
- Generic over protected value
Sendable— safe to share across concurrency boundaries- Closure-based access only (no lock/unlock methods)
OSAllocatedUnfairLock (iOS 16+)
import os
let lock = OSAllocatedUnfairLock(initialState: 0)
// Closure-based (recommended)
lock.withLock { state in
state += 1
}
// Traditional (same-thread only)
lock.lock()
defer { lock.unlock() }
// access protected stateProperties:
- Heap-allocated, stable memory address
- Non-recursive (can't re-lock from same thread)
Sendable
Atomic Types (iOS 18+)
import Synchronization
let counter = Atomic<Int>(0)
// Atomic increment
counter.wrappingAdd(1, ordering: .relaxed)
// Compare-and-swap
let (exchanged, original) = counter.compareExchange(
expected: 0,
desired: 42,
ordering: .acquiringAndReleasing
)Patterns
Pattern 1: Thread-Safe Counter
final class Counter: Sendable {
private let mutex = Mutex<Int>(0)
var value: Int { mutex.withLock { $0 } }
func increment() { mutex.withLock { $0 += 1 } }
}Pattern 2: Sendable Wrapper
final class ThreadSafeValue<T: Sendable>: @unchecked Sendable {
private let mutex: Mutex<T>
init(_ value: T) { mutex = Mutex(value) }
var value: T {
get { mutex.withLock { $0 } }
set { mutex.withLock { $0 = newValue } }
}
}Pattern 3: Fast Sync Access in Actor
actor ImageCache {
// Mutex for fast sync reads without actor hop
private let mutex = Mutex<[URL: Data]>([:])
nonisolated func cachedSync(_ url: URL) -> Data? {
mutex.withLock { $0[url] }
}
func cacheAsync(_ url: URL, data: Data) {
mutex.withLock { $0[url] = data }
}
}Pattern 4: Lock-Free Counter with Atomic
final class FastCounter: Sendable {
private let _value = Atomic<Int>(0)
var value: Int { _value.load(ordering: .relaxed) }
func increment() {
_value.wrappingAdd(1, ordering: .relaxed)
}
}Pattern 5: iOS 16 Fallback
#if compiler(>=6.0)
import Synchronization
typealias Lock<T> = Mutex<T>
#else
import os
// Use OSAllocatedUnfairLock for iOS 16-17
#endifDanger: Mixing with Swift Concurrency
Never Hold Locks Across Await
// ❌ DEADLOCK RISK
mutex.withLock {
await someAsyncWork() // Task suspends while holding lock!
}
// ✅ SAFE: Release before await
let value = mutex.withLock { $0 }
let result = await process(value)
mutex.withLock { $0 = result }Why Semaphores/RWLocks Are Unsafe
Swift's cooperative thread pool has limited threads. Blocking primitives exhaust the pool:
// ❌ DANGEROUS: Blocks cooperative thread
let semaphore = DispatchSemaphore(value: 0)
Task {
semaphore.wait() // Thread blocked, can't run other tasks!
}
// ✅ Use async continuation instead
await withCheckedContinuation { continuation in
// Non-blocking callback
callback { continuation.resume() }
}Sync Lifecycle Callbacks with Async Cleanup
First: are you in the right callback? applicationWillTerminate is rarely the right place to flush async work, because it's not called for the common termination paths:
- User swiping the app away from the app switcher → not called
- System jetsam under memory pressure → not called
- Backgrounded app silently killed → not called
applicationWillTerminate only fires when the app is running in the foreground and the system kills it, or when the app calls exit() directly. If you're using it as your "wrap up before termination" hook, you've already lost most terminations. Use `applicationDidEnterBackground` (or `sceneDidEnterBackground`) paired with `UIApplication.beginBackgroundTask(expirationHandler:)` to get a ~30-second guaranteed window for cleanup; that callback fires for every transition out of foreground including the user-swipe-away case.
If you genuinely need to bridge an async cleanup from a synchronous OS callback — even if you've picked the right one — read on. The pattern below applies to any sync lifecycle callback that hands you a deadline.
OS lifecycle callbacks like applicationWillTerminate, sceneWillResignActive, and applicationDidEnterBackground are synchronous — they expect cleanup to complete before they return. If your cleanup logic is async, you cannot bridge with a DispatchSemaphore without risking deadlock: the cooperative thread pool may already be saturated, and blocking the main thread on a semaphore can leave the signal nowhere to come from.
// ❌ DEADLOCK RISK — sync callback waiting on async work via semaphore
func applicationWillTerminate(_ application: UIApplication) {
let semaphore = DispatchSemaphore(value: 0)
Task {
await persistAllChanges()
semaphore.signal()
}
semaphore.wait() // ❌ Main thread blocked; the Task may need main to make progress
}What to do instead:
1. Refactor cleanup to be synchronous where possible. Most teardown work (writing to disk, in-memory cleanup, Core Data save()) has synchronous APIs. Extract a sync code path for the lifecycle callback.
2. Use background tasks for work that must finish later. For network flushes or other genuinely async work, request a BGTaskScheduler task to complete on the next launch (or backgrounded continuation). The OS will resume your app briefly to finish.
3. Design for graceful partial completion. Mark state as "unclean" at the start of a session and run recovery logic on next launch. This is more reliable than racing the OS's termination window — and the window is so short (a few seconds) that even successful async cleanup is risky.
// ✅ Sync path for what can be sync, defer the rest
func applicationWillTerminate(_ application: UIApplication) {
saveUnsavedDocumentsSynchronously() // Sync
markSessionAsUnclean() // Sync flag for recovery
scheduleBackgroundFlush() // BGTaskScheduler for async work
}The hard truth: the OS does not guarantee you enough time to complete async work in lifecycle callbacks. Design for cleanup failure being possible rather than trying to force completion.
os_unfair_lock Danger
Never use `os_unfair_lock` directly in Swift — it can be moved in memory:
// ❌ UNDEFINED BEHAVIOR: Lock may move
var lock = os_unfair_lock()
os_unfair_lock_lock(&lock) // Address may be invalid
// ✅ Use OSAllocatedUnfairLock (heap-allocated, stable address)
let lock = OSAllocatedUnfairLock()Decision Tree
Need synchronization?
├─ Lock-free operation needed?
│ └─ Simple counter/flag? → Atomic
│ └─ Complex state? → Mutex
├─ iOS 18+ available?
│ └─ Yes → Mutex
│ └─ No, iOS 16+? → OSAllocatedUnfairLock
├─ Need suspension points?
│ └─ Yes → Actor (not lock)
├─ Cross-await access?
│ └─ Yes → Actor (not lock)
└─ Performance-critical hot path?
└─ Yes → Mutex/Atomic (not actor)Common Mistakes
Mistake 1: Using Lock for Async Coordination
// ❌ Locks don't work with async
let mutex = Mutex<Bool>(false)
Task {
await someWork()
mutex.withLock { $0 = true } // Race condition still possible
}
// ✅ Use actor or async state
actor AsyncState {
var isComplete = false
func complete() { isComplete = true }
}Mistake 2: Recursive Locking Attempt
// ❌ Deadlock — OSAllocatedUnfairLock is non-recursive
lock.withLock {
doWork() // If doWork() also calls withLock → deadlock
}
// ✅ Refactor to avoid nested locking
let data = lock.withLock { $0.copy() }
doWork(with: data)Mistake 3: Mixing Lock Styles
// ❌ Don't mix lock/unlock with withLock
lock.lock()
lock.withLock { /* ... */ } // Deadlock!
lock.unlock()
// ✅ Pick one style
lock.withLock { /* all work here */ }Memory Ordering Quick Reference
| Ordering | Read | Write | Use Case |
|---|---|---|---|
.relaxed | Yes | Yes | Counters, no dependencies |
.acquiring | Yes | - | Load before dependent ops |
.releasing | - | Yes | Store after dependent ops |
.acquiringAndReleasing | Yes | Yes | Read-modify-write |
.sequentiallyConsistent | Yes | Yes | Strongest guarantee |
Default choice: .relaxed for counters, .acquiringAndReleasing for read-modify-write.
Resources
Docs: /synchronization, /synchronization/mutex, /os/osallocatedunfairlock
Swift Evolution: SE-0433
Skills: See skills/swift-concurrency.md, axiom-performance (skills/swift-performance.md)
Related skills
FAQ
What Swift concurrency features does axiom-concurrency cover?
axiom-concurrency addresses actors, structured tasks, async sequences, and MainActor boundaries to eliminate data races and UI jank in Swift and SwiftUI codebases.
When should developers apply axiom-concurrency?
axiom-concurrency applies when structuring async Swift work, isolating shared mutable state in actors, or ensuring UI mutations run on MainActor during parallel execution.