| name | programming-concurrency |
| user-invocable | false |
| description | Internal skill invoked by /programming chain. Use when writing, reviewing, debugging, or migrating concurrent code on Apple platforms -- threads, GCD, actors, async/await, Combine, SwiftUI state management, Core Data threading, Sendable conformance, Swift 6 migration errors |
macOS Concurrency
Reference for writing thread-safe code on Apple platforms. Covers Swift concurrency, GCD, actors, Combine, and their interactions. Consult this when writing, reviewing, debugging, or migrating any concurrent code.
0. Detect Project Context First
Before giving concurrency advice, determine:
- Swift language mode: Check
Package.swift for swiftLanguageVersions or target swiftSettings. Check Xcode build settings for "Swift Language Version."
- Default isolation: Look for
.defaultIsolation(MainActor.self) in swiftSettings or MainActorIsolatedByDefault feature flag. If present, all code is @MainActor by default.
- Concurrency model: Scan imports and patterns --
import Dispatch / DispatchQueue = GCD world. actor, async/await, Task = Swift concurrency world. Most real projects are mixed.
- Deployment target:
Mutex/Atomic from Synchronization framework require macOS 15+ / iOS 18+.
Swift 6.2 with defaultIsolation(MainActor.self) inverts assumptions: everything is main-actor unless opted out with nonisolated or @concurrent. Advice depends on which mode the project uses.
1. Hard Rules
Violating any of these produces crashes, deadlocks, or data corruption.
Deadlocks
- Never
DispatchQueue.main.sync from the main thread. Also applies to any serial queue calling .sync on itself.
- Never
DispatchSemaphore.wait() or Thread.sleep() inside an async context. Blocks a cooperative thread pool thread. With enough blocked threads, the entire app deadlocks.
- Never
DispatchQueue.main.sync from inside a @MainActor function. Already on main; sync dispatch to main deadlocks.
- Never hold a lock across an
await suspension point. The lock may never be released.
Data Races
- Every mutable variable accessed from multiple isolation domains needs documented synchronization. No exceptions.
@unchecked Sendable on a mutable class is not a fix. It silences the compiler; the race remains. The type must actually be protected (Mutex, actor, or immutable).
@Observable is NOT thread-safe for mutations. The registrar's lock protects bookkeeping, not your stored properties.
NSManagedObject must never cross thread boundaries. Pass NSManagedObjectID (Sendable) and fetch in the target context. SwiftData: pass PersistentIdentifier.
Main Thread
- All AppKit API calls must be on the main thread unless documented otherwise. NSView, NSWindow, NSViewController, NSOpenPanel, autolayout, menus, accessibility.
@Published setters must fire on the main thread when observed by SwiftUI.
@Observable properties read by SwiftUI must be mutated on MainActor. No runtime warning (unlike @Published) -- silent race or crash. Use @MainActor on the class.
Continuation
withCheckedContinuation must be resumed exactly once on every code path. Missing = caller hangs forever. Double = runtime crash.
Actor Reentrancy
- Actor state is NOT stable across
await points. Another caller can execute between suspension and resumption. Re-read state after awaiting.
2. Which Primitive?
| Need | Primitive | Notes |
|---|
| Protect mutable state (async context) | actor | Serializes access, no explicit locks |
| Protect mutable state (sync context) | Mutex (macOS 15+) or os_unfair_lock | No await needed |
| UI thread safety | @MainActor | Compile-time enforcement |
| Single async operation | async let or Task {} | Task inherits actor context |
| Fan-out N operations | withTaskGroup | Bounded: one-in-one-out pattern |
| Rate limiting concurrency | Bounded TaskGroup | NOT semaphores in async code |
| Reactive streams | AsyncSequence / AsyncStream | Replaces Combine for new code |
| Simple atomic counter/flag | Atomic (macOS 15+) | Lock-free, single values only |
| Bridge callback API | withCheckedContinuation | Resume exactly once (Rule 12) |
| Cross-domain sync | Custom global actor | Shared resource across unrelated types |
| Legacy interop | GCD with careful queue management | Avoid thread explosion |
Do NOT use in async contexts: DispatchSemaphore, NSLock.lock(), Thread.sleep(), DispatchGroup.wait().
3. Writing New Concurrent Code
The Standard Architecture
SwiftUI View (.task) ──observes──▶ @MainActor @Observable ViewModel ──await──▶ actor Service
- View: Uses
.task for async work (auto-cancelled on disappear), .task(id:) for reactive async.
- ViewModel:
@Observable @MainActor class. Holds UI state. Async methods call services.
- Service:
actor types for business logic, caching, network. Return Sendable values.
actor ItemService {
func fetch() async throws -> [Item] { }
}
@Observable @MainActor
final class ItemViewModel {
var items: [Item] = []
var error: Error?
private let service: ItemService
func load() async {
do { items = try await service.fetch() }
catch { self.error = error }
}
}
struct ItemView: View {
@State private var vm: ItemViewModel
var body: some View {
List(vm.items) { item in Text(item.name) }
.task { await vm.load() }
}
}
When NOT to Use @MainActor on a Class
- Pure data models not observed by SwiftUI -- use an actor.
- Computation-heavy classes --
@MainActor blocks the UI thread. Use actor or @concurrent methods.
- Shared services consumed by multiple actors.
Rule: If SwiftUI views directly read its properties, @MainActor. If it only feeds data to a ViewModel, actor.
Task {} vs Task.detached
Task {} inherits the current actor context. Preferred in most cases.
Task.detached {} runs on the cooperative pool with no actor inheritance. Use only for genuinely independent background work.
- Never use
.detached just to "go to background" -- the actor hop is handled automatically by await.
.task vs .onChange for Async Work
Prefer .task(id:) over .onChange + Task {}. The former auto-cancels on value change and disappear. The latter leaks tasks.
4. GCD + Swift Concurrency Coexistence
Most macOS codebases mix GCD and Swift concurrency. The boundary between them is where bugs live.
Bridging GCD to Async
func fetchLegacy() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyAPI.fetch { data, error in
if let error { continuation.resume(throwing: error) }
else { continuation.resume(returning: data!) }
}
}
}
Migration Table
| GCD Pattern | Modern Replacement |
|---|
DispatchQueue.main.async | @MainActor or MainActor.run |
Serial DispatchQueue | actor |
DispatchGroup | withTaskGroup / async let |
DispatchSemaphore (rate limiting) | Bounded TaskGroup |
DispatchQueue.global().async | Task {} or @concurrent |
NSLock / os_unfair_lock | Mutex (Synchronization framework) |
NotificationCenter.addObserver | NotificationCenter.notifications(named:) (AsyncSequence) |
When GCD Is Still Right
- C/ObjC interop requiring dispatch queues
DispatchSource for file descriptors, signals, timers
- Precise QoS targeting
- Legacy code not yet migrated (wrap in actors at boundaries)
5. Sendable & Isolation Boundaries
Decision Tree
Value type (struct/enum)?
├── All stored properties Sendable? → Sendable automatically
└── No → Make them Sendable, or Mutex-protect + @unchecked Sendable
Reference type (class)?
├── final + all let Sendable properties → conform to Sendable
├── Protected by Mutex/actor → @unchecked Sendable + document WHY
└── Otherwise → restructure as actor or struct
The sending Keyword (SE-0430)
Marks parameters/results that transfer ownership across isolation domains. More flexible than full Sendable conformance -- compiler verifies safety at call sites.
Escape Hatches (ranked by danger)
| Escape Hatch | Danger | When Acceptable |
|---|
@preconcurrency import | Low | Dependency hasn't adopted Sendable yet |
nonisolated(unsafe) | Medium | Written once before any concurrent access |
@unchecked Sendable | High | Protected by Mutex YOU control. Verify with TSan |
assumeIsolated | High | You know runtime actor context but compiler can't prove it |
Every escape hatch needs a comment explaining why it's safe. If you can't write that comment, don't use it.
6. Common Mistakes
| # | Mistake | Consequence | Fix |
|---|
| 1 | DispatchQueue.main.sync inside @MainActor | Deadlock | Remove -- already on main |
| 2 | Missing @MainActor on @Observable class | Silent data race | Add @MainActor to the class |
| 3 | @unchecked Sendable as quick fix | Race preserved | Make actually Sendable or use actor |
| 4 | DispatchSemaphore.wait() in async function | Pool deadlock | Use bounded TaskGroup |
| 5 | Actor reentrancy -- stale state across await | Logic bug | Re-read state after await |
| 6 | Missing receive(on: DispatchQueue.main) before Combine .sink updating UI | Background UI update | Add scheduler hop or use @MainActor |
| 7 | Task.detached when Task {} suffices | Lost actor context | Use Task {} to inherit isolation |
| 8 | Advising Mutex for macOS 14 target | Won't compile | Check deployment target first |
| 9 | publisher.values to bridge Combine→async | Drops events under load | Use buffered AsyncStream wrapper |
| 10 | Accidentally serial test: await a(); await b() | Doesn't test concurrency | Use async let for actual concurrency |
| 11 | Passing NSManagedObject across actors | Crash/corruption | Pass NSManagedObjectID, fetch in target |
| 12 | @ModelActor created from MainActor | Runs on main, not background | Create from background context |
7. Swift 6 Migration
Incremental Ladder
- Swift 5 +
StrictConcurrency = targeted → fix warnings
- Swift 5 +
StrictConcurrency = complete → fix warnings
- Swift 6 language mode → warnings become errors
- Swift 6.2 approachable concurrency flags (one at a time)
Migrate leaf modules first (no internal dependencies).
Top Compiler Errors
| Error (abbreviated) | Fix |
|---|
| Capture of non-sendable type in @Sendable closure | Make type Sendable, or capture Sendable snapshot before closure |
| MainActor-isolated property mutated from non-isolated | Add @MainActor to caller, or await MainActor.run {} |
| Static property is not concurrency-safe | static let instead of var, or add @MainActor |
| Sending non-Sendable type risks data races | Add sending to param/return, or make type Sendable |
| Actor-isolated property in @Sendable closure | Copy to local let before closure, or use Task {} not .detached |
| Mutable var captured in @Sendable closure | Copy to let snapshot before closure |
Swift 6.2 Feature Flags
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferSendableFromCaptures"),
.enableUpcomingFeature("InferIsolatedConformances"),
.defaultIsolation(MainActor.self)
nonisolated(nonsending): In 6.2, nonisolated async functions run on the caller's executor by default. A nonisolated method called from MainActor stays on main. Use @concurrent to explicitly run off-main.
8. Code Review Checklist
9. Testing Concurrent Code
Strategy: Two Tests Per Concurrent Component
- Deterministic test: Use
withMainSerialExecutor (from swift-concurrency-extras) to force serial execution. Verifies logic without timing sensitivity.
- Concurrent test: Run with real concurrency + TSan enabled. Catches races.
Key Tools
| Tool | Use For |
|---|
withMainSerialExecutor | Deterministic async testing |
TestClock (swift-clocks) | Time-dependent code without real delays |
confirmation(expectedCount:) | Swift Testing: verify async callbacks fire N times |
| TSan (Thread Sanitizer) | Runtime data race detection (2-20x slowdown) |
TSan Caveats
TSan has known false positives with Swift concurrency as of 2025. Do NOT treat "TSan found no races" as proof of safety. Use it as one signal alongside compile-time checks.
Testing Actors
@Test func actorStateUpdates() async {
await withMainSerialExecutor {
let vm = ItemViewModel(service: MockService())
await vm.load()
#expect(vm.items.count == 3)
}
}
10. Debugging
| Symptom | Tool | Likely Cause |
|---|
| App freezes (deadlock) | LLDB: thread backtrace all | .sync on own queue, or semaphore in async |
| Purple runtime warning | Main Thread Checker | AppKit/UIKit call from background |
| EXC_BAD_ACCESS in concurrent code | TSan + check Sendable boundaries | Data race on shared mutable state |
| Inconsistent state | Actor reentrancy check | State read before await, mutated after |
| Cooperative pool starvation | Instruments System Trace | Blocking calls in async contexts |
| Continuation misuse crash | Search for "SWIFT TASK CONTINUATION MISUSE" | Double resume in withCheckedContinuation |
LLDB Commands
thread list -- all threads
thread backtrace all -- essential for deadlock diagnosis
thread info -- current thread's dispatch queue and QoS
11. Platform-Specific Rules
Core Data
NSManagedObjectContext: one queue only (NSMainQueueConcurrencyType or NSPrivateQueueConcurrencyType). Use perform {} for all private context access.
- Pass
NSManagedObjectID across boundaries, fetch in target context.
NSPersistentContainer.performBackgroundTask {} for background work.
SwiftData
ModelContext is NOT sendable. ModelContainer IS sendable.
@ModelActor runs on the thread where it was created. If created on main, it runs on main.
- Pass
PersistentIdentifier across actors.
Combine
receive(on: DispatchQueue.main) before .sink that touches UI.
@Published must be set from main thread when observed by SwiftUI.
publisher.values drops events under load. Use buffered AsyncStream for reliable bridging.
Set<AnyCancellable> is NOT thread-safe. Confine to one isolation domain.
Metal
MTLCommandQueue: thread-safe. MTLCommandBuffer: NOT thread-safe (encode on one thread).
- Triple buffering with semaphore for CPU-GPU sync.
CAMetalLayer configuration changes: main thread only.
AppKit Thread Safety
| API | Thread Safety |
|---|
| NSView, NSWindow, NSViewController | Main thread only |
| NSImage | Creation OK on background; bitmapData access NOT safe |
| NSColor, NSFont | Immutable, safe to share |
| NSOpenPanel, NSSavePanel | Main thread only (crashes otherwise) |
XPC
- Reply handlers fire on the connection's private serial queue, never main thread.
- Synchronous XPC: reply fires on calling thread.
- Bridge to async with
withCheckedContinuation.