3
\$\begingroup\$

While migrating to structured concurrency it may be sometimes convenient to temporarily make a blocking call to an async function - e.g.:

let x: Int = try blocking {
    try await Task.sleep(nanoseconds: 1)
    return 5
}

print(x)

I have this implemented like so at the moment, but I’m not in love with it:

@available(*, deprecated, message: "For use only as a last resort or to allow incremental migration to structured concurrency")
public func blocking<A>(_ ƒ: @escaping () async -> A) -> A {
    let semaphore = DispatchSemaphore(value: 0)
    let got = Got<A>()
    Task(priority: .high) {
        got.a = await ƒ()
        semaphore.signal()
    }
    semaphore.wait()
    return got.a!
}

And a throwing variant:

@available(*, deprecated, message: "For use only as a last resort or to allow incremental migration to structured concurrency")
public func blocking<A>(_ ƒ: @escaping () async throws -> A) throws -> A {
    let semaphore = DispatchSemaphore(value: 0)
    let got = Got<Result<A, Error>>()
    Task(priority: .high) {
        do { got.a = try await .success(ƒ()) }
        catch { got.a = .failure(error) }
        semaphore.signal()
    }
    semaphore.wait()
    return try got.a!.get()
}

Where Got is just a simple class to hold a reference to the result:

private class Got<A> {
    var a: A?
}

Having reviewed the concurrency module of the Standard Library, I now believe that there is no idiomatic way of expressing, "I'm ok to block my synchronous function while awaiting for this particular async call".

\$\endgroup\$
2
\$\begingroup\$

I know that you were contemplating this as merely a temporary step in the process of the migration, but I would discourage the use of this pattern at all. Semaphores are unsafe to use with Swift concurrency.

In WWDC 2021 video Swift concurrency: Behind the scenes, Apple discusses the need to “preserve the runtime contract” and ensure “forward progress”, and officially discourages the use of semaphores:

On the other hand, primitives like semaphores and condition variables are unsafe to use with Swift concurrency. This is because they hide dependency information from the Swift runtime, but introduce a dependency in execution in your code.

That is an illuminating video and I might suggest watching it in its entirety.


Regarding whether there is a “trick” to accomplish this with the existing API, I am afraid that the answer is that you are best advised to simply refactor the code such that this pattern is not needed. If you have some code where you were inclined to use the above, I might advise posting that as a separate question, and we might be able to help you solve that problem without resorting to semaphores.

\$\endgroup\$
5
  • \$\begingroup\$ Excellent response @Rob - many thanks for taking the time to reflect on this. I'll upvote this for now and accept it if no "trick" is found (e.g. one that does not use GCD at all). Indeed, I have myself already seen dispatch group behaving suspiciously around concurrency idioms... \$\endgroup\$
    – Milos
    Jan 14 at 9:40
  • \$\begingroup\$ As for the usefulness of this question, I'm not quite persuaded yet that the question should not even be posed. Symmetry is an important value to strive for in computing and so one would hope that in this case too, we can go from sync to async and back, even if that may not be advisable in the general case. \$\endgroup\$
    – Milos
    Jan 14 at 10:14
  • \$\begingroup\$ Regarding Apple's comment, "primitives like semaphores and condition variables are unsafe to use with Swift concurrency", it would be interesting to better understand what notion of safety is meant here and how would that apply to the proposed code. For example, I'm as yet unable to construct a test that would break it. Can you? \$\endgroup\$
    – Milos
    Jan 14 at 10:42
  • \$\begingroup\$ I completely disagree with the argument that one should need to be able to make asynchronous methods behave synchronously for the sake of “symmetry”. We go from synchronous to asynchronous for a reason, namely that something takes a little time and we don't want to block a thread or UI. When you have something that is asynchronous, it is asynchronous for a reason. Making async code behave synchronously is going backwards, reintroducing the problem that async solves! Besides, the motivating idea of async-await is to get asynchronous behaviors in code that reads in a logical, sequential manner. \$\endgroup\$
    – Rob
    Jan 14 at 17:54
  • 2
    \$\begingroup\$ Oh, I don't believe that we really do disagree all that much. Perhaps only in as much that I find this question interesting and that thinking about it carefully only offers the opportunity to appreciate Swift more deeply. \$\endgroup\$
    – Milos
    Jan 14 at 18:41
0
\$\begingroup\$

The proposed approach hangs both on iOS and macOS (just not in unit tests executed in parallel), which is fascinating in its own right. See inline comments:

@main
struct BlockerApp: App {
    
    @State var count = 0
    
    var body: some Scene {
        WindowGroup {
            VStack {
                
                Text("\(count)")
                
                Button("Increment") { // tapping on the button hangs the app
                    count += blocking { 1 } // the closure is never called
                }
            }
        }
    }
}

func blocking<A>(_ ƒ: @escaping () async -> A) -> A {
    let semafore = DispatchSemaphore(value: 0)
    let got = Got<A>()
    Task.detached(priority: .high) {
        got.a = await ƒ() // gets as far as this line but ƒ is never called
        semafore.signal()
    }
    semafore.wait()
    return got.a!
}

private class Got<A> {
    var a: A?
}

However, if we require that the closure is executed in another context, for example by annotating the parameter with a custom global actor, then this solution works on both macOS and iOS:

@globalActor public actor BlockingActor {
    public static let shared = BlockingActor()
}

@available(*, deprecated, message: "For use only to allow incremental migration to structured concurrency")
public func blocking<A>(_ ƒ: @BlockingActor @escaping () async -> A) -> A {
    let semaphore = DispatchSemaphore(value: 0)
    let got = Got<A>()
    Task(priority: .high) {
        got.a = await ƒ()
        semaphore.signal()
    }
    semaphore.wait()
    return got.a!
}

Alas, a nested call would still cause a hang:

count += blocking {
    blocking { // moving to the same executor, so we hang as before
        1
    }
}

This, of course, is just a particular example of the fundamental reason that the use of any such API must be discouraged. Note, however, that we do already have quite a few existing APIs that are similarly discouraged, yet do exist for good reason.

A particularly relevant example is the sync method of DispatchQueue, which will cause a hang in exactly the same way if you use it to schedule a task to wait for itself. In spite of this, sync method has been put to many a good use over the years.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.