iOS Concurrency and Parallelism Comprehensive Guide
Posted onIniOSViews: Word count in article: 4.6kReading time ≈23 mins.
A comprehensive reference covering every concurrency and parallelism mechanism available on Apple platforms — from the lowest-level POSIX threads to Swift 6’s strict concurrency model — with runnable examples and migration guidance.
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
Swift 6.0, Xcode 16 (2024)
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.
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 2 3 4 5 6 7 8 9 10 11 12 13 14
import Darwin
funcposixThreadExample() { var thread: pthread_t?
let result = pthread_create(&thread, nil, { _in print("Running on POSIX thread: \(pthread_self())") returnnil }, nil)
if result ==0, let thread { pthread_join(thread, nil) // Block until thread finishes } }
When to use: Almost never. Only for C interop or extreme low-level control (custom thread attributes, real-time scheduling).
// Read-write lock var rwlock = pthread_rwlock_t() pthread_rwlock_init(&rwlock, nil) pthread_rwlock_rdlock(&rwlock) // Multiple readers OK pthread_rwlock_wrlock(&rwlock) // Exclusive writer pthread_rwlock_unlock(&rwlock) pthread_rwlock_destroy(&rwlock)
// Condition variable var cond = pthread_cond_t() pthread_cond_init(&cond, nil) pthread_cond_wait(&cond, &mutex) // Wait for signal pthread_cond_signal(&cond) // Wake one waiter pthread_cond_broadcast(&cond) // Wake all waiters
2. Thread (NSThread)
Objective-C era thread abstraction (iOS 2.0 / macOS 10.2, 2002). Slightly higher level than pthreads but still manual.
// Perform selector on main thread (Obj-C interop) // myObject.performSelector(onMainThread: #selector(updateUI), with: nil, waitUntilDone: false)
Thread-local storage:
1 2 3 4
// Each thread gets its own copy let key ="com.app.requestID" Thread.current.threadDictionary[key] =UUID().uuidString let requestID =Thread.current.threadDictionary[key] as?String
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.
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.
Serial vs concurrent queues
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// Serial queue — tasks execute one at a time, in order let serial =DispatchQueue(label: "com.app.serial") serial.async { print("Task 1") } serial.async { print("Task 2") } // Always after Task 1
// Concurrent queue — tasks can run simultaneously let concurrent =DispatchQueue(label: "com.app.concurrent", attributes: .concurrent) concurrent.async { print("Task A") } concurrent.async { print("Task B") } // May run alongside Task A
// Global concurrent queues (shared, system-managed) DispatchQueue.global(qos: .userInteractive).async { /* Highest priority */ } DispatchQueue.global(qos: .userInitiated).async { /* User triggered, expects quick result */ } DispatchQueue.global(qos: .default).async { /* Normal priority */ } DispatchQueue.global(qos: .utility).async { /* Long tasks, progress bar OK */ } DispatchQueue.global(qos: .background).async { /* User doesn't notice — backups, indexing */ }
// Main queue — always serial, always main thread DispatchQueue.main.async { /* UI updates here */ }
Sync vs async dispatch
1 2 3 4 5 6 7 8 9 10 11 12 13
let queue =DispatchQueue(label: "com.app.sync-demo")
// async: returns immediately, work runs later queue.async { print("Async work") } print("This prints before async work")
// sync: blocks the calling thread until work completes queue.sync { print("Sync work") } print("This prints after sync work")
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.
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 2 3 4 5 6 7 8
let key =DispatchSpecificKey<String>() let queue =DispatchQueue(label: "com.app.identified") queue.setSpecific(key: key, value: "my-queue")
queue.async { let name =DispatchQueue.getSpecific(key: key) print("Running on: \(name ??"unknown")") }
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.
Basic usage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
let queue =OperationQueue() queue.maxConcurrentOperationCount =3// Limit parallelism queue.qualityOfService = .userInitiated
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 2 3 4 5 6 7 8 9 10 11 12
import os
// iOS 16+ / macOS 13+ — safe Swift wrapper let lock =OSAllocatedUnfairLock(initialState: 0)
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.
// Background processing with main thread delivery URLSession.shared.dataTaskPublisher(for: url) .map(\.data) .decode(type: [User].self, decoder: JSONDecoder()) .receive(on: DispatchQueue.main) // Switch to main for UI .sink( receiveCompletion: { completion in ifcase .failure(let error) = completion { print("Error: \(error)") } }, receiveValue: { users in // Update UI with users — guaranteed main thread } ) .store(in: &cancellables)
// Subscribe on a background queue publisher .subscribe(on: DispatchQueue.global(qos: .background)) // Work happens here .receive(on: DispatchQueue.main) // Results delivered here .sink { value in/* UI update */ } .store(in: &cancellables)
Parallel with MergeMany
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
let urls: [URL] = [url1, url2, url3]
let publishers = urls.map { url in URLSession.shared.dataTaskPublisher(for: url) .map(\.data) .catch { _inEmpty<Data, Never>() } }
Publishers.MergeMany(publishers) .collect() // Wait for all to finish .receive(on: DispatchQueue.main) .sink { allData in print("Got \(allData.count) responses") } .store(in: &cancellables)
Subjects (imperative push)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// PassthroughSubject — no initial value, only emits to current subscribers let eventBus =PassthroughSubject<String, Never>() eventBus.send("Hello") // Lost if no subscriber
// CurrentValueSubject — has a current value, replays to new subscribers let counter =CurrentValueSubject<Int, Never>(0) counter.value // 0 counter.send(1) counter.value // 1
// Calling Task { do { let user =tryawait fetchUser(id: 42) print(user.name) } catch { print("Failed: \(error)") } }
Sequential vs parallel execution
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// Sequential — each awaits before the next starts funcloadProfile() asyncthrows -> Profile { let user =tryawait fetchUser(id: 42) // Wait... let avatar =tryawait fetchImage(user.avatarURL) // Then wait... let posts =tryawait fetchPosts(userId: 42) // Then wait... returnProfile(user: user, avatar: avatar, posts: posts) }
// Parallel with async let — all three start concurrently funcloadProfileFast() asyncthrows -> Profile { asynclet user = fetchUser(id: 42) asynclet avatar = fetchImage(avatarURL) asynclet posts = fetchPosts(userId: 42) returntryawaitProfile(user: user, avatar: avatar, posts: posts) }
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.
funcfetchAllUsers(ids: [Int]) asyncthrows -> [User] { tryawait withThrowingTaskGroup(of: User.self) { group in for id in ids { group.addTask { tryawait fetchUser(id: id) } }
var users: [User] = [] fortryawait user in group { users.append(user) } return users } }
// With limited concurrency (manual sliding-window pattern) funcfetchAllUsersLimited(ids: [Int]) asyncthrows -> [User] { tryawait withThrowingTaskGroup(of: User.self) { group in var iterator = ids.makeIterator()
var users: [User] = [] fortryawait user in group { users.append(user) // As each completes, start the next iflet id = iterator.next() { group.addTask { tryawait fetchUser(id: id) } } } return users } }
Discarding task groups (Swift 5.9+)
When you don’t need results from individual tasks — just fire-and-forget with structured cancellation.
1 2 3 4 5 6 7 8
tryawait withThrowingDiscardingTaskGroup { group in for connection in connections { group.addTask { tryawait handleConnection(connection) // Result is discarded } } // All tasks automatically cancelled if any throws }
// Inherits actor context and priority let task =Task { let user =tryawait fetchUser(id: 42) await updateUI(with: user) // Runs on the caller's actor }
// Does NOT inherit context — runs on global executor let detached =Task.detached(priority: .background) { let data =tryawait processLargeFile() return data }
// Cancellation task.cancel() let result =tryawait task.value // Still need to await (and handle errors)
// Check cancellation inside a task funcprocessItems(_items: [Item]) asyncthrows { for item in items { tryTask.checkCancellation() // Throws CancellationError // Or: guard!Task.isCancelled else { return } await process(item) } }
Task priority and priority escalation
1 2 3 4 5 6 7 8 9 10 11 12 13
Task(priority: .high) { // High-priority work }
Task(priority: .low) { // Low-priority work — may be escalated if a high-priority task awaits it }
// Priority escalation happens automatically: let lowTask =Task(priority: .low) { await heavyComputation() } Task(priority: .high) { let result =await lowTask.value // lowTask gets escalated to .high }
Task-local values
Thread-local storage equivalent for structured concurrency.
funcprocessRequest() async { // Available anywhere in the task tree print("Request: \(RequestContext.requestID)") print("User: \(RequestContext.userID ??-1)") }
Task sleep and yielding
1 2 3 4 5 6 7 8 9 10 11 12 13
// Sleep (respects cancellation — throws if cancelled) tryawaitTask.sleep(for: .seconds(1)) // Swift 5.9+ Duration-based tryawaitTask.sleep(nanoseconds: 1_000_000_000) // Older API
// Yield (give other tasks a chance to run) awaitTask.yield()
Reference types that protect their mutable state from concurrent access. The compiler enforces isolation — you must await when crossing an actor boundary.
// nonisolated — can be called without await (no mutable state access) nonisolatedvar description: String { "Account \(id)"// Only accesses let property } }
// Usage — must await let account =BankAccount(id: "001", balance: 1000) await account.deposit(500) let balance =await account.balance print(account.description) // No await needed — nonisolated
Actor reentrancy
Actors are reentrant — when an actor suspends (at an await), other callers can execute on it.
funcloadUser() async { // This runs on the main thread (we're @MainActor) let user =try?await fetchUser(id: 42) // Suspends, frees main thread self.user = user // Back on main thread tableView.reloadData() // Safe — main thread } }
// All methods on UserRepository are isolated to DatabaseActor // Must await from outside: let repo =UserRepository() let user =await repo.getUser(id: 42)
Actor-isolated properties and Sendable
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// Sendable — safe to pass across actor boundaries structUserDTO: Sendable { let id: Int let name: String }
// Not Sendable — has mutable reference state classMutableState { var count =0// Compiler warns if sent across actors }
// Manually mark as @unchecked Sendable (you're responsible for thread safety) finalclassThreadSafeCounter: @unchecked Sendable { privatelet lock =OSAllocatedUnfairLock(initialState: 0)
funcincrement() { lock.withLock { $0+=1 } } }
9. AsyncSequence and AsyncStream
Async equivalents of Sequence. Values arrive over time, and iteration suspends between elements.
Built-in AsyncSequences
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// URL bytes let (bytes, _) =tryawaitURLSession.shared.bytes(from: url) fortryawait byte in bytes { process(byte) }
// File lines let fileURL =URL(filePath: "/path/to/file.txt") fortryawait line in fileURL.lines { print(line) }
// Notifications let notifications =NotificationCenter.default.notifications(named: .NSManagedObjectContextDidSave) forawait notification in notifications { handleSyncChange(notification) }
// Continuation-based (push model) let heartbeats =AsyncStream<Date> { continuation in let timer =Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _in continuation.yield(Date()) } continuation.onTermination = { _in timer.invalidate() } }
forawait beat in heartbeats { print("Heartbeat: \(beat)") }
// With buffering policy let buffered =AsyncStream<Int>(bufferingPolicy: .bufferingNewest(5)) { continuation in for i in0..<100 { continuation.yield(i) // Only keeps newest 5 if consumer is slow } continuation.finish() }
// AsyncThrowingStream — can produce errors let dataStream =AsyncThrowingStream<Data, Error> { continuation in startMonitoring { result in switch result { case .success(let data): continuation.yield(data) case .failure(let error): continuation.finish(throwing: error) } } }
AsyncStream.makeStream (Swift 5.9+)
Returns a tuple of stream + continuation for when the producer and consumer are set up in different scopes.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
let (stream, continuation) =AsyncStream.makeStream(of: String.self)
// Producer (e.g., delegate callback) funclocationManager(_manager: CLLocationManager, didUpdateLocationslocations: [CLLocation]) { for location in locations { continuation.yield(location.description) } }
// Merge multiple sequences let merged = merge(notificationsStream, timerStream)
// Debounce let debounced = searchTextStream.debounce(for: .milliseconds(300))
// Throttle let throttled = sensorStream.throttle(for: .seconds(1))
// Combine latest let combined = combineLatest(locationStream, headingStream) forawait (location, heading) in combined { updateMap(location: location, heading: heading) }
// Zip (pairs elements 1:1) let zipped =zip(requestStream, responseStream)
// Chain (sequential concatenation) let chained = chain(cachedResults.async, networkResults)
// Chunked let batched = dataPoints.chunks(ofCount: 10) forawait batch in batched { await uploadBatch(Array(batch)) }
10. Continuations (bridging callback → async)
Convert callback-based APIs to async/await.
withCheckedContinuation
1 2 3 4 5 6 7
funcfetchLocation() async -> CLLocation { await withCheckedContinuation { continuation in locationManager.requestLocation { location in continuation.resume(returning: location) } } }
withCheckedThrowingContinuation
1 2 3 4 5 6 7 8 9 10 11 12 13
funcfetchData(fromurl: URL) asyncthrows -> Data { tryawait withCheckedThrowingContinuation { continuation in URLSession.shared.dataTask(with: url) { data, _, error in iflet error { continuation.resume(throwing: error) } elseiflet data { continuation.resume(returning: data) } else { continuation.resume(throwing: URLError(.unknown)) } }.resume() } }
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).
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.
// Automatically Sendable: structs/enums with all Sendable stored properties structPoint: Sendable { let x: Double let y: Double }
// Classes must be final + immutable to be implicitly Sendable finalclassConfig: Sendable { let apiKey: String let timeout: Int init(apiKey: String, timeout: Int) { self.apiKey = apiKey self.timeout = timeout } }
// @Sendable closures — no mutable captures let task =Task { @Sendablein // Cannot capture mutable local variables }
The compiler tracks which “region” a value belongs to. Values in disconnected regions can be sent across isolation boundaries even if not Sendable.
1 2 3 4 5 6 7 8 9 10 11 12 13
// This works in Swift 6 because `array` is in a disconnected region funcmakeArray() -> sending [String] { var array = ["hello"] array.append("world") return array // Transferred to the caller's region }
actorProcessor { funcprocess() async { let data = makeArray() // Receives ownership of the array print(data) } }
sending parameter and return types (Swift 6.0)
1 2 3 4 5 6 7 8 9 10 11
// The caller must give up ownership of the value actorImageProcessor { funcprocess(_image: sending UIImage) { // This actor now owns the image } }
// The callee guarantees the return value is in a disconnected region funccreateBuffer() -> sending [UInt8] { [UInt8](repeating: 0, count: 1024) }
nonisolated(unsafe) (escape hatch)
When you know a value is safe but can’t prove it to the compiler.
1 2 3 4 5
// Global mutable state that's actually only accessed from one context nonisolated(unsafe) var legacyCache: [String: Any] = [:]
// Module-level configurable strings nonisolated(unsafe) var appName ="My App"
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 2 3 4
@preconcurrencyimport SomeOldFramework
// SomeOldFramework's types are treated as implicitly Sendable // Warnings are suppressed at the import boundary
Default actor isolation (Swift 6.2)
Swift 6.2 (Xcode 26, Jun 2025) introduces the ability to set a default isolation for an entire module (SE-0466).
1 2 3 4 5 6 7 8
// Package.swift — default MainActor isolation for a UI module .target( name: "MyUIModule", swiftSettings: [ .swiftLanguageMode(.v6), .defaultIsolation(MainActor.self), ] )
With default MainActor isolation:
1 2 3 4 5 6 7 8 9 10
// Every declaration is implicitly @MainActor classProfileViewModel { // Implicitly @MainActor var name =""// Isolated to MainActor funcload() async { } // Isolated to MainActor }
// Opt out explicitly nonisolatedfuncpureComputation(_x: Int) -> Int { x *2 }