iOS Concurrency and Parallelism Complete Guide
Every concurrency mechanism on Apple platforms, from pthread up to Swift 6.2’s approachable concurrency. Runnable examples and migration paths.
Quick comparison
| Mechanism | Introduced | Abstraction level | Cancellation | Priority | Structured | Swift 6 safe |
|---|---|---|---|---|---|---|
POSIX Threads (pthread) |
Unix (all Apple platforms) | Lowest | Manual | Manual | No | No |
Thread (NSThread) |
iOS 2.0 / macOS 10.0 (2001) | Low | Manual via cancel() |
5 levels | No | No |
GCD (DispatchQueue) |
iOS 4.0 / macOS 10.6 (2009) | Medium | DispatchWorkItem |
QoS classes | No | No |
OperationQueue |
iOS 2.0 / macOS 10.5 (2007) | Medium-High | Built-in cancel() |
QoS + dependencies | Partial | No |
| Combine | iOS 13 / macOS 10.15, Swift 5.1 (2019) | High (reactive) | AnyCancellable |
Scheduler-based | No | No |
async/await |
iOS 13+ (back-deploy) / iOS 15 native, Swift 5.5 (2021) | High | Task.cancel() |
TaskPriority |
Yes | Yes |
| Actors | iOS 13+ (back-deploy) / iOS 15 native, Swift 5.5 (2021) | High | Via tasks | Inherited | Yes | Yes |
AsyncSequence / AsyncStream |
iOS 13+ (back-deploy) / iOS 15 native, Swift 5.5 (2021) | High | Task cancellation | Inherited | Yes | Yes |
| Swift 6 strict concurrency | 6.0 / Xcode 16 (2024); 6.1 (Mar 2025); 6.2 approachable (Sept 2025) | Compile-time | N/A | N/A | Yes | Yes |
Swift Concurrency (async/await, actors, AsyncSequence) shipped natively with iOS 15 / macOS 12 (2021), but was back-deployed to iOS 13 / macOS 10.15 starting with Xcode 13.2 (Dec 2021). Some newer APIs like AsyncStream.makeStream (Swift 5.9) and withDiscardingTaskGroup (Swift 5.9) require later minimum deployments.
Mental model
A handful of ideas hold the rest of the chapter together. Worth carrying in your head before reading any specific API: most of the rules below are corollaries of these.
Process vs thread vs task
A process is the OS container: own address space, own file descriptors. Your iOS app is one process; an app extension is a separate process. When the app crashes, iOS restarts it from scratch.
A thread is a unit of execution inside a process. Threads share their process’s memory, and the kernel schedules them onto cores. Creating one costs ~512 KB of stack by default plus a syscall, so you don’t want millions. pthread, NSThread, and GCD workers are all kernel threads dressed in different APIs.
A Task is a Swift Concurrency unit: a heap-allocated state machine the runtime schedules onto threads from the cooperative pool. Cheap (hundreds of bytes), cancellation-aware. Millions of tasks share roughly one-thread-per-core because most are suspended at any instant.
Why the distinction matters when something goes wrong:
- A crash inside a thread tears down the whole process; iOS relaunches the app cold.
- An uncaught error inside a
Taskends the task. The actor it called into keeps running, and other tasks on the cooperative pool keep going. - Across processes, nothing is shared. iOS uses XPC for app-to-extension calls, and the App Group container for shared files.
Cooperative pool, no blocking
Swift Concurrency’s runtime owns a small pool of threads, capped at roughly one thread per CPU core, per QoS class, and its contract is that every thread is always making progress. A Task that calls Thread.sleep, DispatchSemaphore.wait, blocks on a pthread_mutex held by a peer, or makes a synchronous I/O call into a C library parks the thread it borrowed: the runtime can’t reclaim it, can’t reassign it, and can’t apologise. Nothing flags it either: from the runtime’s view, a parked worker is just busy.
The cap is what makes the contract load-bearing. GCD’s failure mode was thread explosion: every worker that blocks gets replaced by a fresh thread with its own stack, until the pool hits a ceiling and work starts to deadlock (§3). Adding more threads doesn’t help if everyone is parked, so the runtime forces you to suspend (release the thread) instead of block (occupy it).
A work item holds a thread, a task borrows one
Creating either one starts no thread. A DispatchWorkItem is a heap object holding a closure plus its QoS and cancellation state: allocate a thousand and the process’s thread count doesn’t move. Queues aren’t threads either. A serial queue promises one block at a time, never a particular thread. Threads come from a pool, and GCD’s pool and the cooperative pool react to blocking in opposite ways.
A work item keeps its thread from the first line of the closure to the last. When the closure blocks, GCD notices a core going idle and starts another worker to fill it. A Task hands its thread back at every await and resumes on whichever pool thread is free. The cooperative pool never adds a thread to replace a blocked one.
The difference is easy to measure. Thirty-two jobs on an 8-core M1 Pro (macOS 27, Swift 6.4), each workload in its own process:
| Workload | Wall time | Peak worker threads |
|---|---|---|
32 DispatchWorkItems on a global queue, each calling sleep(1) |
1.0 s | 32 |
32 child tasks in a TaskGroup, each calling sleep(1) |
4.0 s | 8 |
32 child tasks, each awaiting Task.sleep(for: .seconds(1)) |
1.0 s | 4 |
GCD bought its one-second finish with 32 kernel threads, a stack each, while the blocking task group stayed at core count and ran in four waves of eight. Suspension is what the cooperative pool is built for: the third row finishes in the same second on four threads, because a sleeping task holds no thread at all.
To reproduce, sample the thread count from a plain Thread every 20 ms while the workload runs. Give each workload a fresh process: idle GCD workers linger for seconds, and leftovers from one run inflate the next run’s peak.
1 | // Count this process's kernel threads (macOS and iOS) |
The hop shows up inside a single task too: one detached task, cut into eight segments by awaits, ran them on six different threads. Thread identity means nothing inside async code, and Swift 6 turns the APIs that assume otherwise into compile errors. From an async function in Swift 6 language mode:
1 | class property 'current' is unavailable from asynchronous contexts; Thread.current cannot be used from async contexts. |
The wait line is DispatchSemaphore.wait(); DispatchGroup.wait() gets its own message pointing at task groups. Each replacement lives with its API: @TaskLocal for per-thread context (§7), MainActor.assertIsolated for main-thread checks (§12), scoped withLock (§5), Task.sleep for Thread.sleep (§7), and a continuation around a dispatch queue for any call that has to block (§10).
async is a state machine, not a thread
The compiler splits each async function at every await into partial functions. Locals that survive a suspension live in a heap-allocated context; the function “returns” at await and the runtime invokes the next partial function when the awaited result is ready.
Two things follow:
- Suspension is roughly a function call: no kernel transition, no stack copy, no thread context switch.
awaitis closer toreturnthan toThread.sleep. - Stack traces are split. A debugger paused inside an async function shows the current partial function, not the whole logical call chain. Xcode’s Async Backtrace view stitches the chain back together.
Tasks are coroutines
If you’ve written async function in JavaScript or suspend fun in Kotlin, Swift’s Task is the same construct those languages call a coroutine, and the compiler-split state machine from the previous section is the standard way to build one.
Where Swift sits among coroutine languages:
- Stackless, like Kotlin or JavaScript. Go’s goroutines are stackful instead: each owns a small growable stack, and any function can yield without the compiler instrumenting call sites. Stackless gives a cheaper per-task footprint and a predictable cost model. Stackful makes calling into C from inside async free.
- Coloured functions.
asyncis contagious: only callable from anotherasynccontext. Kotlin’ssuspendmakes the same choice. Go and Lua avoid colour by being stackful. Colour is the price for not switching stacks on every call. - Structured by default (next section). Kotlin’s
coroutineScope { }is the direct equivalent of a task group.go f()in Go is unstructured: you get a goroutine and a hope.
The piece still missing, four years in: a real yield. Task.yield() is a hint the runtime is allowed to ignore, and on a busy actor it routinely does. Kotlin’s yield() reliably hands control to the next ready coroutine. Python uses await asyncio.sleep(0). JavaScript uses await new Promise(setTimeout). Swift has no equivalent contract, so making a long-running task share the pool fairly with UI work means dropping a try? await Task.sleep(for: .nanoseconds(1)) mid-loop.
When you read Task in Swift, mentally substitute “coroutine.” Most concurrency literature outside Swift uses that vocabulary.
Structured concurrency
Children created inside withTaskGroup or via async let belong to the surrounding function, which can’t return until they finish. A child’s lifetime fits inside the lexical scope that created it, and the compiler refuses code that would leak one past its parent. That ownership chain is the single design choice the rest of the system spends its complexity on.
Two consequences worth holding onto:
- Cancellation flows down the tree. Cancel a parent and every descendant sees
Task.isCancelled == trueat the next cooperative check:try Task.checkCancellation(), anawaiton a cancellation-aware API, or the next iteration of anAsyncSequence. You almost never need a manual cancellation flag. - Errors abort siblings. When one child of a
withThrowingTaskGroupthrows, the group cancels its other children before re-throwing. The function returns or throws but never strands a worker.
Task { } and Task.detached { } sit outside the tree. Neither is a child, so cancelling the code that created them doesn’t reach them, and the handle each returns outlives the surrounding scope, which makes unstructured tasks the most common source of “why is this still running?” bugs. Task { } still inherits the caller’s actor isolation along with its priority and task-locals. Task.detached { } inherits nothing; reserve it for work that genuinely has to shed its caller’s context (a fire-and-forget log flush, a background refresh kicked off from a deinit).
Executors decide where work runs
Every async function runs on an executor. The ones you actually meet:
- The global concurrent executor: the cooperative pool above. Where
Task.detached { }runs, andTask { }too when created outside any actor. - Each actor’s serial executor: every actor owns one, and it serializes incoming calls so actor state is data-race-free by construction. The executor isn’t a thread: an actor’s jobs run on whichever cooperative-pool thread is free, so consecutive calls on one actor can land on different threads.
MainActor‘s executor: the main run loop.MainActoris a global actor whose executor happens to be the main thread.
await is the place where the runtime can change executors. When you call an actor method from outside, the await is where you get enqueued on that actor’s serial executor. Most of the design choices later in this chapter (actor reentrancy, Sendable, region-based isolation, priority escalation) are answers to the same question: what guarantees do we need to switch executors safely?
The rest of this chapter walks each API in roughly the order it shipped. Default to Swift Concurrency in new code.
1. POSIX Threads (pthread)
The lowest-level threading API available on Apple platforms. You almost never need this directly, but understanding it helps reason about everything built on top.
1 | import Darwin |
When to use: Almost never. Only for C interop or extreme low-level control (custom thread attributes, real-time scheduling).
Synchronization primitives (rarely needed directly: Foundation and the standard library wrap them):
1 | // Mutex |
pthread also offers pthread_rwlock_* (reader-writer locks) and pthread_cond_* (condition variables). For new code use OSAllocatedUnfairLock, NSCondition, or an actor instead: they wrap the same kernel primitives with safe Swift semantics.
2. Thread (NSThread)
Objective-C era thread abstraction (iOS 2.0 / macOS 10.2, 2002). Slightly higher level than pthreads but still manual.
1 | // Subclass approach |
When to use: Legacy code, run loops that need a dedicated thread (e.g., streaming network connections). Prefer GCD or async/await for new code. For per-thread context, Swift Concurrency’s @TaskLocal (§7) is the modern replacement for Thread.current.threadDictionary.
3. Grand Central Dispatch (GCD)
Apple’s C-based concurrency library, introduced at WWDC 2009 (iOS 4.0 / macOS 10.6 Snow Leopard). Manages a pool of threads automatically: you submit work to queues, GCD decides which thread runs it.
GCD lives in an awkward middle layer today: too low-level for new code (no structured cancellation, no typed return values), too useful to retire (Apple frameworks pass dispatch queues everywhere). Worth knowing in detail because everything around it (Combine schedulers, OperationQueue, the Swift Concurrency interop layer) speaks dispatch.
Serial vs concurrent queues
1 | // Serial queue: tasks execute one at a time, in order |
Sync vs async dispatch
1 | let queue = DispatchQueue(label: "com.app.sync-demo") |
sync doesn’t hop: GCD runs the block on the calling thread, which would otherwise sit idle waiting. The main queue is the exception; its blocks always run on the main thread.
Never call sync on the main queue from the main thread: it deadlocks. Never call sync on a serial queue from that same queue.
Dispatch groups
Coordinate multiple async tasks and get notified when all finish.
1 | let group = DispatchGroup() |
Dispatch barriers
Reader-writer lock pattern on concurrent queues.
1 | final class ThreadSafeArray<Element> { |
Dispatch semaphores
Limit concurrent access to a resource.
1 | // Limit to 3 concurrent downloads |
Dispatch sources
Event-driven callbacks from the system.
1 | // Timer source |
Timer sources
DispatchSourceTimer fires on any queue with no run loop involved. That alone fixes the classic Timer complaint, a countdown label that freezes while the user drags a scroll view because the timer was scheduled in the default run-loop mode. It also suspends and resumes in place, where a Timer can only be invalidated and rebuilt, and it has no target to keep alive until invalidate(), which is the leak Timer is famous for.
Its lifecycle is less forgiving. Three mistakes kill the process with SIGTRAP (verified on macOS 27), each with its own reason in the crash report:
| Mistake | Crash report reason |
|---|---|
| Releasing a source that was never resumed | BUG IN CLIENT OF LIBDISPATCH: Release of an inactive object |
| Releasing a source while it is suspended | BUG IN CLIENT OF LIBDISPATCH: Release of a suspended object |
Calling resume() on a source that is already running |
BUG IN CLIENT OF LIBDISPATCH: Over-resume of an object |
A fourth mistake is silent: drop the last strong reference to a running timer and it never fires. A wrapper that tracks its own state rules out the three traps, and holding it in a property covers the fourth:
1 | final class RepeatingTimer { |
The state isn’t synchronized, so call it from one queue. Whoever sets onFire still needs [weak self] if they own the timer.
Two parameters matter more than the interval. schedule(deadline:) measures DispatchTime, which stops while the device sleeps; schedule(wallDeadline:) follows the wall clock and keeps counting through sleep. Anything tied to real time, like a countdown the user is watching, wants the wall clock, and should derive what it shows from an end date instead of counting ticks, so one late tick can’t make it drift:
1 | let end = Date.now.addingTimeInterval(60) |
The other is leeway. It lets the system fold your wakeup into one it was already doing, and on battery-bound hardware like Apple Watch that matters more than punctuality. Give it as much as the feature tolerates; 10% of the interval is a reasonable start. The system adds some slack even at zero leeway, and the .strict flag on makeTimerSource(flags:queue:) is how a timer that genuinely has to be punctual opts out.
In async code the timer becomes a loop:
1 | let ticker = Task { |
It isn’t a drop-in replacement: the loop waits five seconds after each refresh() returns, so the period stretches by however long the refresh takes, where the dispatch timer keeps a fixed schedule.
Work items with cancellation
1 | // Two-step pattern: declare first so the closure can capture it |
Do NOT use Thread.current.isCancelled inside a DispatchWorkItem: that checks NSThread cancellation, which is completely unrelated. Always use the work item’s own isCancelled property.
Cancelling a work item only sets a flag: an item cancelled before a queue starts it never runs, and once it is running only its own isCancelled checks can stop it, as in the loop above. The first half is what makes work items the standard GCD debounce:
1 | // Debounce: each keystroke cancels the search still waiting to start |
Dispatch-specific data (per-queue context)
1 | let key = DispatchSpecificKey<String>() |
Design note: thread explosion. GCD’s habit of replacing every blocked worker (§Mental model) has a ceiling. On the same Mac, two hundred blocking items on DispatchQueue.global() level off at 64 workers and the rest wait in the queue. That is where thread explosion turns into deadlock: if the blocked workers are waiting on work queued behind them, nothing is left to run it, and when the main thread is one of the waiters the UI freezes for good. Swift Concurrency’s cooperative pool exists primarily to fix this, by turning the same workload into suspended tasks on a core-sized pool.
4. OperationQueue and Operation
Object-oriented task abstraction (iOS 2.0 / macOS 10.5, 2007). Originally built on threads, reimplemented on top of GCD in iOS 4.0 / macOS 10.6 (2009) when BlockOperation was also added. Adds dependency graphs, priorities, KVO-observable state, and built-in cancellation.
Why this still exists: GCD added blocks but dropped Operation’s structured cancellation, KVO state, and dependency graphs. Swift Concurrency replaces both with typed return values, structured cancellation, and compile-time isolation, and is the right answer for new code. OperationQueue survives because Apple frameworks still hand them out, and because explicit dependency graphs (rare) remain easier to read here than in a TaskGroup.
Basic usage
1 | let queue = OperationQueue() |
Custom operations
1 | final class ImageDownloadOperation: Operation { |
Async operations
Wrapping a callback-based API (network, file I/O) inside an Operation requires overriding isAsynchronous, manually toggling isExecuting/isFinished, and emitting KVO notifications around each change. The pattern is well-documented but obsolete: Swift Concurrency’s withCheckedContinuation (§10) does the same job in three lines and is structured. If you’re hitting this in legacy code, the migration is mechanical: wrap the callback API with a continuation, kick the work off from a Task, and delete the AsyncOperation subclass.
Cancellation propagation
1 | let queue = OperationQueue() |
5. Locks and synchronization primitives
Beyond GCD barriers and semaphores, Foundation and the standard library provide several lock types.
NSLock
1 | let lock = NSLock() |
NSRecursiveLock
Allows the same thread to acquire the lock multiple times without deadlocking.
1 | let recursiveLock = NSRecursiveLock() |
os_unfair_lock (C-level, fastest)
The fastest user-space lock on Apple platforms. Cannot be used across processes. Must not be called from Swift directly in a struct (value semantics can copy the lock, causing undefined behavior): use a class wrapper or OSAllocatedUnfairLock (iOS 16+).
1 | import os |
NSCondition
A combined mutex + condition variable.
1 | let condition = NSCondition() |
Atomics (Swift Atomics package)
For lock-free concurrent programming.
1 | import Atomics |
Locks across await
Every lock above must be released by the thread that acquired it. An await can resume on a different thread (§Mental model), so no lock may be held across one. Swift 6 enforces it: inside an async function, lock() and unlock() on NSLock or OSAllocatedUnfairLock are compile errors (§Mental model quotes the message). The scoped form stays legal because its closure is synchronous, so no await can slip inside:
1 | func record(_ event: Event) async { |
State that has to stay consistent across a suspension belongs in an actor (§8), reentrancy caveats included.
Lock comparison
| Lock | Reentrant | Speed | Use case |
|---|---|---|---|
os_unfair_lock / OSAllocatedUnfairLock |
No | Fastest | Hot paths, counters, short critical sections |
NSLock |
No | Fast | General mutex, Obj-C interop |
NSRecursiveLock |
Yes | Moderate | Recursive algorithms |
NSCondition |
No | Moderate | Producer-consumer patterns |
DispatchSemaphore |
No | Fast | Resource limiting in GCD code (wait() is a compile error in async code) |
| GCD barrier | N/A | Fast | Reader-writer on concurrent queues |
| Swift Atomics | N/A | Lock-free | Counters, flags, CAS loops |
6. Combine
Apple’s reactive framework, introduced at WWDC 2019 (iOS 13 / macOS 10.15, Swift 5.1). Declarative chains of publishers and subscribers that handle async events over time.
For new code, prefer AsyncSequence + the Swift Async Algorithms package (§9). Combine remains the right tool for SwiftUI bindings (@Published, ObservableObject) and existing pipelines, but most use cases that aren’t UIKit-bound now have native async equivalents.
For a closer side-by-side of Combine, RxSwift, and synchronous Swift collection chains, see Combine vs RxSwift vs Swift collection chains.
Basics
1 | import Combine |
Concurrency with Combine
1 | // Background processing with main thread delivery |
Parallel with MergeMany
1 | let urls: [URL] = [url1, url2, url3] |
Subjects (imperative push)
1 | // PassthroughSubject: no initial value, only emits to current subscribers |
Debounce, throttle, and timing
1 | let searchText = PassthroughSubject<String, Never>() |
Future (single-value async)
1 | func fetchUser(id: Int) -> Future<User, Error> { |
7. async/await (Swift Concurrency)
Introduced in Swift 5.5 at WWDC 2021; deployment targets are in the note under Quick comparison. The structured-concurrency rules from §Mental model govern everything below.
Basic async functions
1 | func fetchUser(id: Int) async throws -> User { |
Sequential vs parallel execution
1 | // Sequential: each awaits before the next starts |
async let bindings start executing immediately when declared. The await keyword is where you wait for the result. If you never await an async let, the task is implicitly cancelled when the scope exits.
Task groups
For dynamic numbers of concurrent tasks.
1 | func fetchAllUsers(ids: [Int]) async throws -> [User] { |
Discarding task groups (Swift 5.9+)
When you don’t need results from individual tasks: just fire-and-forget with structured cancellation.
1 | try await withThrowingDiscardingTaskGroup { group in |
Unstructured tasks
1 | // Inherits actor context and priority |
Design intent: cancellation is cooperative. task.cancel() only sets a flag. The runtime never kills a task; it relies on the task itself to call Task.checkCancellation() or check Task.isCancelled at safe points. This is the same model as Operation.cancel() and the opposite of pthread_cancel (preemptive at cancellation points). The upside: no broken invariants from a task killed mid-update. The downside: a long synchronous loop with no cancellation check ignores cancel() until it ends. Every long loop needs a check.
Task priority and priority escalation
1 | Task(priority: .high) { |
Design: escalation, not inversion. Classic priority inversion (a high-priority task blocked behind a low-priority lock holder) is solved at the OS level by priority donation: the lock holder runs at the waiter’s priority for as long as it holds the lock. Swift Concurrency generalises this through await: when a high-priority task awaits a low-priority task, or an actor with pending low-priority callers, Swift escalates the awaited chain to the higher priority for the duration. You won’t usually see “priority inversion” in profilers because the runtime resolves it before it surfaces.
Task-local values
Thread-local storage equivalent for structured concurrency. Real thread-locals don’t survive an await: the task can resume on another thread and leave them behind.
1 | enum RequestContext { |
Task sleep and yielding
1 | // Sleep (respects cancellation: throws if cancelled) |
8. Actors
Reference types that protect their mutable state from concurrent access. The compiler enforces isolation: you must await when crossing an actor boundary.
Basic actor
1 | actor BankAccount { |
Actor reentrancy
Actors are reentrant: when an actor suspends (at an await), other callers can execute on it.
1 | actor ImageCache { |
Design intent: reentrancy avoids deadlock. A non-reentrant actor would deadlock in any A→B→A call chain: a classic mutex pattern that’s nearly impossible to avoid in practice. Swift’s actors are reentrant by deliberate choice: the runtime can interleave other callers on the actor across each await. The trade is that invariants don’t survive a suspension: any state you read before await may have been mutated by another caller by the time you resume. Always re-check conditions after suspension points (the cache[url] re-check above is the canonical pattern).
@MainActor
A global actor that guarantees execution on the main thread. Essential for UI code.
1 |
|
Custom global actors
1 |
|
9. AsyncSequence and AsyncStream
Async equivalents of Sequence. Values arrive over time, and iteration suspends between elements.
Built-in AsyncSequences
1 | // URL bytes |
AsyncStream (custom producer)
1 | // Continuation-based (push model) |
AsyncStream.makeStream (Swift 5.9+)
Returns a tuple of stream + continuation for when the producer and consumer are set up in different scopes.
1 | let (stream, continuation) = AsyncStream.makeStream(of: String.self) |
Async algorithms (Swift Async Algorithms package)
1 | import AsyncAlgorithms |
10. Continuations (bridging callback → async)
Convert callback-based APIs to async/await.
withCheckedContinuation
1 | func fetchLocation() async -> CLLocation { |
withCheckedThrowingContinuation
1 | func fetchData(from url: URL) async throws -> Data { |
A continuation must be resumed exactly once. Resuming zero times leaks the task. Resuming more than once is undefined behavior. withCheckedContinuation traps on double-resume in debug builds; withUnsafeContinuation does not check (faster but dangerous).
Delegate pattern bridging
1 | final class LocationBridge: NSObject, CLLocationManagerDelegate { |
Bridging blocking work
The same tool covers the opposite direction: a synchronous API that blocks, called from async code. Data(contentsOf:) on a slow network volume or a C library that waits on a socket will park whatever thread it runs on. Run it on a dispatch queue, where a parked thread is something GCD plans for, and resume the caller from there:
1 | private let ioQueue = DispatchQueue(label: "com.app.io", qos: .utility) |
Marking the function @concurrent (§11) is no substitute: it moves the call off the main actor and onto the cooperative pool, where it parks one of the core-count threads everything else shares.
11. Swift 6 strict concurrency
Swift 6.0 (Xcode 16, Sep 2024) makes data-race safety a compile-time guarantee. Code that was valid in Swift 5 may produce errors in Swift 6 strict mode.
Enabling strict concurrency
1 | // Package.swift |
To see the same diagnostics as warnings first, stay in Swift 5 mode with complete checking:
1 | .target( |
Sendable enforcement
In Swift 6, the compiler checks that values crossing isolation boundaries are Sendable.
1 | // Automatically Sendable: structs/enums with all Sendable stored properties |
Design: Sendable is a typing discipline, not a runtime check. It’s a marker protocol: the compiler reasons about it at compile time and emits zero runtime overhead. A class can conform only if it’s final and every stored property is immutable and Sendable, because that’s the only structural form Swift can verify safe without help. @unchecked Sendable is the explicit “I’ve taken a lock, trust me” escape hatch. The discipline was deliberately strict at first, then loosened in Swift 6 by region-based isolation (next) when the strictness turned out to make a lot of plainly-safe code uncompilable.
Region-based isolation and sending (Swift 6.0)
Why it exists: before SE-0414, you couldn’t pass a freshly-built non-Sendable value across an actor boundary, even when no other code could reach it. A [User] you just constructed and never aliased was plainly safe to transfer, but the compiler had no way to know: Sendable is a per-type discipline, but aliasing is a per-value question.
Region-based isolation tracks isolation regions: a value with no aliases sits in a disconnected region of its own, and the compiler lets it cross an isolation boundary even when its type isn’t Sendable. sending writes that into a signature. A sending parameter means the caller gives the value up; a sending result means the caller receives one nothing else can reach.
1 | // Result: `array` is in a disconnected region, so the caller takes ownership |
nonisolated(unsafe) (escape hatch)
When you know a value is safe but can’t prove it to the compiler.
1 | // Global mutable state that's actually only accessed from one context |
nonisolated(unsafe) disables all compiler checks. Use only when you’ve manually verified safety and can’t restructure the code to satisfy the compiler.
@preconcurrency (incremental adoption)
Suppress warnings from pre-concurrency modules you don’t control.
1 | import SomeOldFramework |
Swift 6.1 (Xcode 16.3, Mar 2025)
Smaller cycle, two notable additions:
- Isolated synchronous deinits (
isolated deinit): a deinit can run on a specific actor, fixing a long-standing “deinit can’t safely touch isolated state” gap. isolated(any)parameters: a parameter that says “I run on whichever actor I was given,” without making the surrounding function isolated. Useful for higher-order async APIs that need to call a callback on the user’s actor.
Swift 6.2: approachable concurrency (Xcode 26, Sept 2025)
Swift 6.2 reframes the defaults around a different mental model: start on the main thread, opt into background work. The 6.0 model was “every async function may hop to the global pool, every closure must prove it’s Sendable.” That produced a mountain of Sendable warnings on code that ran nowhere except the main thread. Three pieces, one philosophy.
nonisolated(nonsending): async stays on the caller’s executor. A nonisolated async function used to implicitly hop to the global executor on entry. Now you can ask it to stay where it was called from:
1 | final class ProfileViewModel { |
@concurrent: opt in to the old behaviour. When you actually want background execution (heavy CPU work, JSON decoding, image processing), mark the function explicitly:
1 | func decode(_ data: Data) async throws -> [User] { |
@concurrent reads as “I will hop to the global pool”; nonisolated(nonsending) reads as “I stay where you called me.” Swift 6.0 gave every nonisolated async function the first behaviour implicitly. The NonisolatedNonsendingByDefault upcoming-feature flag makes the second one the default, so background execution needs an explicit @concurrent; the Approachable Concurrency settings below turn it on.
Default actor isolation (SE-0466): set a default isolation for an entire module:
1 | // Every top-level declaration is implicitly @MainActor |
Approachable Concurrency: a package-level setting that bundles 6.2’s new defaults: MainActor isolation by default, async-stays-on-caller, fewer Sendable checks at hot boundaries. The recommended starting point for new app and UI modules:
1 | .target( |
With both on, a UI module looks closer to single-threaded code with explicit @concurrent doors into the background pool: the inverse of the 6.0 default, and a much shorter path through Sendable warnings for app code.
Concurrency migration checklist
| Swift 5 pattern | Swift 6 equivalent |
|---|---|
DispatchQueue.main.async { } |
Task { @MainActor in } or MainActor.run { } |
var in closure capture |
@Sendable closure + Sendable captures |
class with var properties across threads |
actor or @MainActor class |
Global var |
nonisolated(unsafe) or actor-isolated |
NotificationCenter + @objc handler |
NotificationCenter.default.notifications(named:) async sequence |
| Delegate pattern | AsyncStream or continuation |
DispatchGroup |
async let or TaskGroup |
| GCD barrier queue | actor |
@objc callback closure |
withCheckedContinuation |
Thread.sleep() |
try await Task.sleep(for:) |
Under .defaultIsolation(MainActor.self), most @MainActor annotations in the right column become implicit.
12. Concurrency debugging and profiling
Thread Sanitizer (TSan)
Detects data races at runtime.
1 | Product → Scheme → Edit Scheme → Diagnostics → Thread Sanitizer ✓ |
Or via xcodebuild:
1 | xcodebuild test \ |
Instruments concurrency tools
| Instrument | Purpose |
|---|---|
| Swift Concurrency | Visualize task trees, actor contention, task creation/completion |
| Thread State Trace | Thread blocking, context switches, runnable vs blocked |
| System Trace | Low-level thread scheduling, priority inversions |
| Time Profiler | CPU time per thread, identify hot code on wrong threads |
| os_signpost | Custom intervals for measuring concurrency regions |
Runtime concurrency checks
1 | // Assert main thread (UIKit code) |
When to use what
| Scenario | Recommended approach |
|---|---|
| New async code (iOS 15+) | async/await |
| Protecting mutable state | actor (or @MainActor for UI) |
| Parallel independent tasks (known count) | async let |
| Parallel tasks (dynamic count) | TaskGroup |
| UI updates from background | @MainActor |
| Bridging callbacks to async | withCheckedContinuation |
| Calling a blocking API from async code | Dispatch queue + continuation |
| Event streams over time | AsyncStream / AsyncSequence |
| Reactive chains with operators | Combine (or AsyncAlgorithms) |
| Legacy codebase (pre-iOS 15) | GCD |
| Repeating timer off the main thread | DispatchSourceTimer, or a Task.sleep loop in async code |
| Task dependencies / complex graphs | OperationQueue |
| Lock-free counters | OSAllocatedUnfairLock or Swift Atomics |
| C interop threading | pthread |
Further reading
- Swift concurrency manifesto: Chris Lattner’s original vision
- SE-0296 async/await: the proposal that started it all
- SE-0304 Structured concurrency: task groups and child tasks
- SE-0306 Actors: actor model for Swift
- SE-0337 Sendable: incremental Sendable adoption
- SE-0414 Region-based isolation: the typing rules behind
sending - SE-0430 sending parameter: region-based isolation
- SE-0461 Run nonisolated async functions on the caller’s actor:
nonisolated(nonsending)and@concurrent - SE-0466 Default isolation: module-level default actor isolation
- Swift Async Algorithms: merge, debounce, throttle, combineLatest
- Swift Atomics: lock-free atomic operations
- WWDC21: Swift concurrency: Behind the scenes: cooperative pool, hop counts, runtime contract
- WWDC22: Eliminate data races using Swift Concurrency
- WWDC23: Beyond the basics of structured concurrency
- Donny Wals: Exploring concurrency changes in Swift 6.2
- Donny Wals: What is @concurrent in Swift 6.2?
- How is the Cooperative Thread Pool integrated in Swift?: Swift Forums