← All topics/Concurrency

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

  • withCheckedThrowingContinuation bridges any completion handler into async throws, and withCheckedContinuation covers 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: no performAndWait, no synchronous I/O, and no lock held across an await.
  • Prefer @MainActor over DispatchQueue.main.async in new code, and keep the dispatch form 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
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: blocking inside async work
// 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 */ }
}