| name | ios-concurrency |
| description | iOS/Swift concurrency expert - advises on Swift Concurrency (async/await, actors, TaskGroup), Grand Central Dispatch, Combine, OperationQueue, and Thread. Use when the user asks about iOS/macOS threading, parallelism, data races, deadlocks, MainActor, actor isolation, Sendable conformance, GCD queues, Dispatch barriers, Combine schedulers, or migrating from GCD to Swift Concurrency. |
iOS Concurrency Expert
Expert guidance on iOS and macOS concurrency across all layers: Swift Concurrency (modern), GCD, Combine, OperationQueue, and legacy Threading primitives. Covers thread safety, data races, actor isolation, and migration patterns.
Concurrency Model at a Glance
| Primitive | Parallelism | Overhead | Scheduler | Best For |
|---|
| Swift Task (async/await) | ✅ True | Very low | Swift runtime cooperative | Modern async code, structured concurrency |
| Swift Actor | ✅ (serial per-actor) | Very low | Swift runtime | Shared mutable state, replacing locks |
| DispatchQueue (serial) | ❌ | Low | GCD (preemptive) | Serializing access, background work |
| DispatchQueue (concurrent) | ✅ True | Low | GCD (preemptive) | Parallel I/O, read-heavy workloads |
| OperationQueue | ✅ True | Medium | GCD under the hood | Dependency graphs, cancellable work |
| Combine | Depends on scheduler | Low | Configurable | Reactive pipelines, event streams |
| Thread | ✅ True | High | OS | Rarely—prefer higher abstractions |
Swift Concurrency (async/await)
Swift Concurrency is the preferred model for all new iOS/macOS code (iOS 15+, Swift 5.5+).
async/await Basics
func fetchUser(id: String) async throws -> User {
let data = try await URLSession.shared.data(from: url).0
return try JSONDecoder().decode(User.self, from: data)
}
Task {
do {
let user = try await fetchUser(id: "42")
updateUI(with: user)
} catch {
handleError(error)
}
}
Key rules:
await is a suspension point — the thread is freed, not blocked
- A suspended task resumes on the same actor it suspended from (unless you hop explicitly)
async functions can only be called from async contexts or inside Task { }
Structured Concurrency
Structured concurrency ties task lifetimes to scopes. Child tasks are cancelled when their scope exits.
func loadDashboard() async throws -> Dashboard {
async let profile = fetchProfile()
async let feed = fetchFeed()
async let notifications = fetchNotifications()
return Dashboard(profile: try await profile,
feed: try await feed,
notifications: try await notifications)
}
func resizeImages(_ images: [UIImage]) async -> [UIImage] {
await withTaskGroup(of: UIImage.self) { group in
for image in images {
group.addTask { await resizeSingle(image) }
}
var results: [UIImage] = []
for await resized in group {
results.append(resized)
}
return results
}
}
Task Cancellation
func processItems(_ items: [Item]) async throws {
for item in items {
try Task.checkCancellation()
await process(item)
}
}
func longWork() async -> Result {
while !Task.isCancelled {
doSomeWork()
}
return .cancelled
}
let task = Task {
try await processItems(items)
}
task.cancel()
Task Priority
Task(priority: .userInitiated) { ... }
Task(priority: .utility) { ... }
Task(priority: .background) { ... }
Task.detached(priority: .background) {
await performBackgroundSync()
}
Warning: Task.detached breaks structured concurrency — the parent cannot cancel it. Prefer Task { } unless you explicitly need detachment.
See Swift Concurrency deep dive for continuations, AsyncSequence, and clock/sleep APIs.
Actors & MainActor
Actors are Swift's primary mechanism for protecting shared mutable state without explicit locks.
Defining Actors
actor UserCache {
private var cache: [String: User] = [:]
func user(for id: String) -> User? {
cache[id]
}
func insert(_ user: User, for id: String) {
cache[id] = user
}
nonisolated var description: String { "UserCache" }
}
let cache = UserCache()
await cache.insert(user, for: user.id)
let cached = await cache.user(for: "42")
Actor Reentrancy
Actors are reentrant: while a task is suspended inside an actor method, another task can run on the same actor. This can cause state to change across an await:
actor BankAccount {
var balance: Double = 1000
func withdraw(_ amount: Double) async {
guard balance >= amount else { return }
await logTransaction(amount)
balance -= amount
}
func withdrawSafe(_ amount: Double) async {
guard balance >= amount else { return }
balance -= amount
await logTransaction(amount)
}
}
@MainActor
@MainActor guarantees code runs on the main thread. Use it for all UI work:
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
let fetched = try? await api.fetchItems()
items = fetched ?? []
}
}
await MainActor.run {
label.text = "Done"
}
See Actors deep dive for Sendable, global actors, actor isolation boundaries, and interop with ObjC.
Grand Central Dispatch (GCD)
GCD remains widely used in production codebases and in UIKit/AppKit internal APIs.
DispatchQueue Basics
let serialQueue = DispatchQueue(label: "com.app.serial")
serialQueue.async { doWork() }
serialQueue.sync { criticalSection() }
let concurrentQueue = DispatchQueue(label: "com.app.concurrent",
attributes: .concurrent)
concurrentQueue.async { task1() }
concurrentQueue.async { task2() }
DispatchQueue.global(qos: .userInitiated).async { expensiveWork() }
DispatchQueue.global(qos: .background).async { syncToServer() }
DispatchQueue.main.async { updateUI() }
QoS Classes
| QoS | Use Case | Relative Priority |
|---|
.userInteractive | Animations, UI response | Highest |
.userInitiated | User-triggered, awaiting result | High |
.default | General work | Medium |
.utility | Long tasks, progress visible | Low |
.background | Sync, backups | Lowest |
DispatchGroup
let group = DispatchGroup()
group.enter()
fetchProfile { profile in
self.profile = profile
group.leave()
}
group.enter()
fetchFeed { feed in
self.feed = feed
group.leave()
}
group.notify(queue: .main) {
self.updateUI()
}
group.wait(timeout: .now() + 5)
Barriers (Reader-Writer Pattern)
let queue = DispatchQueue(label: "com.app.rw", attributes: .concurrent)
var data: [String: Any] = [:]
func read(key: String) -> Any? {
queue.sync { data[key] }
}
func write(value: Any, for key: String) {
queue.async(flags: .barrier) { self.data[key] = value }
}
DispatchSemaphore
let semaphore = DispatchSemaphore(value: 3)
for url in urls {
semaphore.wait()
URLSession.shared.dataTask(with: url) { _, _, _ in
semaphore.signal()
}.resume()
}
Warning: Never call semaphore.wait() on the main thread — it blocks the run loop.
See GCD deep dive for DispatchSource, timers, I/O sources, and DispatchWorkItem.
Combine
Combine is Apple's reactive framework for processing asynchronous event streams (iOS 13+).
Core Concepts
import Combine
let cancellable = URLSession.shared
.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion { handle(error) }
},
receiveValue: { user in self.user = user }
)
Subjects (imperative bridge into Combine)
let events = PassthroughSubject<Event, Never>()
events.send(.userTapped)
let isLoading = CurrentValueSubject<Bool, Never>(false)
isLoading.value = true
Schedulers
publisher
.subscribe(on: DispatchQueue.global(qos: .background))
.receive(on: RunLoop.main)
Memory Management
var cancellables = Set<AnyCancellable>()
publisher
.sink { value in print(value) }
.store(in: &cancellables)
See Combine deep dive for backpressure, custom publishers, error handling, and async/Combine interop.
Legacy: OperationQueue & Thread
OperationQueue
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 4
queue.addOperation {
performWork()
}
let op1 = BlockOperation { fetchData() }
let op2 = BlockOperation { processData() }
op2.addDependency(op1)
queue.addOperations([op1, op2], waitUntilFinished: false)
queue.cancelAllOperations()
Thread
Avoid raw Thread in new code. Use only when interfacing with C APIs requiring thread affinity.
let thread = Thread {
RunLoop.current.run()
}
thread.qualityOfService = .utility
thread.start()
See Legacy concurrency for NSLock, NSRecursiveLock, RunLoop details, and NSOperation subclassing.
Common Patterns
Parallel Network Requests (Modern)
func loadDashboard() async throws -> Dashboard {
async let profile = try await api.fetchProfile()
async let feed = try await api.fetchFeed()
return Dashboard(profile: try await profile, feed: try await feed)
}
Safe UI Updates from Background
Task { @MainActor in
self.tableView.reloadData()
}
DispatchQueue.main.async {
self.tableView.reloadData()
}
Wrapping Completion Handlers (Bridging to async)
func fetchData(from url: URL) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
URLSession.shared.dataTask(with: url) { data, _, error in
if let error { continuation.resume(throwing: error) }
else if let data { continuation.resume(returning: data) }
else { continuation.resume(throwing: URLError(.badServerResponse)) }
}.resume()
}
}
Rule: Never call resume more than once — it crashes. Ensure all code paths resume exactly once.
Throttling Concurrent Work
func processInBatches(_ items: [Item], concurrency: Int = 4) async {
await withTaskGroup(of: Void.self) { group in
var active = 0
for item in items {
if active >= concurrency {
await group.next()
active -= 1
}
group.addTask { await self.process(item) }
active += 1
}
}
}
Actor-Isolated Cache
actor ImageCache {
private var cache: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let cached = cache[url] { return cached }
if let existing = inFlight[url] { return try await existing.value }
let task = Task { try await downloadImage(from: url) }
inFlight[url] = task
defer { inFlight.removeValue(forKey: url) }
let image = try await task.value
cache[url] = image
return image
}
}
Diagnosing Issues
Thread Sanitizer (TSan)
Enable in Xcode: Product → Scheme → Run → Diagnostics → Thread Sanitizer
TSan detects data races at runtime with ~5–10× slowdown. Run UI flows and background operations together to trigger races.
Instruments
- Time Profiler: see which threads are consuming CPU
- System Trace: visualize thread scheduling, preemptions, and queue hops
- Swift Concurrency template (Xcode 14+): visualize task trees, actor contention, and cooperative thread pool usage
Detecting Deadlocks
serialQueue.sync {
serialQueue.sync { }
}
DispatchQueue.main.async {
semaphore.wait()
}
Main Thread Checker
Enabled by default in debug builds. Catches UIKit/AppKit calls off main thread at runtime.
DispatchQueue.global().async {
self.label.text = "hello"
}
Swift Concurrency Debugging
func updateLabel() {
MainActor.assertIsolated()
}
Decision Guide
New code, iOS 15+: Use Swift Concurrency (async/await + actors) exclusively. It's the best model for correctness, performance, and readability.
Wrapping legacy callback APIs: Use withCheckedThrowingContinuation / withCheckedContinuation to bridge into async/await.
Protecting shared mutable state: Use actor (modern) or a serial DispatchQueue (GCD). Avoid raw locks unless profiling shows actor overhead matters.
Reader-heavy shared state: Use a concurrent queue with barrier writes (GCD reader-writer pattern).
Reactive event streams / UI bindings: Use Combine (or SwiftUI's @Published/@StateObject which wrap Combine internally).
Dependency graphs with cancellation: Use OperationQueue with addDependency, or model as structured Swift tasks.
CPU-bound parallelism: Use TaskGroup (Swift Concurrency) or DispatchQueue.concurrentPerform (GCD).
Never do: Call DispatchQueue.sync on the main queue from any code that may be called from main, use Thread.sleep on main thread, or semaphore.wait() on main thread.
Reference Docs
- Swift Concurrency — async/await, structured concurrency, AsyncSequence, continuations, clocks
- Actors & Sendable — actor isolation, MainActor, global actors, Sendable, ObjC interop
- Grand Central Dispatch — queues, groups, barriers, semaphores, DispatchSource, timers
- Combine — publishers, operators, schedulers, backpressure, async interop
- Legacy (Thread, OperationQueue, RunLoop) — NSLock, NSRecursiveLock, RunLoop, NSOperation subclassing