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
withCheckedThrowingContinuationbridges any completion handler intoasync throws; usewithCheckedContinuationfor non-throwing callbacks.- Resume exactly once — build that discipline before touching unsafe continuations.
- Never block inside a
Task— noperformAndWait, no synchronous I/O, no locks held acrossawait. DispatchQueue.main.asyncvs@MainActor: prefer@MainActorfor new code; keepmain.asynconly where interop demands it.
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)
}
}
}
}
// ✅ 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
}
}
// Can stall the cooperative pool
Task {
context.performAndWait { /* Core Data */ }
}
// Prefer: context.perform { } async wrapper, or NSPersistentCloudKitContainer patterns