Let’s say I have the following function which performs a long-running, expensive, synchronous operation:
func doSomethingExpensive() -> Foo {
let result = // ... some long-running, expensive, synchronous operation ...
let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
return manipulatedResult
}
Knowing that this is a long-running, expensive operation, I would like to do it asynchronously on a background thread to avoid blocking the UI. Prior to async/await, I would have achieved this as follows:
func doSomethingExpensive(completion: ((Foo) -> Void)) {
DispatchQueue.global().async {
let result = // ... some long-running, expensive, synchronous operation ...
let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
DispatchQueue.main.async {
completion(manipulatedResult)
}
}
}
With async/await, I could write my function as follows to make it asynchronous, but this doesn’t give me any control over which thread it ends up being executed on — that’ll be determined by the caller.
func doSomethingExpensive() async -> Foo {
let result = // ... some long-running, expensive, synchronous operation ...
let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
return manipulatedResult
}
So if the caller is MainActor-isolated, my async func will get executed on the main thread and block the main thread until its long-running, expensive operation completes:
func reloadUI() {
Task { @MainActor in
showSpinner = true
foo = await doSomethingExpensive()
showSpinner = false
}
}
So that’s obviously no good. I’ve done a lot of research and it seems to all boil down to two solutions:
-
Task.detached
func doSomethingExpensive() async -> Foo { await Task.detached { let result = // ... some long-running, expensive, synchronous operation ... let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation return manipulatedResult }.value }
-
GCD + Continuations
func doSomethingExpensive() async -> Foo { await withCheckedContinuation { continuation in DispatchQueue.global().async { let result = // ... some long-running, expensive, synchronous operation ... let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation continuation.resume(returning: manipulatedResult) } } }
And this is where my confusion comes in as I’ve seen conflicting information. For example, this comment states:
[You] should not run long-running expensive, blocking operations in a concurrency context, regardless of if it’s the main actor or not. Swift concurrency is a cooperative model, i.e. the functions that run in a concurrency context are expected to regularly suspend to give up control of their thread and give the runtime a chance to schedule other tasks on that thread.
In addition, there’s this statement excerpted from the WWDC21 video, “Swift concurrency: Behind the scenes”:
Recall that with Swift, the language allows us to uphold a runtime contract that threads will always be able to make forward progress. It is based on this contract that we have built a cooperative thread pool to be the default executor for Swift. As you adopt Swift concurrency, it is important to ensure that you continue to maintain this contract in your code as well so that the cooperative thread pool can function optimally.
If I’m understanding this correctly, this would mean “Solution 1: Task.detached” is no good as we shouldn’t be executing any long-running, expensive, blocking operations within an async function (including Task.detached { ... }
) as we’d be blocking forward progress and breaking that runtime contract.
So if we’re not supposed to do that, I would have thought that GCD would have been the correct alternative, but I’ve also seen comments stating that GCD should be avoided:
One should avoid using
DispatchQueue
at all.
https://stackoverflow.com/a/70645342/8408162
I would avoid introducing GCD, as that does not participate in the cooperative thread pool and Swift concurrency will not be able to reason about the system resources, avoid thread explosion, etc.
https://stackoverflow.com/a/74580345/8408162
Lastly, this comment:
While it is interesting to look at
Thread.current
, it should be noted that Apple is trying to wean us off of this practice. E.g., in Swift 5.7, if we look atThread.current
from an asynchronous context, we get a warning:Class property ‘current’ is unavailable from asynchronous contexts; Thread.current cannot be used from async contexts.; this is an error in Swift 6
The whole idea of Swift concurrency is that we stop thinking in terms of threads and we instead let Swift concurrency choose the appropriate thread on our behalf (which cleverly avoids costly context switches where it can; sometimes resulting code that runs on threads other than what we might otherwise expect).
If this is true — and I hope it is because I love the idea of not having to think about threads — I would imagine that I should be able to retire GCD completely and rely entirely on Swift concurrency, but based on the earlier comments above I’m not convinced that that’s the case.
All of that being said, I’m not sure what to believe nor which solution is correct. Hoping someone can give me a definitive answer (and explanation) one way or the other. Thanks!
2
Answers
As your second quote says,
The "contract" refers to allowing other threads to make progress, and you do that by "give up control of their thread and give the runtime a chance to schedule other tasks on that thread", as your first quote says.
So that’s what you should do.
Break up the task you want to do into non-"long running" smaller pieces. For example, you can make
manipulateResult
and the function that gets you the result bothasync
.You can make the functions that
getResult
andmanipulateResult
usesasync
too, andawait
their return values. Illustrative code:Every time you write
await
, you have a suspension point where theTask
can "give up control of their thread" and allow other tasks on that thread to make progress.You can also give other tasks a chance to run by awaiting
Task.yield()
, but that wouldn’t work if the current task has the highest priority.And yes,
Task.detached
is what you should use if you don’t want to inherit the actor context.tl;dr
Ideally, the long computation should periodically
yield
, then you can remain entirely within Swift concurrency.You quote this comment:
We should note that two sentences later, they answer your question:
So, bottom line, if you have your own long-running routine, you should periodically
yield
and you can safely perform computationally intense calculations within Swift concurrency while upholding the contract to not impede forward progress.This begs the question: What if the slow routine cannot be altered to periodically
yield
(or otherwise suspend in a cooperative manner)?In answer to that, the proposal, SE-0296 – Async/await, says:
So, the proposal is reiterating what was said above, that you should integrate the ability to “interleave code” (e.g., periodically
yield
within the long computations). If you do that, you can safely stay with Swift concurrency.Re long computations that do not have that capability, the proposal suggests that one “run it in a separate context” (which it never defined). On the forum discussion for this proposal, someone asked for clarification on this point, but this was never directly answered.
One might infer that they intend (if you cannot
yield
to allow interleaving) to put this on a thread outside of the cooperative thread pool, e.g., via a GCD queue. But, Swift concurrency cannot reason about other threads that might be tying up CPU cores, which theoretically can lead to an over-commit of CPU resources (one of the problems that the cooperative thread pool was designed to address).