Concurrency means several tasks are in progress at the same time and may interleave; parallelism means they literally execute at the same instant on different CPU cores. Synchronous code makes the caller wait for the result, asynchronous code lets the caller continue and delivers the result later.
Concurrency without parallelism: on a single core the OS time-slices threads, so a download and the UI both make progress even though only one runs at any moment.
Parallelism needs several cores; it is one way to execute concurrent work, not a synonym for it.
Synchronous: the call returns only when the work is done. A long synchronous call on the main thread freezes the UI.
Asynchronous: the call returns at once and the result arrives through a callback, a delegate or an await. Asynchronous does not mean "in the background": DispatchQueue.main.async is asynchronous and still runs on the main thread.
iOSMiddle
Swift Concurrency
async/await, Task, actors, structured concurrency, AsyncSequence
Questions closed
0 / 25
- 0Review
- 17Asked often
Questions
GCD (Grand Central Dispatch) is Apple's library for submitting closures to queues that the system runs on a thread pool it manages. A serial queue runs one item at a time in FIFO order, a concurrent queue may run several at once; async returns immediately, sync blocks until the closure finishes.
Main queue: serial and bound to the main thread; every UIKit and SwiftUI update goes here.
Global queues: concurrent, one per QoS class: DispatchQueue.global(qos: .userInitiated).
Custom queues: DispatchQueue(label:) is serial by default; pass attributes: .concurrent for a concurrent one.
A queue is not a thread: a serial queue guarantees one item at a time, not the same thread; GCD may run consecutive items on different threads. Only the main queue is pinned to one thread.
sync onto the serial queue you are already running on deadlocks, because the queue waits for itself.
An async function can suspend at each await and resume later, and while suspended it holds no thread. Compared with completion handlers, the code reads top to bottom, errors go through try/catch, and the compiler guarantees the result arrives exactly once.
Linear flow: three sequential steps are three lines, not three nested closures.
Errors: async throws uses the same try / do / catch as synchronous code instead of a Result switch in every callback.
No forgotten callbacks: a completion handler can be called twice or never; an async function returns exactly once.
Versus GCD: no manual DispatchQueue.main.async hops: actor isolation (for example @MainActor) decides where the code runs, and code may resume on a different thread after an await.
Structured cancellation: Task.cancel() propagates to child tasks; a completion handler has no such parent/child link.
From synchronous code you start a Task { } and await inside it; the synchronous function returns immediately and never sees the result directly. The other direction needs nothing: an async function can call any synchronous function, as long as that call does not block for long.
Isolation is inherited: a Task created in @MainActor code runs its body on the main actor, so it can update UI state directly.
Keep the handle when the work must be cancellable: store the Task and call cancel() later. In SwiftUI prefer the .task { } modifier, which cancels when the view disappears.
No synchronous wait: there is no correct way to block until an async result is ready; a semaphore around a Task deadlocks (see the cooperative pool question).
Long CPU work inside an async function holds its thread the whole time; split it up or move it to a @concurrent function.
On the main thread it prints A, C and then gets stuck: main.sync waits for the main queue to run D, but the main queue is busy running test() itself, which is waiting; this is a deadlock that libdispatch usually turns into a crash.
B never prints either: it sits in the main queue behind the blocked test().
The crash message is dispatch_sync called on queue already owned by current thread; without that check the app would simply freeze.
From a background queue there is no deadlock: A first, then B and C in either order (B runs on main whenever main is free), then D, then E. B always precedes D because the main queue is serial and B was enqueued first.
The rule: never sync onto the serial queue you are already on. In Swift Concurrency the equivalent hop is await MainActor.run { }, which suspends instead of blocking and cannot deadlock this way.
QoS tells the system how much the work matters to the user right now, and the system uses it to set thread priority, share CPU time and choose between performance and efficiency cores. Lower QoS work gets less CPU and can be delayed for a long time under load.
.userInteractive: the main thread, animations, event handling; must finish within a frame.
.userInitiated means the user started it and is waiting: opening a document, loading the next screen.
.default: used when nothing is specified; between .userInitiated and .utility.
.utility covers long work the user knows about, often with a progress bar: downloads, imports.
.background covers work the user does not see: backups, prefetching, indexing. It is throttled hard, especially in Low Power Mode.
When high-QoS work waits on a low-QoS queue (priority inversion), GCD raises the queue's QoS for a sync call or a serial queue with high-QoS items behind it. Swift Concurrency task priorities map onto the same classes: .high is .userInitiated, .low is .utility. Never put something the user waits for on .background; it may wait seconds or minutes.
DispatchGroup counts a set of asynchronous jobs and tells you when all of them have finished; the classic case is firing several requests in parallel and updating the UI once every one has returned.
**enter() / leave()**: call enter() before each job and leave() exactly once in its completion, on every path. A missing leave() means the group never finishes; an extra one crashes with Unbalanced call to dispatch_group_leave().
**notify(queue:): a non-blocking callback when the counter reaches zero. Prefer it.
wait(timeout:)**: blocks the calling thread; never on the main thread and never inside async code.
queue.async(group:) does enter/leave for you, but only covers work that is synchronous inside the closure; for callback APIs you still need enter/leave.
Results from several jobs need their own synchronisation (a lock or a serial queue).
In new code async let or a task group replaces it, with typed results, error propagation and cancellation built in.
OperationQueue is an Objective-C layer over GCD that wraps work in Operation objects and adds dependencies, a concurrency limit, cancellation and observable state; GCD just runs closures. Pick it when the work is a graph of steps to order, limit or cancel as a whole.
Dependencies: b.addDependency(a) makes b wait for a, even across queues; GCD has nothing equivalent short of nesting callbacks or groups.
Concurrency limit: maxConcurrentOperationCount = 3; in GCD you would need a semaphore.
Cancellation: cancel() and cancelAllOperations() set isCancelled, but it is still cooperative: the operation has to check it. A DispatchWorkItem has the same cooperative cancel() (it skips an item that has not started, and a running one can check isCancelled); a plain async { } closure cannot be cancelled at all.
State: isExecuting and isFinished are KVO-observable; an asynchronous operation needs a subclass that manages those flags by hand, which is where most Operation bugs come from.
Cost: every operation is an object; for fire-and-forget closures GCD is simpler and cheaper.
In new code Swift Concurrency covers most of these needs (task groups give limits and cancellation), but OperationQueue remains common in older codebases and in APIs that take one, such as URLSession(configuration:delegate:delegateQueue:).
A data race is two threads touching the same memory at once, at least one writing, with no synchronisation: undefined behaviour. A race condition is a logic bug where the result depends on timing even with synchronised access. A deadlock is threads waiting on each other forever.
Data race: count += 1 from two threads loses updates or corrupts a dictionary. Fix: an actor, a lock or a serial queue. The Swift 6 language mode rejects most data races at compile time; Thread Sanitizer finds the rest at runtime.
Race condition: check-then-act: two callers both see cache[key] == nil and both start a download, even inside an actor if an await sits between the check and the write. Fix: make check and act one step without a suspension, or store the in-flight task.
Deadlock: DispatchQueue.main.sync from the main thread, two locks taken in opposite order, a semaphore waiting for work that needs the blocked thread. Fix: one global lock order, never sync onto your own queue, no blocking inside async code.
Swift 6 data-race safety eliminates most data races, not race conditions; those are logic errors the compiler cannot see.
A semaphore is a counter that lets up to N threads through and blocks the rest; a mutex lets exactly one thread into a critical section and has an owner: only the thread that locked it may unlock it.
Semaphore: DispatchSemaphore(value: n): wait() decrements and blocks at zero, signal() increments, and any thread may signal. Used to cap parallel work at N or, with value 0, to wait for an event.
Mutex: Mutex from the Synchronization module (iOS 18+), OSAllocatedUnfairLock (iOS 16+), NSLock. Very cheap when uncontended; meant for short critical sections.
Ownership matters: a semaphore used as a lock has no owner, so the system cannot boost the holder's priority. A high-priority thread waiting on a low-priority holder is a priority inversion; Xcode's Thread Performance Checker flags it.
Bad idea: on the main thread (frozen UI, watchdog kill), to turn an async API into a synchronous one, and anywhere inside Swift Concurrency, where it blocks a cooperative-pool thread.
Structured concurrency binds child tasks to the scope that created them: they cannot outlive it, cancellation flows down, errors flow up, and the parent waits for every child. async let suits a fixed number of children; a task group suits a number known only at runtime.
**async let: each binding starts a child immediately; the children can return different types (user, posts, avatar). Leaving the scope without awaiting one cancels it and waits for it.
withTaskGroup / withThrowingTaskGroup**: add any number of children of the same result type and consume them in completion order with for await or next().
**withDiscardingTaskGroup: for children whose results you do not need; each finished child is released immediately instead of being kept until collected.
Unstructured**: Task { } and Task.detached are not children of anything; you own their lifetime and their cancellation.
Task { } inherits the actor isolation, priority and task-local values of the code that creates it; Task.detached inherits none of them and runs nonisolated with its own priority. Neither is a child task, so cancelling the code that created them does not cancel them.
**Task { }**: the default. Created in a @MainActor view model, its body stays on the main actor and can touch the model's state.
**Task.detached**: rarely right; use it only when you explicitly must not inherit context. To get CPU work off the main actor, a @concurrent async function (Swift 6.2+) states the intent better.
Priority: inherited from the creator unless passed explicitly (.high, .medium, .low, .background, plus aliases .userInitiated and .utility). Child tasks in async let and groups inherit the parent's priority.
Escalation: when a higher-priority task awaits a lower-priority one (await task.value), or is queued on an actor behind lower-priority work, the runtime raises the lower priority temporarily.
The trap: Task.detached(priority: .background) for something the user waits on, such as pull-to-refresh, gets throttled and can take seconds under load or in Low Power Mode.
Cancellation is cooperative: cancel() only sets a flag on the task and all its children, and nothing stops until code checks that flag or awaits an API that checks it for you.
Checking: try Task.checkCancellation() throws CancellationError; Task.isCancelled lets you clean up or return a partial result instead.
Built-in checks: Task.sleep and the async URLSession methods throw when the task is cancelled.
Propagation: cancelling a parent cancels its async let children and group children; a throwing task group cancels the remaining children when an error escapes its body.
**withTaskCancellationHandler**: runs onCancel immediately, possibly on another thread, so you can stop an underlying legacy request instead of waiting for it.
Unstructured tasks need their handle: store the Task and cancel it. SwiftUI's .task { } cancels automatically when the view disappears.
Treat CancellationError as "the user moved on", not as a failure to show in an alert.
try? swallows the CancellationError that tells the loop to stop. After stop(), a cancelled Task.sleep throws at once, the error becomes nil, and while true spins: a hot loop that pins the CPU and keeps firing requests.
Cancellation is cooperative: cancel() only sets a flag. Code stops only when it checks Task.isCancelled or lets an error from Task.sleep or Task.checkCancellation() propagate.
**try? hides it**: a cancelled Task.sleep (and a cancellation-aware fetchPrice(), e.g. async URLSession) throws immediately instead of waiting 5 s, so each iteration takes microseconds instead of 5 s.
Consequence: it stays alive: while the loop never ends, the task keeps self alive through its strong capture; capture [weak self] or always call stop() from deinit/disappear.
Fix: check !Task.isCancelled in the loop condition, or write try await Task.sleep inside do/catch and return on CancellationError.
Catch it next time: a unit test that calls stop() and asserts the mock API's call count stays flat; treat every try? await Task.sleep in a loop as a review red flag.
The error surfaces at try await user, and before the function exits Swift implicitly cancels posts and waits for it, so loadScreen() throws only once the posts task has wound down: almost immediately if loadPosts honours cancellation, after the full 10 s if it ignores it.
Children cannot outlive the scope: leaving it by return or throw cancels every async let that was not awaited and then awaits it. Nothing leaks.
Await order matters: had you awaited posts first, you would wait the whole 10 s before even seeing the user error. async let reports errors in the order you await, not the order they happen.
Fail fast on whichever error comes first: use a throwing task group: next() returns children in completion order, and throwing out of the group cancels the rest.
**An async let you never await** still runs, and is cancelled and awaited at scope exit; its error is silently dropped. It is not a way to fire and forget.
Seed the group with 5 children, then every time one finishes (next() returns), add the next one, so the group never has more than 5 in flight.
Adding all 500 at once starts 500 requests: hundreds of timeouts, memory for every response and decoded image at the same time. URLSession's per-host connection limit queues the sockets, not your decoding work.
The sliding window keeps the cap exact and still uses all 5 slots: a slow image does not hold back the others.
Errors: with withThrowingTaskGroup the first error that escapes the loop cancels the remaining children; catch inside the child if one bad image should not stop the batch.
Cancellation flows in for free: cancelling the parent task cancels the in-flight downloads, and the loop stops adding new ones.
In legacy code the same cap is OperationQueue.maxConcurrentOperationCount or a DispatchSemaphore, but never inside async code.
Race the operation against Task.sleep in a throwing task group: take whichever child finishes first and cancel the other. If the sleep wins, it throws your timeout error.
It relies on cancellation: the group still waits for the losing child before it returns. An operation that ignores cancellation keeps the caller waiting anyway; structured concurrency cannot abandon work.
**defer { group.cancelAll() } stops the loser on every path, including when the operation throws first.
Generic constraints**: the result crosses a task boundary, so it must be Sendable, and the operation closure must be @Sendable.
Prefer the API's own timeout when it exists: for URLSession, timeoutIntervalForRequest is an idle timeout between packets, timeoutIntervalForResource bounds the whole transfer.
Use Duration and Task.sleep(for:), not nanosecond integers.
An actor is a reference type that protects its mutable state by running only one piece of its code at a time on its own serial executor. Code outside the actor cannot touch that state directly; it calls the actor's methods with await, because the call may have to wait until the actor is free.
Inside the actor: methods access self synchronously; isolation is guaranteed by the compiler.
Outside: every access to isolated state or methods is awaited: the caller suspends rather than blocks, so no thread sits waiting, unlike a lock or queue.sync.
Compile-time checks: synchronous access from outside is an error, and values passed in or returned out must be Sendable.
**nonisolated** members: logic that reads only immutable let state; callable without await.
Not a transaction: isolation holds only until the next await inside the actor; other calls can run in between (actor reentrancy).
@MainActor is a global actor with the same rules, whose executor is the main thread.
@MainActor is a global actor whose executor is the main thread: annotated code is guaranteed to run there, and the compiler makes callers from other isolation domains await it. It is for UI state, not for work; everything it covers runs one piece at a time on the thread that draws your UI.
Where it belongs: view models and UI-facing types. UIViewController, UIView and SwiftUI's View are already main-actor isolated.
Why not everything: JSON decoding, image processing and disk I/O on the main actor freeze the UI. Marking a function async does not move it off the main actor; isolation decides where it runs.
Getting off it: call a method on another actor, or a @concurrent async function (Swift 6.2+), which always runs on the global pool. Task.detached is the last resort.
Getting back: inside a @MainActor type you are already there; from elsewhere use await MainActor.run { } or call a @MainActor method.
The guarantee is static: callbacks from Objective-C or C code are not checked; MainActor.assumeIsolated checks at runtime and crashes if you are not on main.
New Xcode 26 app targets default to MainActor isolation, so in them everything is main-actor unless you opt out.
Sendable is a marker protocol saying a value can cross concurrency domains (between tasks, into or out of an actor) without causing a data race. In the Swift 6 language mode the compiler checks it at every such crossing and rejects code that would share unprotected mutable state.
Implicit: structs and enums whose stored properties are all Sendable (inferred for non-public types; public ones need an explicit conformance), actors, and types isolated to a global actor such as @MainActor.
Classes: only a final class with nothing but let properties of Sendable types, declared explicitly.
Mutable class: not Sendable. Make it an actor, isolate it to @MainActor, or protect its state with a Mutex and then declare the conformance.
**@unchecked Sendable: you promise thread-safety yourself (usually an internal lock) and the compiler stops checking. Every one deserves a comment and a review.
Closures**: a @Sendable closure may capture only Sendable values and no mutable local variables.
Region-based isolation lets a freshly created non-Sendable value move into another domain when nothing else can still reach it; sending parameters make that explicit.
Call withCheckedThrowingContinuation, start the legacy call inside it, and resume the continuation exactly once from the callback; the awaiting task stays suspended until then. A checked continuation verifies that "exactly once" at runtime, an unsafe one does not.
Resuming twice crashes a checked continuation with SWIFT TASK CONTINUATION MISUSE … tried to resume its continuation more than once.
Never resuming leaves the caller suspended forever; a checked continuation logs that it leaked when it is destroyed unresumed.
**withUnsafeContinuation skips both checks and misuse is undefined behaviour. Use it only on a measured hot path, after the checked version has proven correct.
Every path resumes**: success, error, early return. resume(with: result) forwards a Result in one call.
Many events (a delegate that fires repeatedly) need an AsyncStream, not a continuation.
Check first: many Apple callback APIs already have async versions generated from their Objective-C completion handlers.
AsyncSequence is a sequence whose elements arrive over time and is consumed with for await; AsyncStream is the ready-made one you build yourself to turn callbacks, delegates or notifications into such a sequence.
Built-in sequences: URL.lines, URLSession.bytes(for:), NotificationCenter.default.notifications(named:), and a task group itself.
Building one: AsyncStream { continuation in … } or AsyncStream.makeStream(of:): yield delivers an element, finish() ends the stream, onTermination cleans up when the consumer stops or is cancelled.
Buffering: the default buffer is unbounded; .bufferingNewest(1) gives "latest value only" semantics for fast producers.
One consumer: AsyncStream is not multicast; two for await loops split the elements between them. For several consumers, create a stream per subscriber.
Lifetime: the loop ends when the stream finishes or the consuming task is cancelled. A forgotten finish() leaves the consumer waiting forever.
Compared with Combine it has few operators; the swift-async-algorithms package adds debounce, merge, combineLatest and others.
Actors are reentrant: at the await the actor is released, the second call runs, finds the cache still empty and starts its own download. Isolation protects state between suspension points, not across them.
Why actors are reentrant
A non-reentrant actor would deadlock as soon as it awaited something that calls back into it, and would serialise every caller behind slow I/O.
So every await inside an actor is a point where any other call may change its state. Check-then-act across an await is a race condition, even though there is no data race and the compiler is satisfied.
How to fix it
Store the in-flight work: put a Task into the dictionary before the first await; a second caller finds it and awaits the same task instead of starting another.
**Re-check after every await when de-duplication does not apply: the value you read before suspending may be stale.
Keep mutations synchronous**: read, decide and write in one stretch without a suspension point.
On failure, remove the entry so the next caller can retry instead of awaiting a stored error forever.
Swift Concurrency runs tasks on a cooperative pool with about one thread per CPU core and assumes every task either runs or suspends. A thread blocked in wait() or Thread.sleep is lost to the pool, and nothing replaces it.
Why it breaks
GCD spawns a new thread when one blocks; that is what kept such code alive before, at the price of thread explosion. The cooperative pool deliberately does not grow.
Block as many threads as there are cores and every task in the app stops. No crash, no log line, the app just stops making progress.
On the main actor it is immediate: a Task started from main-actor code inherits the main actor and needs the very thread that is blocked in wait().
What to do instead
Make the caller async and await. A synchronous entry point starts a Task and delivers the result through state or a callback.
Task.sleep(for:) suspends and gives the thread back.
A genuinely blocking C or legacy API runs on its own DispatchQueue, bridged back with a continuation.
Short critical sections use Mutex, which cannot be held across an await.
Setting LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 in the scheme shrinks the pool to one thread, so a latent deadlock reproduces every time.
Use an actor when callers are async or the critical section must await; a lock (Mutex, OSAllocatedUnfairLock) when access is short, synchronous and hot; a serial DispatchQueue mainly inside GCD code. All three serialise access; they differ in whether callers suspend or block, and in cost.
Actor
Callers await and suspend, so no thread is blocked; isolation is checked by the compiler.
Every call from outside is a potential executor hop plus a suspension. In a loop of 500 tiny calls that overhead dominates and a plain lock measures faster, so send the whole batch in one actor call instead.
State may change at every await inside it (reentrancy).
Lock
Mutex (Synchronization, iOS 18+), OSAllocatedUnfairLock (iOS 16+), NSLock: nanoseconds when uncontended, callable from synchronous code such as a non-async protocol requirement.
Never hold one across an await; Mutex.withLock takes a synchronous closure, so it cannot. Keep sections tiny and take locks in one fixed order.
Serial queue
sync for reads, async for writes; a concurrent queue with .barrier writes gives a reader-writer lock.
sync blocks the caller and deadlocks if re-entered from the same queue, and the compiler cannot verify it, so the class ends up @unchecked Sendable.
Fine at the boundary with GCD-based APIs; to make an actor run on a specific queue, give it a custom executor.