← All topics/Concurrency

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–Q10
async / 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 — Task is 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 · Q10
Task { } 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 · Q8
Actor — 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 · Q6
Search — 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

Q5
withCheckedThrowingContinuation
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 · Q3
Guarantee 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
ApproachThread safetyCancellationRead perfBest for
actorCompiler enforcedYesSerial readsMutable shared state
NSCacheBuilt-in safeNoConcurrent readsUIImage + eviction
DispatchQueue barrierConcurrent reads / serial writesNoParallel readsRead-heavy GCD codebases
@MainActorCompiler enforcedYesMain onlyUI state / view models
Swift concurrency — interview prep cheat sheetAligned with practical question set