My team is currently in the process of migrating existing code from stuff that uses a lot of DispatchQueue and DispatchGroup to using structured concurrency. Here's an example of some code that might exist in our repo.
func loadModelC(_ completion: @escaping (Result<ModelC, Error>) -> Void) {
let group = DispatchGroup()
var resultA: Result<ModelA, Error>?
var resultB: Result<ModelB, Error>?
group.enter()
fetchModelA { result in
resultA = result
group.leave()
}
group.enter()
fetchModelB { result in
resultB = result
group.leave()
}
group.notify(queue: .main) {
let finalResult = Result {
try ModelC(
a: resultA.orThrow().get(),
b: resultB.orThrow().get()
)
}
completion(finalResult)
}
}
There doesn't seem to be an easy way to incrementally migrate to async/await, as Task blocks require passed in closure to be @Sendable, which is problematic with the mutable Results declared at the top. Is there any way around this to be able to, for example, replace one of these calls with one that uses async/await?
A related question: As we're migrating, is it at all likely that closure-taking methods on DispatchQueue and related types will require their closures to be @Sendable in the future? We're not entirely sure if legacy closure-taking code should be migrated to use @Sendable closures or if it's safe remaining non-Sendable.
func makeCallbackAsync<T>(_ operation: () -> (T) -> Void) async -> T {
return await withCheckedContinuation { continuation in
operation { val in
continuation.resume(returning: val)
}
}
}
Should transform any callback-based API into an asynchronous one, if I got the syntax right. I donβt believe continuations need Sendable; places where itβs needed are such to enforce program correctness.
Edit for clarity: I mean that T should not need to be Sendable. operation may need to be @Sendable.
Right, I'm aware of the continuation APIs. To be clearer, the problem is, if I replace my fetchModelB method with an async version, and try to replace it in the code above...
...this will not compile due to the mutable capture of resultB in a @Sendable closure. Therefore, we would have to migrate the entire method to use structured concurrency rather than migrate bit by bit. This is less than ideal when there's a large number of these sorts of methods with lots of group.enter/group.leave pairs involving more legacy asynchronous closure-taking method calls.
Perhaps I misunderstood. You want to make fetchModelB async, but keep the wrapping function loadModelC callback-based? Using a callback-based API inside an async one is fairly easy with continuations, but the other way around is harder; Iβm not sure how to do it without spawning unstructured Tasks:
Right. The fundamental reason for this is that async functions can suspend, and non-async functions cannot. If fetchModelBcan suspend (because it is async), it cannot be called from a context which cannot suspend (i.e. fetchModelC, which is not async).
The structure in structured concurrency inherently comes from these suspensions. You may need to approach your incremental migration from the other direction (top-down rather than bottom-up) in order to use structured concurrency, else you will be dealing with a lot of unstructured tasks because your calling code can't handle having to suspend.
You want to make fetchModelB async, but keep the wrapping function loadModelC callback-based?
Correct. Migrating the entire loadModelC method to using structured concurrency, when there are many, many such methods in the codebase that might contain many nested calls like fetchModelA, is something we're hoping to approach incrementally within each method rather than all at once. Perhaps there is no way around this, though.
I would agree with @Karl then: The first method (of these three) that should be migrated from callbacks to async should be loadModelC, not fetchModelA or fetchModelB.
That's not so much a problem; the problem is that the use of those unstructured tasks mean that we're going to have to rewrite the whole loadModelC methods if we want to introduce any async/await code at all, which is a shame.
Seems there's no way around that though! In any case, many thanks to both of you.
The upcoming Swift 6 language mode has a goal of enforcing data race safety, which will require the SDK to declare concurrently-executed callbacks like those on DispatchQueue to be @Sendable. You won't have to immediately change language modes, of course, and Swift will continue to provide source compatibility if you don't, but if you're asking where the language is going, it definitely includes things like that becoming @Sendable.
Continuations and explicit Tasks are usually the right way to bridge sync and async code when you need to migrate them gradually.