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.
The hierarchy also explains the most common Swift Concurrency bug: blocking a thread from inside a task. The task can’t suspend, so it parks the cooperative thread it borrowed. Two of those parked threads are half of a 4-core pool. The runtime can’t tell you anything is wrong; from its view, the worker is busy.
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.
The cap is what makes the contract load-bearing. GCD’s failure mode was thread explosion: a queue with ready work and no available worker would create one, easy to push past the kernel’s thread limit on I/O-bound workloads. 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). This is also why creating millions of tasks is fine: they share that handful of threads via cheap suspensions, not by spawning kernel threads.
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. The compiler-split state machine from the previous section is the standard implementation: every await is a continuation point, locals live in a heap-allocated frame, and the function “returns” at suspension while the runtime invokes the next part when the result is ready.
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. A
withTaskGroup { }block can’t return until its children finish, and cancellation flows down the tree. Kotlin’scoroutineScope { }is the direct equivalent.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
Every task belongs to a parent. A top-level Task { } is owned by the calling context; children created inside withTaskGroup or via async let are owned by the surrounding function, and the parent can’t return until they finish. 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.detached { } is the deliberate escape hatch: no parent, no cancellation propagation, no priority inheritance. Use it when you genuinely want the task to outlive its caller (a fire-and-forget log flush, a background refresh kicked off from a deinit). Most of the time you don’t, and a structured Task { } is the right default.
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. The default for
Task { }andTask.detached { }. - Each actor’s serial executor: every actor owns one, and it serializes incoming calls so actor state is data-race-free by construction.
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; the older systems survive because they predate it and Apple frameworks still hand them out.
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") |
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 |
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.
Dispatch-specific data (per-queue context)
1 | let key = DispatchSpecificKey<String>() |
Design note: thread explosion. GCD’s worker model creates a new thread whenever a queue has ready work and no available worker. Under I/O-bound load this can exceed the kernel’s thread limit and crash the process. Swift Concurrency’s cooperative pool (§Mental model) exists primarily to fix this: the same workload becomes thousands of suspended tasks sharing a small pool of threads, not thousands of parked threads.
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 |
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, async signaling |
| 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 (iOS 15 native, back-deployed to iOS 13+ with Xcode 13.2). The modern, recommended approach for all new code.
The shape: lifetime ⊆ lexical scope. async let and TaskGroup guarantee child tasks complete (or are cancelled) before the enclosing scope exits. Errors propagate up the task tree, cancellation propagates down, and the compiler refuses code that would leak a child past its parent. Task { } is the deliberate exception: it returns a handle that outlives the surrounding scope, which is also why unstructured tasks are the most common source of “why is this still running?” bugs.
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.
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 |
|
Actor-isolated properties and Sendable
1 | // Sendable: safe to pass across actor boundaries |
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 is implicitly Sendable 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 (see §11) when the strictness turned out to make a lot of plainly-safe code uncompilable.
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 { |
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 |
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 |
Region-based isolation (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 is in its own region and can be transferred. sending declares “this parameter (or return) is in a disconnected region: the callee/caller may take ownership.” The compiler tracks which “region” a value belongs to, and values in disconnected regions can be sent across isolation boundaries even if not Sendable.
1 | // This works in Swift 6 because `array` is in a disconnected region |
sending parameter and return types (Swift 6.0)
1 | // The caller must give up ownership of the value |
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.” The 6.0 default was the former implicitly; the 6.2 default (with the upcoming-feature flag below) is the latter.
NonisolatedNonsendingByDefault upcoming-feature flag: flips the default. Any unannotated nonisolated async declaration behaves as nonisolated(nonsending); functions that need background execution must say @concurrent explicitly.
1 | .target( |
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 Swift 6.2 with .defaultIsolation(MainActor.self), most @MainActor annotations in the right column become implicit: UI code is on MainActor by default and only background work needs explicit annotation (@concurrent).
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) |
Strict concurrency checking (pre-Swift 6)
Enable warnings before fully migrating:
1 | // Package.swift |
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 |
| Event streams over time | AsyncStream / AsyncSequence |
| Reactive chains with operators | Combine (or AsyncAlgorithms) |
| Legacy codebase (pre-iOS 15) | GCD |
| 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