Audience: Senior iOS Engineers preparing for Apple-level interviews
Scope: Swift 5.5+ Structured Concurrency, async/await, actors, and the full runtime model Depth: Internals, gotchas, Apple interview patterns, and production tradeoffs
Before Swift Modern Concurrency (introduced in Swift 5.5, WWDC 2021), iOS developers relied on Grand Central Dispatch (GCD), OperationQueue, callbacks, and delegate patterns. Each approach has fundamental problems that Swift concurrency was specifically designed to solve.
// Traditional callback-based code
func fetchUserProfile(id: String, completion: @escaping (Result<Profile, Error>) -> Void) {
fetchUser(id: id) { result in
switch result {
case .success(let user):
fetchAvatar(url: user.avatarURL) { avatarResult in
switch avatarResult {
case .success(let image):
fetchPreferences(userId: user.id) { prefResult in
// Deeply nested, hard to reason about, error
// handling duplicated
switch prefResult {
case .success(let prefs):
completion(.success(Profile(user: user, avatar:
image, prefs: prefs)))
case .failure(let error):
completion(.failure(error)) // Easy to forget
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
Problems: Error propagation is duplicated and fragile. Forgetting to call completion is a silent bug. Execution order is non-obvious. No compiler enforcement.
GCD-based code has no compile-time guarantees about thread safety. You must manually ensure shared mutable state is only accessed from the correct queue.
// Classic GCD race condition — no compile-time warning
class Cache {
private var storage: [String: Data] = [:]
func set(_ data: Data, for key: String) {
storage[key] = data // RACE: Multiple threads can write
simultaneously
}
}
| Problem | GCD/Callbacks | Swift Concurrency |
|---|---|---|
| Callback hell | ❌ Pyramid of doom | ✅ Linear, sequential-looking code |
| Forget completion | ❌ Silent bug | ✅ Compiler error if async fn returns |
| Thread safety | ❌ Runtime crashes | ✅ Actor isolation at compile time |
| Cancellation | ❌ Ad hoc, manual | ✅ Structured, propagates via task tree |
| Priority propagation | ❌ Manual | ✅ Automatic through task hierarchy |
| Thread explosion | ❌ Possible | ✅ Cooperative thread pool prevents it |
async MeansMarking a function async tells the compiler that the function may
suspend — it can pause its
execution on the current thread, yield that thread to other work, and later
resume (possibly on a
different thread).
func fetchData(from url: URL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return data
}
Key insight: async does NOT mean "runs on a background thread." It
means the function can be
suspended. Where it runs depends on the executor context.
await is a potential suspension point. The function may or may not
actually suspend — it
depends on whether the awaited operation needs to do async work. The
compiler requires you to mark
every suspension point with await explicitly, making them visible.
func process() async {
let value = await computeValue() // May suspend here
// Execution resumes here, possibly on a different thread
print(value)
}
Critical interview point: After any await, you must not assume you're
on the same thread as
before. However, you ARE guaranteed to be in the same actor context (if the
function is actor-
isolated).
async let launches a child task immediately and evaluates the binding
lazily when you await it.
This is the primary mechanism for parallel work within structured
concurrency.
func loadDashboard() async throws -> Dashboard {
// These three fetches start CONCURRENTLY
async let user = fetchUser()
async let posts = fetchPosts()
async let notifications = fetchNotifications()
// This line awaits all three — total time = max(user, posts,
// notifications)
return try await Dashboard(user: user, posts: posts, notifications:
notifications)
}
Compare with sequential:
func loadDashboardSequential() async throws -> Dashboard {
let user = try await fetchUser() // Wait for this...
let posts = try await fetchPosts() // Then this...
let notifications = try await fetchNotifications() // Then this
// Total time = user + posts + notifications
return Dashboard(user: user, posts: posts, notifications: notifications)
}
Interview trap: With async let, if the await throws, ALL child tasks
from async let are
automatically cancelled. This is structured concurrency in action.
You cannot call async functions from synchronous contexts directly. The
bridges are:
// 1. Task {} — creates an unstructured task from sync context
func viewDidLoad() {
Task {
let data = try await fetchData()
await updateUI(with: data)
}
}
// 2. Task.detached {} — detached from current context (rare)
Task.detached(priority: .background) {
await expensiveBackgroundWork()
}
// 3. In tests: XCTest has async test support
func testFetch() async throws {
let data = try await fetchData()
XCTAssertNotNil(data)
}
Swift concurrency introduces a task hierarchy. Every task has a parent, and this relationship creates a tree. This structure gives us:
Task (root)
├── async let fetchUser (child)
├── async let fetchPosts (child)
│ └── async let images (grandchild)
└── async let fetchPrefs (child)
| Feature | Structured (async let, TaskGroup) |
Unstructured |
|---|---|---|
| Parent task | Has one | None (or the enclosing task) |
| Cancellation | Inherits from parent | Manual via stored Task handle |
| Priority | Inherits from parent | Inherits at creation, can specify |
| Lifetime | Bounded by parent scope | Independent |
await required |
Yes — parent awaits children | No — fire & forget |
The key insight: structured concurrency makes concurrent code as
predictable as sequential code
with respect to lifetime and error handling. When a do block exits
(normally or via thrown error),
all child tasks it created are guaranteed to be cancelled and awaited.
func example() async throws {
async let a = task1()
async let b = task2()
// If task1() throws, task2() is automatically cancelled
// The compiler ensures both are awaited before this scope exits
let result = try await (a, b)
}
// Guaranteed: when we reach here, both task1 and task2 have
// completed/cancelled
Task groups allow you to create a dynamic number of concurrent child
tasks at runtime, unlike
async let which requires knowing the tasks at compile time.
func fetchAllImages(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: UIImage.self) { group in
for url in urls {
group.addTask {
try await downloadImage(from: url)
}
}
var images: [UIImage] = []
for try await image in group {
images.append(image)
}
return images
}
}
Order is NOT guaranteed. Results arrive in completion order, not
submission order. If you need
ordering, use (Int, UIImage) as the result type and carry the index.
func fetchOrderedImages(urls: [URL]) async throws -> [UIImage] {
try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
for (index, url) in urls.enumerated() {
group.addTask {
let image = try await downloadImage(from: url)
return (index, image)
}
}
var results: [(Int, UIImage)] = []
for try await result in group {
results.append(result)
}
return results
.sorted { $0.0 < $1.0 }
.map { $0.1 }
}
}
Task groups by default add ALL tasks immediately, which could overwhelm a server. Limit concurrency with a sliding window pattern:
func fetchWithConcurrencyLimit(urls: [URL], maxConcurrent: Int) async
throws -> [Data] {
try await withThrowingTaskGroup(of: (Int, Data).self) { group in
var results = [Data](repeating: Data(), count: urls.count)
var index = 0
// Seed initial batch
while index < min(maxConcurrent, urls.count) {
let i = index
let url = urls[i]
group.addTask { (i, try await URLSession.shared.data(from:
url).0) }
index += 1
}
// As each finishes, add next
for try await (i, data) in group {
results[i] = data
if index < urls.count {
let nextIndex = index
let url = urls[nextIndex]
group.addTask { (nextIndex, try await
URLSession.shared.data(from: url).0) }
index += 1
}
}
return results
}
}
When you don't need results from child tasks (fire-and-collect-errors only),
use
withDiscardingTaskGroup — it's more memory-efficient because it doesn't
buffer results.
await withDiscardingTaskGroup { group in
for item in items {
group.addTask {
await process(item) // no return value needed
}
}
}
With withThrowingTaskGroup: if any child task throws, the group cancels
all remaining children
and rethrows the first error. This is the key difference from non-throwing
groups, which ignore
individual errors.
Task {})Created from a synchronous context or when you need a task that outlives its enclosing scope. It inherits the actor context and priority of where it's created, but has no structured parent.
class ViewModel: ObservableObject {
@Published var data: [Item] = []
func refresh() {
// This Task inherits @MainActor isolation because ViewModel is on
// MainActor
Task {
let items = try await fetchItems()
self.data = items // Safe: we're still on MainActor
}
}
}
You can hold a reference to cancel it:
var loadingTask: Task<Void, Never>?
func startLoading() {
loadingTask?.cancel()
loadingTask = Task {
await performLoad()
}
}
func stopLoading() {
loadingTask?.cancel()
loadingTask = nil
}
Task.detached {})A detached task inherits nothing from its creation context — not actor isolation, not priority, not task-local values (unless explicitly specified).
Task.detached(priority: .background) {
// NOT on @MainActor even if created from MainActor context
// No task-local values from the calling context
await expensiveOperation()
}
When to use detached: Rarely. Use it when you explicitly don't want to inherit the calling context's actor isolation (e.g., to escape MainActor for CPU-intensive work).
public enum TaskPriority: UInt8 {
case high // 25 — .userInitiated equivalent
case medium // 21 — default for Task {}
case low // 17
case background // 9 — .background equivalent
// Aliases:
// .userInitiated = .high
// .utility = .low
}
Priority escalation: If a high-priority task is waiting on a result from a low-priority task, the runtime can promote the low-priority task's priority to avoid priority inversion.
Actors provide a unit of concurrency-safe mutable state. The compiler statically guarantees that actor state is only accessed from within the actor's executor (its serial queue), eliminating data races.
actor BankAccount {
private var balance: Decimal
init(balance: Decimal) {
self.balance = balance
}
func deposit(_ amount: Decimal) {
balance += amount // Safe — actor isolation guarantees serial
access
}
func withdraw(_ amount: Decimal) throws {
guard balance >= amount else { throw BankError.insufficient }
balance -= amount
}
var currentBalance: Decimal { balance }
}
// Usage
let account = BankAccount(balance: 1000)
await account.deposit(500) // Must await from outside
let balance = await account.currentBalance
This is the most critical actor concept for Apple interviews. Actors are reentrant by default — an actor can start processing a new message while waiting at a suspension point within the current message.
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 // Fast path — no suspension
}
// REENTRANCY TRAP:
// When we await here, another call to image(for: url) can enter the
// actor
// That second call will also not find it in cache and start ANOTHER
// download
let image = try await downloadImage(from: url) // ← suspension
point
// By the time we resume, someone else may have already cached it
cache[url] = image
return image
}
}
The correct solution — use a "deduplicate in-flight requests" pattern:
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 existingTask = inFlight[url] {
return try await existingTask.value // Join the in-flight task
}
let task = Task { try await downloadImage(from: url) }
inFlight[url] = task
do {
let image = try await task.value
cache[url] = image
inFlight[url] = nil
return image
} catch {
inFlight[url] = nil
throw error
}
}
}
Use nonisolated to mark actor members that don't need actor isolation —
they can be called without
await and are safe because they don't access mutable state.
actor Logger {
let name: String // Stored let properties are implicitly nonisolated
private var logs: [String] = []
// Explicitly nonisolated — can be called synchronously from anywhere
nonisolated func makePrefix() -> String {
return "[\(name)]" // OK: only accesses immutable `name`
}
// nonisolated cannot access mutable state
// nonisolated func badMethod() { logs.append("") } // ❌ Compile error
}
// No await needed:
let prefix = logger.makePrefix()
Actors cannot inherit from other actors (or from classes). They can conform to protocols.
// ❌ Not allowed
// actor Child: ParentActor { }
// ✅ Allowed
protocol Cacheable {
func invalidate() async
}
actor ImageCache: Cacheable {
func invalidate() async { /* ... */ }
}
actor |
Class + NSLock/DispatchQueue |
|
|---|---|---|
| Compile-time safety | ✅ Yes | ❌ No |
| Priority inversion | ✅ Handled by runtime | ❌ Manual |
| Deadlock risk | ✅ None (no blocking) | ❌ Possible |
| Reentrancy | ✅ Explicit via suspension points | ⚠️ Depends on design |
| Interop with async code | ✅ Native | ⚠️ Complex bridging |
A global actor is a singleton actor that provides a shared execution
context for code that must
run on a specific executor. The most important global actor is MainActor,
which corresponds to the
main thread.
@globalActor
actor DatabaseActor {
static let shared = DatabaseActor()
}
@DatabaseActor
class DataStore {
func save(_ item: Item) { /* guaranteed on DatabaseActor */ }
}
@MainActor isolates code to the main thread. You can apply it to:
// Entire class
@MainActor
class ViewModel: ObservableObject {
@Published var items: [Item] = []
func loadItems() async {
// loadItems() starts on MainActor
let fetched = await fetchItems() // Suspension — may switch to pool
// Resumes back on MainActor automatically
items = fetched // Safe: we're on MainActor
}
}
// Individual method
class MixedClass {
@MainActor
func updateUI() {
// Guaranteed main thread
}
func backgroundWork() async {
// Not MainActor
await updateUI() // Must await to hop to MainActor
}
}
// Closures
Task { @MainActor in
// This closure body is isolated to MainActor
}
@MainActor is itself an actor, so it's also reentrant. This is crucial to
understand:
@MainActor
class Controller {
var count = 0
func increment() async {
count += 1 // Main thread
await Task.yield() // ← suspension point!
// Another caller could have changed count here
print("Count: \(count)") // May not be count + 1 from above
}
}
In SwiftUI, @StateObject, @ObservedObject ViewModels should be
@MainActor to ensure UI updates
are safe:
@MainActor
class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
func load(id: String) async {
isLoading = true
defer { isLoading = false }
do {
user = try await userService.fetchUser(id: id)
} catch {
// handle error
}
}
}
Sendable marks types that are safe to share across concurrency boundaries
(across actors, across
tasks). If a type is Sendable, its values can cross isolation boundaries
without data races.
// Value types are generally Sendable
struct UserProfile: Sendable {
let name: String // Sendable
let age: Int // Sendable
}
// Classes must be manually verified
final class SafeCache: @unchecked Sendable {
// We take responsibility for thread safety
private let lock = NSLock()
private var storage: [String: Data] = [:]
func set(_ data: Data, key: String) {
lock.withLock { storage[key] = data }
}
}
Sendable if all stored properties are SendableSendable if all associated values are SendableSendablefinal with all Sendable properties, OR use
@unchecked Sendable@Sendable// @Sendable closures
func performInBackground(_ work: @Sendable @escaping () async -> Void) {
Task.detached {
await work()
}
}
// This won't compile if the closure captures non-Sendable types:
class NonSendable { var count = 0 }
let obj = NonSendable()
performInBackground {
obj.count += 1 // ❌ Compile error: obj is not Sendable
}
Swift 6 introduces strict concurrency checking. The compiler tracks
isolation regions — groups
of values that must be accessed from the same isolation context.
Transferring a value between
regions requires the value to be Sendable.
// Swift 6 strict checking
@MainActor
func updateUI(with data: [Item]) {
// data must be Sendable to cross from background to MainActor
}
func fetchAndUpdate() async {
let items = await fetchItems() // On cooperative pool
await updateUI(with: items) // items crosses to MainActor — must
be Sendable
}
Making third-party types Sendable when you can't modify them:
// In your own module
extension ThirdPartyModel: @retroactive Sendable {}
AsyncSequence is the async equivalent of Sequence. It produces values
asynchronously, one at a
time. You consume it with for await in.
protocol AsyncSequence {
associatedtype Element
associatedtype AsyncIterator: AsyncIteratorProtocol
func makeAsyncIterator() -> AsyncIterator
}
protocol AsyncIteratorProtocol {
associatedtype Element
mutating func next() async throws -> Element?
}
Usage:
func processLines(from url: URL) async throws {
let (bytes, _) = try await URLSession.shared.bytes(from: url)
for try await line in bytes.lines {
process(line)
}
}
AsyncStream is the primary way to create async sequences from callback-
based or delegate-based
APIs.
// Wrapping a delegate-based API
func locationStream(manager: CLLocationManager) -> AsyncStream<CLLocation> {
AsyncStream { continuation in
let delegate = LocationDelegate { location in
continuation.yield(location)
} onFinish: {
continuation.finish()
}
manager.delegate = delegate
manager.startUpdatingLocation()
continuation.onTermination = { _ in
manager.stopUpdatingLocation()
}
}
}
// Usage
for await location in locationStream(manager: locationManager) {
updateMap(with: location)
}
For streams that can fail:
func dataStream(from socket: WebSocket) -> AsyncThrowingStream<Data, Error>
{
AsyncThrowingStream { continuation in
socket.onReceive { data in
continuation.yield(data)
}
socket.onError { error in
continuation.finish(throwing: error)
}
socket.onClose {
continuation.finish()
}
}
}
// Unbounded — keeps all values (memory risk)
AsyncStream(Int.self, bufferingPolicy: .unbounded) { ... }
// Bounded — drops values when full
AsyncStream(Int.self, bufferingPolicy: .bufferingNewest(10)) { ... }
AsyncStream(Int.self, bufferingPolicy: .bufferingOldest(10)) { ... }
// Filter
let filtered = stream.filter { $0.isValid }
// Map
let mapped = stream.map { transform($0) }
// First match
let first = await stream.first(where: { $0.isReady })
// Collect
let all = try await stream.reduce(into: []) { $0.append($1) }
A common architecture pattern for sharing a single upstream async sequence with multiple consumers:
actor StreamMulticaster<Element: Sendable> {
private var continuations: [UUID: AsyncStream<Element>.Continuation] =
[:]
func subscribe() -> AsyncStream<Element> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [weak self] _ in
Task { await self?.unsubscribe(id: id) }
}
}
}
func broadcast(_ element: Element) {
for continuation in continuations.values {
continuation.yield(element)
}
}
private func unsubscribe(id: UUID) {
continuations.removeValue(forKey: id)
}
}
Continuations allow you to bridge callback-based or delegate-based APIs into the async/await world. They represent the "rest of the computation" — the mechanism that resumes an async function.
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(.unknown))
}
}.resume()
}
}
The "checked" variants verify at runtime that you call resume exactly
once:
Use when performance is critical and you've verified correct usage:
// No runtime checking — faster but dangerous
func fetchFast() async -> Data {
await withUnsafeContinuation { continuation in
// Must guarantee exactly one resume call
legacyFetch { data in
continuation.resume(returning: data)
}
}
}
More complex: a delegate that produces multiple callbacks before finishing.
class LocationFetcher: NSObject, CLLocationManagerDelegate {
private var continuation: CheckedContinuation<CLLocation, Error>?
private let manager = CLLocationManager()
func fetchOnce() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.continuation = cont
manager.delegate = self
manager.requestLocation()
}
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return }
continuation?.resume(returning: location)
continuation = nil // Prevent double-resume
}
func locationManager(_ manager: CLLocationManager,
didFailWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}
Cancellation in Swift is cooperative — you must check for it and
respond. Calling
task.cancel() sets a cancellation flag on the task; it does NOT forcefully
stop execution.
func longOperation() async throws {
for i in 0..<1000 {
try Task.checkCancellation() // Throws CancellationError if
cancelled
await processStep(i)
}
}
// Alternative: check without throwing
func longOperationGraceful() async -> [Result] {
var results: [Result] = []
for i in 0..<1000 {
if Task.isCancelled { break } // Check without throwing
results.append(await processStep(i))
}
return results
}
CancellationError is a special error. Many APIs in Swift (URLSession,
Task.sleep, etc.) throw
CancellationError when cancelled.
do {
let data = try await URLSession.shared.data(from: url)
} catch is CancellationError {
// Handle cancellation gracefully — don't show error UI
return
} catch {
// Handle actual error
showError(error)
}
Run cleanup code when a task is cancelled, even if you're suspended waiting on something that doesn't check cancellation:
func operationWithCleanup() async throws -> Data {
try await withTaskCancellationHandler {
// Normal work
try await someAsyncOperation()
} onCancel: {
// Called immediately when task is cancelled
// Runs synchronously, on any thread
// Must be Sendable closure
cleanupResources()
}
}
Critical note: onCancel runs on whichever thread triggered
cancellation — it must be thread-
safe and ideally non-blocking.
let parentTask = Task {
async let child1 = longTask1()
async let child2 = longTask2()
return try await (child1, child2)
}
parentTask.cancel()
// Both child1 and child2 are automatically cancelled
Detached tasks do NOT inherit cancellation from their creator. You must hold a reference and cancel manually:
var backgroundTask: Task<Void, Never>?
func startBackground() {
backgroundTask = Task.detached {
await heavyWork()
}
}
func stopBackground() {
backgroundTask?.cancel()
backgroundTask = nil
}
Task local values are like thread-local storage for tasks. They propagate through the task hierarchy — child tasks inherit the values set by parent tasks, but changes in a child don't affect the parent.
enum RequestContext {
@TaskLocal static var traceID: String = "default"
@TaskLocal static var userID: String? = nil
}
func handleRequest(traceID: String) async {
await RequestContext.$traceID.withValue(traceID) {
// All async code within this scope (and child tasks) sees traceID
await fetchData()
await processData()
}
}
func fetchData() async {
let id = RequestContext.traceID // Reads current task's value
print("Fetching with trace: \(id)")
async let child = childOperation()
// child also inherits traceID
}
Swift 5.7 introduced the Clock protocol, making time-dependent code
testable.
protocol Clock: Sendable {
associatedtype Duration
associatedtype Instant: InstantProtocol
var now: Instant { get }
func sleep(until deadline: Instant, tolerance: Duration?) async throws
}
Built-in clocks:
ContinuousClock — advances in real time, even during sleepSuspendingClock — pauses when device sleeps (used for Task.sleep)// Old way (still works)
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
// Modern way
try await Task.sleep(for: .seconds(1))
try await Task.sleep(for: .milliseconds(500))
try await Task.sleep(until: .now + .seconds(5), clock: .continuous)
struct RetryPolicy<C: Clock> {
let clock: C
func withRetry<T>(attempts: Int, operation: () async throws -> T) async
throws -> T {
for attempt in 0..<attempts {
do {
return try await operation()
} catch {
if attempt < attempts - 1 {
try await clock.sleep(for: .seconds(pow(2.0,
Double(attempt))))
}
}
}
throw RetryError.exhausted
}
}
// In tests, inject a mock clock that you control
Swift concurrency does NOT use one-thread-per-task. Instead, it uses a cooperative thread pool with a fixed number of threads (typically equal to CPU core count).
Why this matters: You should never block a cooperative thread pool
thread. Blocking means
Thread.sleep, semaphore waits, locks, synchronous I/O, etc.
// ❌ DANGEROUS — blocks a thread pool thread
func badWork() async {
Thread.sleep(forTimeInterval: 5) // Blocks the thread, starves the pool
}
// ✅ CORRECT — suspends the task, frees the thread
func goodWork() async {
try await Task.sleep(for: .seconds(5)) // Thread is freed during sleep
}
An executor is the entity responsible for running a task. Swift has two kinds:
Serial executors: Run one task at a time (actors have serial executors by default)
Concurrent executors: Run multiple tasks concurrently (the default task pool)
// Custom executor (advanced — rarely needed)
actor MyActor {
nonisolated var unownedExecutor: UnownedSerialExecutor {
// Custom executor — e.g., pin to specific queue
customQueue.asUnownedSerialExecutor()
}
}
When you call an actor-isolated function from a different actor (or from unstructured code), there's an actor hop — the runtime suspends the current context, enqueues the work on the target actor's executor, and resumes the current context when the actor call returns.
@MainActor
class ViewController {
func buttonTapped() async {
// We're on MainActor
let result = await myActor.compute() // Hop to myActor's executor
// We're back on MainActor
updateUI(with: result)
}
}
Each hop is lightweight but not free. Minimize unnecessary hops in hot paths.
Async functions that suspend allocate their local state on the heap (as part of the continuation object), unlike synchronous functions which use the stack. This is why you should avoid unnecessarily large async functions or unnecessary suspension points in performance-critical paths.
The Swift runtime detects priority inversion: when a high-priority task is waiting on a low-priority task. It automatically promotes the low-priority task's effective priority to match, preventing the high-priority task from being unnecessarily delayed.
Never block an async context with GCD semaphores or synchronous waits. Instead, bridge with continuations:
// Bridging a dispatch queue operation
func runOnSerialQueue(_ block: @escaping () -> Void) async {
await withCheckedContinuation { continuation in
serialQueue.async {
block()
continuation.resume()
}
}
}
You can use a DispatchSerialQueue as a custom actor executor (Swift 5.9+):
actor LegacyWrapper {
private let underlyingQueue = DispatchSerialQueue(label: "legacy")
nonisolated var unownedExecutor: UnownedSerialExecutor {
underlyingQueue.asUnownedSerialExecutor()
}
}
Methods annotated with __attribute__((swift_async)) or that follow the
completionHandler:
convention are automatically available as async in Swift.
// ObjC: - (void)fetchWithCompletion:(void(^)(NSData *, NSError
// *))completion;
// Swift (automatically bridged):
let data = try await legacyAPI.fetch()
You cannot directly expose async functions to Objective-C. Wrap them in
@objc methods that use
callbacks:
@objc func fetchForObjC(completion: @escaping (Data?, Error?) -> Void) {
Task {
do {
let data = try await fetchAsync()
completion(data, nil)
} catch {
completion(nil, error)
}
}
}
XCTest supports async test functions natively:
final class ViewModelTests: XCTestCase {
func testLoadItems() async throws {
let sut = ViewModel(service: MockService())
await sut.loadItems()
XCTAssertEqual(sut.items.count, 3)
}
}
Inject a controllable clock to test time-dependent behavior without actual delays:
// Using the TestClock from Swift Clocks library (by Point-Free)
func testRetry() async throws {
let clock = TestClock()
let sut = RetryPolicy(clock: clock)
var callCount = 0
let task = Task {
try await sut.withRetry(attempts: 3) {
callCount += 1
if callCount < 3 { throw TestError() }
return "success"
}
}
await clock.advance(by: .seconds(1)) // Trigger first retry
await clock.advance(by: .seconds(2)) // Trigger second retry
let result = try await task.value
XCTAssertEqual(result, "success")
}
func testBankAccountConcurrentDeposits() async {
let account = BankAccount(balance: 0)
// Concurrent deposits
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
await account.deposit(10)
}
}
}
let balance = await account.currentBalance
XCTAssertEqual(balance, 1000) // Actor ensures correctness
}
func testCancellation() async throws {
let sut = LongRunningOperation()
let task = Task {
try await sut.perform()
}
task.cancel()
do {
_ = try await task.value
XCTFail("Should have thrown")
} catch is CancellationError {
// Expected
}
}
Test methods do NOT run on the MainActor by default. Use @MainActor
annotation on specific tests
that need it:
@MainActor
func testUIUpdate() async {
let viewModel = ViewModel() // @MainActor class
await viewModel.load()
XCTAssertFalse(viewModel.isLoading)
}
// ❌ NEVER do this — you'll starve the thread pool
func badActor() async {
Thread.sleep(forTimeInterval: 10) // Blocks a thread
let sema = DispatchSemaphore(value: 0)
sema.wait() // Blocks a thread
_ = try? await withCheckedContinuation { c in
// Not resuming — leaked continuation, blocked thread
}
}
var count = 0
// ❌ Race condition — multiple tasks modifying shared var
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
count += 1 // WARNING: mutation of captured var in concurrent
context
}
}
}
// ✅ Use actors or aggregate in group
let total = await withTaskGroup(of: Int.self) { group in
for _ in 0..<100 {
group.addTask { return 1 }
}
return await group.reduce(0, +)
}
// ❌ Task starts even if condition is false
func fetch(if condition: Bool) async throws -> Data {
async let data = expensiveOperation() // Already running!
if condition {
return try await data
}
return Data() // expensiveOperation still ran, was then cancelled
}
// ✅ Conditional fetch
func fetch(if condition: Bool) async throws -> Data {
if condition {
return try await expensiveOperation()
}
return Data()
}
// ❌ Missing @MainActor on ObservableObject
class BadViewModel: ObservableObject {
@Published var items: [Item] = []
func load() async {
let fetched = await fetchItems()
items = fetched // WARNING: published property on non-main thread
}
}
// ✅ Always @MainActor for ObservableObject
@MainActor
class GoodViewModel: ObservableObject {
@Published var items: [Item] = []
func load() async {
let fetched = await fetchItems()
items = fetched // Safe
}
}
// ❌ Creating a task per cell in a list — thousands of tasks
ForEach(items) { item in
ItemView(item: item)
.task {
await item.loadDetails() // OK if item count is small
}
}
For large collections, use batching or task groups with concurrency limits.
// ❌ Showing error UI on cancellation (common bug)
do {
data = try await fetch()
} catch {
showError(error) // Wrong: also shows error when user navigates away
}
// ✅ Distinguish cancellation
do {
data = try await fetch()
} catch is CancellationError {
// Silently ignore — user navigated away
} catch {
showError(error)
}
A classic Apple question for the Creativity/Photos teams.
actor ImageLoader {
private var cache: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func load(url: URL) async throws -> UIImage {
// Check cache first
if let cached = cache[url] { return cached }
// Deduplicate in-flight requests (actor reentrancy safe pattern)
if let existing = inFlight[url] {
return try await existing.value
}
let task = Task<UIImage, Error> {
let (data, response) = try await URLSession.shared.data(from:
url)
guard let image = UIImage(data: data) else {
throw ImageError.invalidData
}
return image
}
inFlight[url] = task
do {
let image = try await task.value
cache[url] = image
inFlight.removeValue(forKey: url)
return image
} catch {
inFlight.removeValue(forKey: url)
throw error
}
}
func evict(url: URL) {
cache.removeValue(forKey: url)
inFlight[url]?.cancel()
inFlight.removeValue(forKey: url)
}
func clearCache() {
cache.removeAll()
for task in inFlight.values { task.cancel() }
inFlight.removeAll()
}
}
Follow-up questions Apple might ask:
actor RateLimiter {
private let maxConcurrent: Int
private var active: Int = 0
private var waiting: [CheckedContinuation<Void, Never>] = []
init(maxConcurrent: Int) {
self.maxConcurrent = maxConcurrent
}
func acquire() async {
if active < maxConcurrent {
active += 1
return
}
await withCheckedContinuation { continuation in
waiting.append(continuation)
}
}
func release() {
if let next = waiting.first {
waiting.removeFirst()
next.resume() // Next waiter gets the slot
} else {
active -= 1
}
}
}
// Usage
let limiter = RateLimiter(maxConcurrent: 5)
func limitedFetch(url: URL) async throws -> Data {
await limiter.acquire()
defer { Task { await limiter.release() } }
return try await URLSession.shared.data(from: url).0
}
Relevant for Apple's iCloud/Creativity apps teams:
actor CloudSyncManager {
private let container: CKContainer
private var pendingUploads: [CKRecord] = []
private var syncTask: Task<Void, Error>?
init(container: CKContainer = .default()) {
self.container = container
}
func scheduleUpload(_ record: CKRecord) async {
pendingUploads.append(record)
await triggerSync()
}
private func triggerSync() async {
guard syncTask == nil else { return }
syncTask = Task {
defer { syncTask = nil }
try await performSync()
}
}
private func performSync() async throws {
while !pendingUploads.isEmpty {
let batch = Array(pendingUploads.prefix(400)) //
CKModifyRecordsOperation limit
pendingUploads.removeFirst(min(400, pendingUploads.count))
try await withCheckedThrowingContinuation { continuation in
let operation = CKModifyRecordsOperation(recordsToSave:
batch)
operation.modifyRecordsResultBlock = { result in
continuation.resume(with: result)
}
container.privateCloudDatabase.add(operation)
}
}
}
}
Model answer:
When you call an await expression on an actor-isolated function from
outside the actor:
This entire mechanism requires no threads to be blocked at any point.
Model answer:
Actor reentrancy means that while an actor is suspended at an await
point, it can start
processing another incoming message before the first one completes. This is
necessary to avoid
deadlock (if actors couldn't reenter, two actors awaiting each other would
deadlock), and to
maintain system liveness.
The danger: any state that was valid before a suspension point might be
invalid after it. An
interleaving message could have mutated actor state between your await
calls. You must treat every
await within an actor as a potential state invalidation point and re-
validate your invariants
after resuming.
The solution: either perform operations atomically (without suspension), or design your state machine to be correct in the face of interleaving.
| Dimension | Structured | Unstructured |
|---|---|---|
| Cancellation | Automatic, hierarchical | Manual via stored Task handle |
| Lifetime | Bounded to scope | Independent, can escape |
| Error propagation | Automatic to parent | Requires explicit handling |
| Priority | Inherited | Specified at creation |
| Use case | Most common, parallel work | Background, UIKit callbacks |
Apple's philosophy: Prefer structured concurrency. Use unstructured only when structure doesn't fit — e.g., responding to a button tap that initiates background work that should survive view lifecycle.
// Find the bug:
class DataManager {
private var cache: [String: Data] = [:]
func data(for key: String) async -> Data? {
if let cached = cache[key] { return cached }
let fetched = await fetchFromNetwork(key: key)
cache[key] = fetched // ← RACE: Multiple tasks can write
simultaneously
return fetched
}
}
// Fix:
actor DataManager {
private var cache: [String: Data] = [:]
private var inFlight: [String: Task<Data?, Never>] = [:]
func data(for key: String) async -> Data? {
if let cached = cache[key] { return cached }
if let task = inFlight[key] { return await task.value }
let task = Task { await fetchFromNetwork(key: key) }
inFlight[key] = task
let result = await task.value
if let result { cache[key] = result }
inFlight[key] = nil
return result
}
}
downloads?
Key points to hit:
.background priority// Cell reuse pattern — cancel previous task
final class ImageCell: UICollectionViewCell {
private var loadTask: Task<Void, Never>?
func configure(url: URL, loader: ImageLoader) {
loadTask?.cancel()
loadTask = Task { @MainActor in
let image = try? await loader.load(url: url)
guard !Task.isCancelled else { return }
self.imageView.image = image
}
}
override func prepareForReuse() {
super.prepareForReuse()
loadTask?.cancel()
loadTask = nil
imageView.image = nil
}
}
| Scenario | Tool |
|---|---|
| Sequential async operations | async/await |
| Known number of parallel tasks | async let |
| Dynamic number of parallel tasks | TaskGroup |
| Produce values over time | AsyncStream |
| Bridge callback API | withCheckedContinuation |
| Share mutable state safely | actor |
| Ensure code runs on main thread | @MainActor |
| Pass context through async chain | @TaskLocal |
| Cancel a tree of tasks | Store Task handle, call .cancel() |
| Background work from sync context | Task { } or Task.detached { } |
Task.sleep, not
Thread.sleepawait is a potential reentrancy point — re-validate actor
state after suspensionasync let over Task groups when you know how many tasks you
need at compile timeTask.isCancelled / Task.checkCancellation() at long-running
loop boundariesCancellationError from real errors in catch blocksSendable is the boundary contract — anything crossing an actor
boundary must be Sendable@MainActor ObservableObjects — always annotate ViewModels to
prevent publishing off main
threadSwift Modern Concurrency reference for Apple Senior iOS Engineering interviews Covers Swift 5.5–5.9+ | WWDC 2021–2024 content