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
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.
Before giving concurrency advice, determine:
Package.swift for swiftLanguageVersions or target swiftSettings. Check Xcode build settings for "Swift Language Version.".defaultIsolation(MainActor.self) in swiftSettings or MainActorIsolatedByDefault feature flag. If present, all code is @MainActor by default.import Dispatch / DispatchQueue = GCD world. actor, async/await, Task = Swift concurrency world. Most real projects are mixed.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.
Violating any of these produces crashes, deadlocks, or data corruption.
DispatchQueue.main.sync from the main thread. Also applies to any serial queue calling .sync on itself.DispatchSemaphore.wait() or Thread.sleep() inside an async context. Blocks a cooperative thread pool thread. With enough blocked threads, the entire app deadlocks.DispatchQueue.main.sync from inside a @MainActor function. Already on main; sync dispatch to main deadlocks.await suspension point. The lock may never be released.@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.@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.withCheckedContinuation must be resumed exactly once on every code path. Missing = caller hangs forever. Double = runtime crash.await points. Another caller can execute between suspension and resumption. Re-read state after awaiting.| 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().
SwiftUI View (.task) ──observes──▶ @MainActor @Observable ViewModel ──await──▶ actor Service
.task for async work (auto-cancelled on disappear), .task(id:) for reactive async.@Observable @MainActor class. Holds UI state. Async methods call services.actor types for business logic, caching, network. Return Sendable values.actor ItemService {
func fetch() async throws -> [Item] { /* network/DB */ }
}
@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() }
}
}
@MainActor blocks the UI thread. Use actor or @concurrent methods.Rule: If SwiftUI views directly read its properties, @MainActor. If it only feeds data to a ViewModel, actor.
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..detached just to "go to background" -- the actor hop is handled automatically by await.Prefer .task(id:) over .onChange + Task {}. The former auto-cancels on value change and disappear. The latter leaks tasks.
Most macOS codebases mix GCD and Swift concurrency. The boundary between them is where bugs live.
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!) }
}
}
}
| 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) |
DispatchSource for file descriptors, signals, timersValue 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
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 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.
| # | 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 |
StrictConcurrency = targeted → fix warningsStrictConcurrency = complete → fix warningsMigrate leaf modules first (no internal dependencies).
| 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 |
// Package.swift swiftSettings
.enableUpcomingFeature("NonisolatedNonsendingByDefault"), // nonisolated async stays on caller
.enableUpcomingFeature("InferSendableFromCaptures"), // auto @Sendable for provably safe closures
.enableUpcomingFeature("InferIsolatedConformances"), // protocol conformances inherit isolation
.defaultIsolation(MainActor.self) // MainActor-by-default (app targets only)
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.
DispatchQueue.main.sync (especially inside @MainActor code)semaphore.wait, Thread.sleep) in async contexts@Observable classes driving SwiftUI are @MainActor@Published mutations are on main threadNSManagedObject / ModelContext never crosses isolation boundarieswithCheckedContinuation resumes exactly once on all pathsawait don't assume state stability across suspension@unchecked Sendable has a comment explaining the protection mechanism.task(id:) preferred over .onChange + Task {} for reactive asyncTask.detached where Task {} would preserve needed actor contextwithMainSerialExecutor (from swift-concurrency-extras) to force serial execution. Verifies logic without timing sensitivity.| 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 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.
@Test func actorStateUpdates() async {
await withMainSerialExecutor {
let vm = ItemViewModel(service: MockService())
await vm.load()
#expect(vm.items.count == 3)
}
}
| 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 |
thread list -- all threadsthread backtrace all -- essential for deadlock diagnosisthread info -- current thread's dispatch queue and QoSNSManagedObjectContext: one queue only (NSMainQueueConcurrencyType or NSPrivateQueueConcurrencyType). Use perform {} for all private context access.NSManagedObjectID across boundaries, fetch in target context.NSPersistentContainer.performBackgroundTask {} for background work.ModelContext is NOT sendable. ModelContainer IS sendable.@ModelActor runs on the thread where it was created. If created on main, it runs on main.PersistentIdentifier across actors.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.MTLCommandQueue: thread-safe. MTLCommandBuffer: NOT thread-safe (encode on one thread).CAMetalLayer configuration changes: main thread only.| 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) |
withCheckedContinuation.