Calling Swift Concurrency async code synchronously in Swift

Sometimes you just need to shove a round peg into a square hole. Sometimes that genuinely is the best option (or perhaps more accurately: the least bad option).

I find my hand is often forced by APIs I don’t control (most often Apple’s APIs). e.g. data source or delegate callbacks that are synchronous1 and require you to return a value, but in order to obtain that value you have to run async code (perhaps because yet again that’s all you’re given by 3rd parties, or because that code makes sense to be async and is used happily as such in other places and you don’t want to have to duplicate it in perpetuity just to have a sync version).

If that asynchronosity is achieved through e.g. GCD or NSRunLoop or NSProcess or NSTask or NSThread or pthreads, it’s easy. There are numerous ways to synchronously wait on their tasks. In contrast, Swift Concurrency really doesn’t want you to do this. The language and standard library take an adamant idealogical position on this – one which is unfortunately impractical; a spherical chicken in a vacuum2.

Nonetheless, despite Swift’s best efforts to prevent me, I believe I’ve come up with a way to do this. It appears to work reliably, given fairly extensive testing. Nonetheless, I do not make any promises. Use at your own risk.

If you know of a better way, please do let me know (e.g. in the comments below).

import Dispatch

extension Task {
    /// Executes the given async closure synchronously, waiting for it to finish before returning.
    ///
    /// **Warning**: Do not call this from a thread used by Swift Concurrency (e.g. an actor, including global actors like MainActor) if the closure - or anything it calls transitively via `await` - might be bound to that same isolation context.  Doing so may result in deadlock.
    static func sync(_ code: sending () async throws(Failure) -> Success) throws(Failure) -> Success { // 1
        let semaphore = DispatchSemaphore(value: 0)

        nonisolated(unsafe) var result: Result<Success, Failure>? = nil // 2

        withoutActuallyEscaping(code) { // 3
            nonisolated(unsafe) let sendableCode = $0 // 4

            let coreTask = Task<Void, Never>.detached(priority: .userInitiated) { @Sendable () async -> Void in // 5
                do {
                    result = .success(try await sendableCode())
                } catch {
                    result = .failure(error as! Failure)
                }
            }

            Task<Void, Never>.detached(priority: .userInitiated) { // 6
                await coreTask.value
                semaphore.signal()
            }

            semaphore.wait()
        }

        return try result!.get() // 7
    }
}

Elaborating on some of the odder or less than self-explanatory aspects of this:

  1. The closure parameter must be sending otherwise this deadlocks if e.g. you call it from the main thread (even if the closure, and all its transitive async calls, are not isolated to the main thread). I don’t understand why this happens – it’s possibly explicable and working as intended, but I wonder if it’s simply a bug. Nobody has been able to explain why it happens.

    Note: in the initial version of this post I accidentally omitted this essential keyword. I apologise for the error, and hope it didn’t cause grief for anyone.
  2. Since there’s no sync way to retrieve the result of a Task, the result has to be passed out through a side-channel. The nonisolated(unsafe) is to silence the Swift 6 compiler’s erroneous error diagnostics about concurrent mutation of shared state.
  3. Task constructors only accept escaping closures, even though as they’re used here the closure never actually escapes. Fortunately the withoutActuallyEscaping escape hatch is available.
  4. code isn’t @Sendable – since it doesn’t actually have to be sent in the sense of executing concurrently – so trying to use it in the Task closure below, which is @Sendable, results in an erroneous compiler error (“Capture of 'code' with non-sendable type '() async throws(Failure) -> Success' in a @Sendable closure“). Assigning to a variable lets us apply nonisolated(unsafe) to disable the incorrect compiler diagnostic.
  5. Several key aspects happen on this line:
    • It’s important to use a detached task, in case we’re already running in an isolated context (e.g. the MainActor) as we’re going to block the current thread waiting on the task to finish, via the semaphore.
    • The task logically needs to be run at the current task’s priority (or higher) in order to ensure it does actually run (re. priority inversion problems), although I’m not sure that technically matters here since we’re blocking in a non-await way anyway. One could use Task.currentPriority here, but I’ve chosen to hard-code the highest priority (userInitiated) because it’s not great to block (in a non-await manner) on async code; although async code isn’t necessarily slow, I feel it’s wise to eliminate task prioritisation as a variable.
    • This closure must be explicitly marked as @Sendable as by default the compiler mistakenly infers it to be not @Sendable, even though all closure arguments to Task initialisers have to be @Sendable. The compiler diagnostics in this case are frustratingly obtuse and misleading (although the sad saving grace is that this is a relatively common problem, so once you hit it enough times you start to develop a spidey sense for it).
  6. This otherwise pointless second Task is critical to prevent withoutActuallyEscaping from crashing.

    withoutActuallyEscaping basically relies on reference-counting – it records the retain count of its primary argument going in (code in this case) and compares that to the retain count going out – if they disagree, it crashes. There’s no way to disable or directly work around this3.

    That’s a problem here because if we just signal the semaphore in the first task, right before exiting the task, we have a race – maybe the task will actually exit before the signal is acted on (semaphore.wait() returns and allows execution to exit the withoutActuallyEscaping block), but maybe it won’t. Since the task is retaining the closure, it must exit before we wake up from the semaphore and exit the withoutActuallyEscaping block, otherwise crash.

    The only way I found to ensure the problematic task has fully exited – after hours of experimenting, covering numerous methods – is to wait for it in a second task. Surprisingly, the second task – despite having a strong reference to the first task – seemingly doesn’t prevent the first task from being cleaned up. This makes me suspicious, but despite extensive testing I’m unable to get withoutActuallyEscaping to crash when using this workaround.
  7. There’s no practical way to avoid this forced unwrap, even though it’s impossible for it to fail unless something goes very wrong with Swift’s built-ins (like withoutActuallyEscaping and Task).

    If you don’t wish to use typed throws, you could unwrap it more gently and throw an error of your own type if it’s nil, but it’s extra work and a loss of type safety for something that realistically cannot happen.
  1. Specifically meaning “not run through Swift Concurrency, as async functions / closures”. Lots of APIs will execute the callback on the main thread, which is the most difficult case, but even those that execute on a user-specified GCD queue aren’t helpful here – at least, not until assumeIsolated actually works. ↩︎
  2. Incidentally, Wikipedia seems to think the canonical version of the joke is about spherical cows, but I’ve only ever heard it about chickens. Indeed the very first known instance of the joke used chickens, and all the pop-culture uses of it that I could find use chickens (most notably the ninth episode of The Big Bang Theory). ↩︎
  3. There’s no unsafeWithoutActuallyEscaping that forgoes the runtime checking, nor any environment variables or similar that influence it; the check is always included and cannot be disabled at runtime. Even when it’s unnecessary or – as in this case – outright erroneous.

    Nor is there a way to replicate withoutActuallyEscaping‘s core functionality of merely adding @escaping to the closure’s signature (e.g. via unsafeBitCast or similar) because it’s a special interaction with the compiler’s escape checker which is evaluated purely at compile-time (whether a closure is escaping or not is not actually encoded into the output binary nor memory representation of closures – the only time escapingness ever leaks into the binary is when you use withoutActuallyEscaping and the compiler inserts the special runtime assertion). ↩︎

2 thoughts on “Calling Swift Concurrency async code synchronously in Swift”

  1. Thank you for taking the time to write up the deeper explanations and quirks — that turned this read from “cool & useful” into “awesomely informative!”

    Reply

Leave a Comment