← All topics/Concurrency

Practical interview questions

Scenario-style prompts with sample answer outlines. Focus is on how you would design and reason in real codebases.

Question 5

Bridging legacy GCD to async/await

You're working in a large codebase that heavily uses GCD. How would you incrementally migrate it to async/await without breaking behavior?

Follow-ups

  • When would you not migrate?

Answer outline

Migrate incrementally from the edges inward — don't rewrite everything at once. Wrap your lowest-level completion-handler APIs with withCheckedThrowingContinuation, so callers can become async while the GCD internals stay untouched. Then move up the call stack module by module.

The one rule you must never break: resume the continuation exactly once. Zero resumes = the caller hangs forever. Two resumes = crash. Checked continuations trap both at runtime, which is why you reach for withCheckedThrowingContinuation over the unsafe variant.

The hidden trap: blocking calls inside a Task can cause thread explosion. performAndWait, synchronous I/O, or a lock held across await pins a cooperative-pool thread. With GCD you owned your threads; Swift concurrency manages a small shared pool - block enough of them and you deadlock. Replace blocking calls with async equivalents, or confine them to an actor.

When not to migrate: stable code with no new features, tight deadlines, heavy Obj-C interop, or a team not ready to deal with strict concurrency warnings. Migration is a trade-off — don't force it where it adds risk with no payoff.

Principles

  • withCheckedThrowingContinuation bridges any completion handler into async throws; use withCheckedContinuation for non-throwing callbacks.
  • Resume exactly once — build that discipline before touching unsafe continuations.
  • Never block inside a Task — no performAndWait, no synchronous I/O, no locks held across await.
  • DispatchQueue.main.async vs @MainActor: prefer @MainActor for new code; keep main.async only where interop demands it.
Wrap a completion handler once, resume exactly once
func legacyFetch(id: String, completion: @escaping (Result<User, Error>) -> Void) {
    DispatchQueue.global().async {
        // ...
    }
}

func fetchUser(id: String) async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        legacyFetch(id: id) { result in
            switch result {
            case .success(let user): continuation.resume(returning: user)
            case .failure(let error): continuation.resume(throwing: error)
            }
        }
    }
}
Resume exactly once — what goes wrong otherwise
// ✅ Correct — one path, one resume
withCheckedThrowingContinuation { continuation in
    legacyFetch { result in
        continuation.resume(with: result) // always called exactly once
    }
}

// ❌ Zero resumes — caller hangs forever (checked continuation will warn at deinit)
withCheckedThrowingContinuation { continuation in
    legacyFetch { result in
        if case .success = result {
            continuation.resume(with: result) // never called on failure path
        }
    }
}

// ❌ Double resume — crash
withCheckedThrowingContinuation { continuation in
    legacyFetch { result in
        continuation.resume(with: result)
        continuation.resume(with: result) // second call traps
    }
}
Blocking inside async work — avoid
// Can stall the cooperative pool
Task {
    context.performAndWait { /* Core Data */ }
}

// Prefer: context.perform { } async wrapper, or NSPersistentCloudKitContainer patterns