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 from the edges inward, and 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 one module at a time.
Resume the continuation exactly once. Zero resumes and the caller hangs forever. Two resumes and you crash. Checked continuations catch both at runtime, which is why you use withCheckedThrowingContinuation rather than the unsafe variant.
Blocking calls inside a Task are the other trap. performAndWait, synchronous I/O, or a lock held across an await pins threads in Swift's cooperative thread pool, and enough blocked threads deadlock the app. Move blocking work to a dedicated dispatch queue and bridge back with a continuation, or use the API's native async form.
Sometimes you shouldn't migrate at all. Stable code with no planned features, tight deadlines, heavy Objective-C interop, or a team not ready for strict concurrency warnings are all reasons to leave GCD alone. Migration is a tradeoff, so don't force it where it adds risk with no payoff.
Principles
withCheckedThrowingContinuationbridges any completion handler intoasync throws, andwithCheckedContinuationcovers callbacks that cannot fail.- Resume exactly once, and build that discipline with checked continuations before you touch the unsafe variants.
- Never block inside a
Task: noperformAndWait, no synchronous I/O, and no lock held across anawait. - Prefer
@MainActoroverDispatchQueue.main.asyncin new code, and keep the dispatch form only 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
try await withCheckedThrowingContinuation { continuation in
legacyFetch(id: id) { result in
continuation.resume(with: result) // always called exactly once
}
}
// Problem: zero resumes on the failure path, so the caller hangs forever
// (a checked continuation logs a warning when it is dropped)
try await withCheckedThrowingContinuation { continuation in
legacyFetch(id: id) { result in
if case .success = result {
continuation.resume(with: result) // never called on failure
}
}
}
// Problem: double resume, which traps
try await withCheckedThrowingContinuation { continuation in
legacyFetch(id: id) { result in
continuation.resume(with: result)
continuation.resume(with: result) // second call traps
}
}
// Problem: blocks a thread from the cooperative pool until Core Data finishes
Task {
context.performAndWait { /* Core Data work */ }
}
// Fix: the native async form suspends instead of blocking
Task {
try await context.perform { /* Core Data work */ }
}



