This comes up over and over, here on the forums and elsewhere, so I thought I’d post my take on it. If you have questions or comments, start a new thread here on the forums. Put it in the App & System Services > Processes & Concurrency subtopic and tag it with Concurrency.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
Waiting for an Async Result in a Synchronous Function
On Apple platforms there is no good way for a synchronous function to wait on the result of an asynchronous function.
Lemme say that again, with emphasis…
On Apple platforms there is no good way for a synchronous function to wait on the result of an asynchronous function.
This post dives into the details of this reality.
Prime Offender
Imagine you have an asynchronous function and you want to call it from a synchronous function:
func someAsynchronous(input: Int, completionHandler: @escaping @Sendable (_ output: Int) -> Void) {
… processes `input` asynchronously …
… when its done, calls the completion handler with the result …
}
func mySynchronous(input: Int) -> Int {
… calls `someAsynchronous(…)` …
… waits for it to finish …
… results the result …
}
There’s no good way to achieve this goal on Apple platforms. Every approach you might try has fundamental problems.
A common approach is to do this working using a Dispatch semaphore:
func mySynchronous(input: Int) -> Int {
fatalError("DO NOT WRITE CODE LIKE THIS")
let sem = DispatchSemaphore(value: 0)
var result: Int? = nil
someAsynchronous(input: input) { output in
result = output
sem.signal()
}
sem.wait()
return result!
}
Note This code produces a warning in the Swift 5 language mode which turns into an error in the Swift 6 language mode. You can suppress that warning with, say, a Mutex
. I didn’t do that here because I’m focused on a more fundamental issue here.
This code works, up to a point. But it has unavoidable problems, ones that don’t show up in a basic test but can show up in the real world. The two biggest ones are:
- Priority inversion
- Thread pools
I’ll cover each in turn.
Priority Inversion
Apple platforms have a mechanism that helps to prevent priority inversion by boosting the priority of a thread if it holds a resource that’s needed by a higher-priority thread. The code above defeats that mechanism because there’s no way for the system to know that the threads running the work started by someAsynchronous(…)
are being waited on by the thread blocked in mySynchronous(…)
. So if that blocked thread has a high-priority, the system can’t boost the priority of the threads doing the work.
This problem usually manifests in your app failing to meet real-time goals. An obvious example of this is scrolling. If you call mySynchronous(…)
from the main thread, it might end up waiting longer than it should, resulting in noticeable hitches in the scrolling.
Threads Pools
A synchronous function, like mySynchronous(…)
in the example above, can be called by any thread. If the thread is part of a thread pool, it consumes a valuable resource — that is, a thread from the pool — for a long period of time. The raises the possibility of thread exhaustion, that is, where the pool runs out of threads.
There are two common thread pools on Apple platforms:
- Dispatch
- Swift concurrency
These respond to this issue in different ways, both of which can cause you problems.
Dispatch can choose to over-commit, that is, start a new worker thread to get work done while you’re hogging its existing worker threads. This causes two problems:
- It can lead to thread explosion, where Dispatch starts dozens and dozens of threads, which all end up blocked. This is a huge waste of resources, notably memory.
- Dispatch has an hard limit to how many worker threads it will create. If you cause it to over-commit too much, you’ll eventually hit that limit, putting you in the thread exhaustion state.
In contrast, Swift concurrency’s thread pool doesn’t over-commit. It typically has one thread per CPU core. If you block one of those threads in code like mySynchronous(…)
, you limit its ability to get work done. If you do it too much, you end up in the thread exhaustion state.
WARNING Thread exhaustion may seem like just a performance problem, but that’s not the case. It’s possible for thread exhaustion to lead to a deadlock, which blocks all thread pool work in your process forever.
There’s a trade-off here. Swift concurrency doesn’t over-commit, so it can’t suffer from thread explosion but is more likely deadlock, and vice versa for Dispatch.
Bargaining
Code like the mySynchronous(…)
function shown above is fundamentally problematic. I hope that the above has got you past the denial stage of this analysis. Now let’s discuss your bargaining options (-:
Most folks don’t set out to write code like mySynchronous(…)
. Rather, they’re working on an existing codebase and they get to a point where they have to synchronously wait for an asynchronous result. At that point they have the choice of writing code like this or doing a major refactor.
For example, imagine you’re calling mySynchronous(…)
from the main thread in order to update a view. You could go down the problematic path, or you could refactor your code so that:
- The current value is always available to the main thread.
- The asynchronous code updates that value in an observable way.
- The main thread code responds to that notification by updating the view from the current value.
This refactoring may or may not be feasible given your product’s current architecture and timeline. And if that’s the case, you might end up deploying code like mySynchronous(…)
. All engineering is about trade-offs. However, don’t fool yourself into thinking that this code is correct. Rather, make a note to revisit this choice in the future.
Async to Async
Finally, I want to clarify that the above is about synchronous functions. If you have a Swift async function, there is a good path forward. For example:
func mySwiftAsync(input: Int) async -> Int {
let result = await withCheckedContinuation { continuation in
someAsynchronous(input: input) { output in
continuation.resume(returning: output)
}
}
return result
}
This looks like it’s blocking the current thread waiting for the result, but that’s not what happens under the covers. Rather, the Swift concurrency worker thread that calls mySwiftAsync(…)
will return to the thread pool at the await
. Later, when someAsynchronous(…)
calls the completion handler and you resume the continuation, Swift will grab a worker thread from the pool to continue running mySwiftAsync(…)
.
This is absolutely normal and doesn’t cause the sorts of problems you see with mySynchronous(…)
.
IMPORTANT To keep things simple I didn’t implement cancellation in mySwiftAsync(…)
. In a real product it’s important to support cancellation in code like this. See the withTaskCancellationHandler(operation:onCancel:isolation:)
function for the details.