Senior / Staff iOS — interview prep
Swift concurrency
async/await · actors · tasks · structured concurrency · GCD migration · debugging
Race conditionsMain threadActor designCancellationGCD migrationTask lifecycleDebuggingThread-safe dataParallelismTask.detached
01
Core mental model
underpins Q1–Q10async / await
- async marks a function that can suspend
- await = suspension point, not a block
- Thread is freed during suspension for other work
- Can only appear inside an async context or Task
- UIKit entry is sync —
Taskis the bridge
Executors
- Every actor has a serial executor
@MainActor= main run loop- Custom actors use the cooperative thread pool
- Crossing an actor boundary = switching executors
- Pool is bounded — limits thread explosion
Tasks vs threads
- Tasks are cheap scheduling units; threads are not
- Runtime maps tasks onto a bounded pool
- You reason about isolation (actors), not manual threads
- Cancellation and priority flow through task hierarchy
i
One-line definition: Swift concurrency replaces threads-as-units-of-work with tasks-as-units-of-work. The runtime schedules tasks onto a bounded cooperative pool — you manage actors and tasks, not raw threads.
02
Tasks — creation, inheritance, lifecycle
Q2 · Q6 · Q10Task { } vs Task.detached
// Task { } — inherits actor context
@MainActor class ViewModel {
func load() {
Task {
// Still on MainActor — inherited
let data = await heavyWork()
}
Task.detached {
// Sever context — cooperative pool
let data = await heavyWork()
await MainActor.run { /* update UI */ }
}
}
}
SwiftUI .task vs UIKit Task {}
// SwiftUI — .task tied to view lifetime
struct FeedView: View {
var body: some View {
List(posts) { /* ... */ }
.task { await viewModel.load() }
}
}
// UIKit — manual lifecycle
class FeedVC: UIViewController {
private var task: Task<Void, Never>?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
task = Task { await viewModel.load() }
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
task?.cancel()
task = nil
}
}
Task { }
- Inherits actor context & priority
- Participates in cancellation tree
- CPU-heavy work still runs on MainActor — use Task.detached for off-main computation
Task.detached
- Severs actor context
- Runs on cooperative pool
- Must hop back to MainActor for UI
SwiftUI .task
- Tied to view lifetime
- Auto-cancelled on disappear
- Preferred in SwiftUI
Task leaks
- Detached can outlive VC
- Store handle; cancel in deinit / disappear
[weak self]in detached closures
03
Actors — isolation, reentrancy, performance
Q1 · Q3 · Q8Actor — shared cache
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func store(_ img: UIImage, for url: URL) {
cache[url] = img
}
func loadIfNeeded(url: URL) async throws -> UIImage {
if let cached = cache[url] { return cached }
let img = try await download(url)
if let existing = cache[url] { return existing }
cache[url] = img
return img
}
}
@MainActor — hops across await
@MainActor class FeedViewModel {
var posts: [Post] = []
func load() {
Task {
let result = await filterActor.filter(posts)
posts = result
}
}
}
Reentrancy trap
- Actor suspends at every await
- Another task can enter during suspension
- Re-check state after await
Actor bottlenecks
- Read-heavy caches — serial reads cost parallelism
- Long work inside actor blocks all callers
- Mitigate: nonisolated pure reads, NSCache, sharding
nonisolated
- Runs on caller’s executor for safe pure methods
- No shared mutable state inside
04
Structured concurrency
Q1 · Q9✓
Definition: Child tasks cannot outlive the scope that created them — lifetime scoping, cancellation propagation, and error bubbling are automatic.
async let — fixed fan-out
func loadCell(for post: Post) async throws {
async let image = fetchImage(post.imageURL)
async let metadata = fetchMetadata(post.videoID)
async let comments = fetchCount(post.id)
let data = try await (image, metadata, comments)
}
TaskGroup — dynamic fan-out
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask { try await fetch(url) }
}
var out: [Data] = []
for try await item in group { out.append(item) }
return out
}
async let vs TaskGroup
- async let — small fixed arity, clean syntax
- TaskGroup — runtime-sized fan-out
- Both: structured lifetime + cancellation
UX tradeoff
- Progressive: await separately — perceived speed
- Atomic: tuple await — one layout pass
05
Cancellation
Q4 · Q6Search — cancel stale work
@MainActor class SearchViewModel {
private var searchTask: Task<Void, any Error>?
func search(query: String) {
searchTask?.cancel()
searchTask = Task {
try await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
results = try await api.search(query)
}
}
}
Cooperative checkpoints
func fetchImage(url: URL) async throws -> UIImage {
try Task.checkCancellation()
let (data, _) = try await URLSession.shared.data(from: url)
try Task.checkCancellation()
return try decode(data)
}
How cancel works
- cancel() sets a flag only
- Cooperative — task must check
- Structured children cancel with parent
checkCancellation
- Throws CancellationError to abort
- Place between expensive steps
Cell reuse
- Cancel task in prepareForReuse
- Guard identity after each await
06
GCD migration
Q5withCheckedThrowingContinuation
func fetchUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
legacyAPI.getUser(id: id) { result in
switch result {
case .success(let user): continuation.resume(returning: user)
case .failure(let err): continuation.resume(throwing: err)
}
}
}
}
Thread explosion risk
// Blocking synchronous APIs inside async work
// can stall the cooperative pool
Task.detached {
context.performAndWait { } // blocks a pool thread
}
// Prefer async-shaped APIs or isolate blocking work
Strategy
- Migrate leaves first, wrap with continuations
- Module-by-module; enable TSan
When not to migrate
- Stable legacy with no churn
- Team not ready / tight deadline
GCD vs Swift
- GCD: fire-and-forget, weak cancellation story
- Swift: compile-time isolation + structured lifetime
07
Main thread safety
Q2 · Q3Guarantee hierarchy
- @MainActor class — strongest compiler guarantee
- MainActor.run — explicit hop from detached
- DispatchQueue.main.async — runtime only
Verify
- Thread Sanitizer for races
- Time Profiler — main thread weight
- Enable strict concurrency checking (Swift 6 mode) module-by-module to catch isolation violations at compile time
!
Common trap:
Task inside @MainActor still runs on the MainActor. Use Task.detached or another actor for CPU-heavy work.08
Thread-safe data layer
Q8| Approach | Thread safety | Cancellation | Read perf | Best for |
|---|---|---|---|---|
| actor | Compiler enforced | Yes | Serial reads | Mutable shared state |
| NSCache | Built-in safe | No | Concurrent reads | UIImage + eviction |
| DispatchQueue barrier | Concurrent reads / serial writes | No | Parallel reads | Read-heavy GCD codebases |
| @MainActor | Compiler enforced | Yes | Main only | UI state / view models |