Bridging async > sync code in swift concurrency

To bridge sync code to async code we have Task { .. }
The other way around is needed as well to bridge legacy systems.

I don't see anything like this available, unless I'm missing something
I'm thinking something like this

func legacyCode() {
   let someResultFromAsyncWorld = BlockingTask { // new async code here }
   // old sync code here
}

Kotlin's equivalent is runBlocking { // async context here }

Would a simple semaphore be sufficient?

func BlockingTask<T>(_ body: @escaping () async throws -> T) throws -> T {
    let semaphore = DispatchSemaphore(value: 0)
    let box = Box<T>()
    Task {
        do {
            let result = try await body()
            box.result = .success(result)
        } catch {
            box.result = .failure(error)
        }
        semaphore.signal()
    }
    semaphore.wait()
    return try box.result!.get()
}

private final class Box<T> {
    var result: Result<T, Error>?
}

I tried the semaphore route & it worked…until it didn’t. It seems like Swift Concurrency creates only one thread per system processor core and once you have every core blocked waiting for your async work, your app is deadlocked.

what do you mean every core blocked; cores cannot be blocked, I can have 1000 threads if I want to, even though inefficient

The cooperative thread pool has many threads as there are cores. Ideally, they kernel-mode context switching of the threads is minimal, and all the work is done by user-mode context switching of Tasks, which is much much much faster.

For that to work though, it's imperative that your async code never blocks. If you need to call legacy code that blocks, then you should spin that off in a separate thread using all the existing techniques (e.g. a dispatch queue), and using a continuation to signal the completion of its work.

I'd highly recommend Swift concurrency: Behind the scenes from WWDC 2021

3 Likes

I don't follow. This is to bridge some legacy code, not a modus operandi for new async code.

@christopherweems said it will deadlock, which will only be the case if BlockingTask is called from a thread within that cooperative thread pool; which is not the intended usage.

How do you intend to enforce this?

Also, isn't that Task initializer creating a new task which will always use the cooperative thread pool? It's been a while and IDR when that thread pool is or isn't used, I need to re-watch that video xD

You can't enforce it, but that's okay for this case. Same way you cannot enforce people to always call semaphore.signal()

If you need to call legacy code that blocks..

This is for the other way around - when your old legacy blocking code wants to call new async stuff

If you know that you’ll never call your bridging code from the cooperative pool & mark it with @available(*, noasync) I think the approach would work.

1 Like

Doesn't compile for me, am I doing it wrong?

This is the feature I’m referencing, it’s only a marker to prevent you from calling it from an async context but it’s new to 5.7. You can leave it off assuming you’re already doing that checking yourself.

@vlastobrecka I tried the same semaphore approach and it did work but now I've seen that on the WWDC session (suggested by @AlexanderM) Swift concurrency: Behind the scenes [check 0h26m] they say that semaphores are unsafe to use with Swift Concurrency.

Screenshot 2023-05-21 at 20.01.21

Semaphores or semaphore-like behavior will cause some really heinous problems; as mentioned before they can lead to deadlocks due to resource exhaustion but also they lead to priority inversions. Can they work in very limited cases? Sure... but I wouldn't suggest they be used in production code interfacing with async/await.

Instead it would be better to emit work items to a singular task running those work items. For example you can yield into a continuation of an AsyncStream the async closure of work to be done. But that works only in cases where the result of that work is Void. The other approach is to use callbacks; run the async work such that it calls back into the synchronous world with the value needed.

Neither of those strategies is perfect but they are by far more performant and better long term solutions than semaphores. I would highly suggest for any real world usage to attempt either using AsyncStreams to emit values into the async code, or use callbacks to indicate values to be used by the synchronous code. Structuring that way will keep you on a much easier path that will be better for the end users of the software.

You can implement a safe-to-use semaphore by using continuations. See the discussions here.