Concurrency and Memory
🟪 Syntax · 🟦 Types/APIs · 🟩 Correct pattern · 🟥 Bug · 🟨 Gotcha · 🧠 Explanation
This sheet covers foundational GCD, ARC, and actor topics. For current Swift 6 isolation and task patterns, see 11-Swift-Concurrency-6.4.md.
Serial, concurrent, main, and global queues
- A serial queue executes one submitted block at a time.
- A concurrent queue may execute multiple blocks simultaneously.
- The main queue is serial and runs work on the main thread.
- Global queues are system-provided concurrent queues with different QoS classes.
- A custom serial queue is not the same thing as the main queue and is not a permanent dedicated thread.
Queues manage work; the system chooses underlying threads. A global concurrent queue generally begins submitted work in FIFO order, but completion order is not guaranteed.
let serialQueue = DispatchQueue(label: "com.example.cache")
serialQueue.async {
// Serialized with other work submitted to this queue.
}sync versus async
syncblocks the caller until the submitted work finishes.asyncreturns immediately and lets the queue schedule the work.syncdoes not automatically make code safe; it can cause deadlock or block a valuable thread.
Never synchronously dispatch to the same serial queue currently executing the caller:
DispatchQueue.main.sync {
// Deadlock if already on the main thread.
}Common GCD deadlocks
Same serial queue
Code running on a serial queue calls queue.sync. The queue cannot start the inner block until the outer block finishes, while the outer block waits for the inner block.
Main queue wait
A background task waits synchronously for the main queue while the main thread waits for that background task.
Circular resource wait
Two queues or locks each own a resource and wait for the other resource.
Prevention:
- Prefer structured concurrency and async APIs.
- Avoid synchronous cross-queue call chains.
- Keep locks small and acquire them in a consistent order.
- Do not block the main thread.
- Encapsulate shared mutable state behind an actor, serial queue, or lock instead of mixing mechanisms.
Concurrent reads with barrier writes
final class ThreadSafeStore<Key: Hashable, Value> {
private var storage: [Key: Value] = [:]
private let queue = DispatchQueue(
label: "com.example.store",
attributes: .concurrent
)
func value(for key: Key) -> Value? {
queue.sync { storage[key] }
}
func set(_ value: Value, for key: Key) {
queue.async(flags: .barrier) { [weak self] in
self?.storage[key] = value
}
}
}Reads can run concurrently. A barrier write waits for earlier work, runs exclusively, and prevents later work from beginning until it completes. Barriers provide this behavior on a custom concurrent queue, not as a general guarantee on a global queue.
Be clear about write completion semantics. An asynchronous write may not be visible immediately to the caller; an actor or async store API often communicates this more honestly.
Actors
An actor protects its isolated mutable state so only one task accesses that state at a time.
actor TokenStore {
private var token: String?
func update(_ token: String) {
self.token = token
}
func currentToken() -> String? {
token
}
}From outside the actor:
let token = await tokenStore.currentToken()Actor isolation protects internal state; it does not automatically redesign the public API or make every related operation safe. Values that leave the actor should be safe to transfer, and multi-step operations may still need to remain inside one actor method to preserve invariants.
Actors are reentrant: when an actor method suspends at await, other work may enter the actor. Recheck assumptions after suspension.
Sendable
Sendable communicates that a value can safely cross concurrency isolation boundaries.
Good natural candidates:
- Immutable value types whose stored properties are Sendable
- Carefully synchronized reference types
- Actor references
Codable and Sendable solve different problems:
Codable: serialization and deserializationSendable: concurrency safety when transferring values
A response model does not need Sendable merely because it is decoded. Add it when the model crosses task or actor boundaries and its contents satisfy the contract.
Async/await and priorities
Tasksupports priorities, but priority is a scheduling hint, not a strict order guarantee.- Prefer inherited priority in structured child tasks.
- Avoid creating detached tasks unless work truly has no parent context.
- Cancellation is cooperative: check
Task.isCancelledor callTask.checkCancellation()during longer work. - UI-facing view models are often isolated to
@MainActor.
Operation cancellation
Calling cancel() on an Operation normally marks it as cancelled. It does not forcibly terminate a running thread. Custom operations must check isCancelled, stop starting new work, and finish cleanly. Underlying URL tasks or other cancellable work should also be cancelled.
ARC
ARC tracks strong references to class instances. When the strong reference count reaches zero, the instance is deinitialized and its memory can be reclaimed.
ARC does not detect or automatically break retain cycles.
Typical cycle:
owner -> closure/subscription -> ownerUse:
weakwhen the reference may becomenilunownedonly when the referenced object must outlive the holder- explicit cancellation or teardown for long-lived callbacks
A lazy property does not inherently create a retain cycle. A lazy closure that captures self strongly and is retained by self can create one.
Interview comparison: GCD versus actors
| GCD | Actors |
|---|---|
| Queue-based scheduling primitive | Language-level isolation model |
| Safety depends on disciplined queue usage | Compiler helps enforce isolation |
| Works with synchronous and callback-era APIs | Fits structured concurrency and async/await |
| Barriers and sync access require careful semantics | Actor methods expose asynchronous boundary clearly |
Use actors for new shared mutable state when deployment constraints allow. GCD remains important for existing APIs, low-level scheduling, and legacy code.
