I have a slow, blocking function in Swift that I want to call in a non-blocking (async/await) way.
Here is the original blocking code:
// Original
func totalSizeBytesBlocking() -> UInt64 {
var total: UInt64 = 0
for resource in slowBlockingFunctionToGetResources() {
total += resource.fileSize
}
return total
}
And here is my attempt at making it non-blocking:
// Attempt at async/await
func totalSizeBytesAsync() async -> UInt64 {
// I believe I need Task.detached instead of just Task.init to avoid blocking the main thread?
let handle = Task.detached {
return self.slowBlockingFunctionToGetResources()
}
// is this the right way to handle cancellation propagation with a detached Task?
// or should I be using Task.withTaskCancellationHandler?
if Task.isCancelled {
handle.cancel()
}
let resources = await handle.value
var total: UInt64 = 0
for resource in resources {
total += resource.fileSize
}
return total
}
My goal is to be able to await the async version from the main actor/thread and have it do the slow, blocking work on a background thread while I continue to update the UI on the main thread.
How should this be done?
There are a few questions here.
How to handle cancelation?
You are correct in your suspicion that this
Task.isCancelledpattern is insufficient. The problem is that you are testing immediately after the task is created, but it will quickly pass that cancelation check, then suspend at theawaitof the task, at which point no further cancelations will be detected. As you guessed, when dealing with unstructured concurrency, you might usewithTaskCancellationHandler, instead:Probably needless to say, as shown above, cancelation will only work correctly if that slow blocking function supports it (e.g., periodically tries
checkCancellationor, less ideal, testsisCancelled).If this slow synchronous function doesn’t handle cancelation, then there is little point in checking for cancelation, and you are stuck with a task that will not finish until the synchronous task is done. But at least
totalSizeBytesAsyncwill not block. E.g.:Should you use unstructured concurrency at all?
As a general rule, we should avoid cluttering our code with unstructured concurrency (where we bear the burden for manually checking for cancelation). So, it begs the question of how you get the task off the current actor, while remaining within structured concurrency. You can put the synchronous function in its own, separate actor. Or, because of SE-0338, you can alternatively just make your slow function both
nonisolatedandasync. That gets it off the current actor:But by remaining within structured concurrency, our code is greatly simplified.
Obviously, if you want to use an
actor, feel free:Where:
How slow is the synchronous function?
Swift concurrency relies upon a “contract” to avoid blocking any thread in the cooperative thread pool. See https://stackoverflow.com/a/74580345/1271826.
So if this really is a slow function, we really should have our slow process periodically
Task.yield()to the Swift concurrency system to avoid potential deadlocks. E.g.,Now, if (a) you do not have to opportunity to refactor this function to periodically
yield; and (b) it really is very, very slow, then Apple advises that you get this function out of the Swift concurrency system. E.g., in WWDC 2022 video Visualize and optimize Swift concurrency, they suggest GCD:Bottom line, be wary of ever blocking a cooperative thread pool thread for any prolonged period of time.