How can I wait for an async function from synchronous function in Swift 5.5?
Asked Answered
H

4

33

When conforming to protocols or overriding superclass methods, you may not be able to change a method to be async, but you may still want to call some async code. For example, as I am rewriting a program to be written in terms of Swift's new structured concurrency, I would like to call some async set-up code at the beginning of my test suite by overriding the class func setUp() defined on XCTestCase. I want my set-up code to complete before any of the tests run, so using Task.detached or async { ... } is inappropriate.

Initially, I wrote a solution like this:

final class MyTests: XCTestCase {
    override class func setUp() {
        super.setUp()
        unsafeWaitFor {
            try! await doSomeSetup()
        }
    }
}

func unsafeWaitFor(_ f: @escaping () async -> ()) {
    let sema = DispatchSemaphore(value: 0)
    async {
        await f()
        sema.signal()
    }
    sema.wait()
}

This seems to work well enough. However, in Swift concurrency: Behind the scenes, runtime engineer Rokhini Prabhu states that

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... This violates the runtime contract of forward progress for threads.

She also includes a code snippet of such an unsafe code pattern

func updateDatabase(_ asyncUpdateDatabase: @Sendable @escaping () async -> Void) {
    let semaphore = DispatchSemaphore(value: 0)

    async {
        await asyncUpdateDatabase()
        semaphore.signal()
    }

    semaphore.wait()

}

which is notably the exact pattern I had come up with (I find it very amusing that the code I came up with is exactly the canonical incorrect code modulo renaming).

Unfortunately, I have not been able to find any other way to wait for async code to complete from a synchronous function. Further, I have not found any way whatsoever to get the return value of an async function in a synchronous function. The only solutions I have been able to find for this on the internet seem just as incorrect as mine, for example this The Swift Dev article says that

In order to call an async method inside a sync method, you have to use the new detach function and you still have to wait for the async functions to complete using the dispatch APIs.

which I believe to be incorrect or at least unsafe.

What is a correct, safe way to wait for an async function from a synchronous function to work with existing synchronous class or protocol requirements, unspecific to testing or XCTest? Alternatively, where can I find documentation spelling out the interactions between async/await in Swift and existing synchronization primitives like DispatchSemaphore? Are they never safe, or can I use them in special circumstances?

Update:

As per @TallChuck's answer which noticed that setUp() always runs on the main thread, I have discovered that I can intentionally deadlock my program by calling any @MainActor function. This is excellent evidence that my workaround should be replaced ASAP.

Explicitly, here is a test which hangs.

import XCTest
@testable import Test

final class TestTests: XCTestCase {
    func testExample() throws {}
    
    override class func setUp() {
        super.setUp()
        unsafeWaitFor {
            try! await doSomeSetup()
        }
    }
}

func doSomeSetup() async throws {
    print("Starting setup...")
    await doSomeSubWork()
    print("Finished setup!")
}

@MainActor
func doSomeSubWork() {
    print("Doing work...")
}

func unsafeWaitFor(_ f: @escaping () async -> ()) {
    let sema = DispatchSemaphore(value: 0)
    async {
        await f()
        sema.signal()
    }
    sema.wait()
}

However, it does not hang if @MainActor is commented out. One of my fears is that if I ever call out to library code (Apple's or otherwise), there is no way to know if it will eventually call an @MainActor function even if the function itself is not marked @MainActor.

My second fear is that even if there is no @MainActor, I still don't know I am guaranteed that this is safe. On my computer, this hangs.

import XCTest
@testable import Test

final class TestTests: XCTestCase {
    func testExample() throws {}
    
    override class func setUp() {
        super.setUp()
        unsafeWaitFor {
            unsafeWaitFor {
                unsafeWaitFor {
                    unsafeWaitFor {
                        unsafeWaitFor {
                            unsafeWaitFor {
                                print("Hello")
                            }
                        }
                    }
                }
            }
        }
    }
}
func unsafeWaitFor(_ f: @escaping () async -> ()) {
    let sema = DispatchSemaphore(value: 0)
    async {
        await f()
        sema.signal()
    }
    sema.wait()
}

If this doesn't hang for you, try adding more unsafeWaitFors. My development VM has 5 cores, and this is 6 unsafeWaitFors. 5 works fine for me. This is distinctly unlike GCD. Here is an equivalent in GCD which does not hang on my machine.

final class TestTests: XCTestCase {
    func testExample() throws {}
    
    override class func setUp() {
        super.setUp()
        safeWaitFor { callback in
            safeWaitFor { callback in
                safeWaitFor { callback in
                    safeWaitFor { callback in
                        safeWaitFor { callback in
                            safeWaitFor { callback in
                                print("Hello")
                                callback()
                            }
                            callback()
                        }
                        callback()
                    }
                    callback()
                }
                callback()
            }
            callback()
        }
    }
}
func safeWaitFor(_ f: @escaping (() -> ()) -> ()) {
    let sema = DispatchSemaphore(value: 0)
    DispatchQueue(label: UUID().uuidString).async {
        f({ sema.signal() })
    }
    sema.wait()
}

This is fine because GCD is happy to spawn more threads than you have CPUs. So maybe the advice is "only use as many unsafeWaitFors as you have CPUs", but if that's the case, I would like to see somewhere that Apple has spelled this out explicitly. In a more complex program, can I actually be sure that my code has access to all the cores on the machine, or is it possible that some other part of my program is using the other cores and thus that the work requested by unsafeWaitFor will never be scheduled?

Of course, the example in my question is about tests, and so in that case, it is easy to say "it doesn't really matter what the advice is: if it works, it works, and if it doesn't, the test fails, and you'll fix it," but my question isn't just about tests; that was just an example.

With GCD, I have felt confident in my ability to synchronize asynchronous code with semaphores (on my own DispatchQueues that I control, and not the main thread) without exhausting the total available threads. I would like be able to synchronize async code from a synchronous function with async/await in Swift 5.5.

If something like this is not possible, I would also accept documentation from Apple spelling out in exactly what cases I can safely use unsafeWaitFor or similar synchronization techniques.

Horne answered 11/6, 2021 at 20:31 Comment(11)
How would we have done this before async/await? We couldn't have. Without async/await, we have never been able to wait, and we still can't. If we do async work during setUp, setUp will end.Network
@Network We (or at least I) use the DispatchSemaphore method above, but with functions which take callbacks instead of with an async function. With concurrency based on DispatchQueue, this is okay because if a queue blocks, GCD can spawn more threads to do work so that the blocked thread may be able to resume in the future. Swift’s built-in executor will not spawn new threads (at least not because of this), so it is easy for the DispatchSemaphore method to deadlock with async functions, at least in theory. My set-up code is simple enough that I haven’t hit a deadlock yet.Horne
In the "Meet async/await in Swift" session they pointed out that "XCTest supports async out of the box" (timestamp 21:20), but it doesn't look like that includes setUp().Extracellular
True. I've been using async for all my tests, and that's been working great. I am pretty sure switching an existing method to async is ABI and source-breaking, so I don't really know how Apple will go about fixing setUp. Hopefully there will be a safe work-around soon.Horne
Why not keep doing what you were doing, unchanged? I don't approve of it, but hey, if you were happy with it, fine; no law requires that all your code migrate away from GCD etc.Network
Haha, true! That is my plan actually, I've been hitting too many walls, edge-cases, bugs, etc. trying to migrate my code over (I am sure you can relate). The principle of "I really want to get out of callback-hell" motivates me to switch to async/await though.Horne
@Network Also out of curiosity, why don't you approve of what I was doing? Is there a bug or problem you think it might cause? That could be useful to know later on, in case I eventually hit it.Horne
Well you're "waiting" by blocking the main thread, which is illegal. On the other hand you could reply "Who cares? It's only a test, not the real app. And expectations do the same sort of thing."Network
Good point, I guess my GCD solution was already unsafe to use from the main thread. And yeah I definitely don't do this in the app. I appreciate your feedback. :)Horne
who says blocking the main thread is illegal? That may be true in the world of mobile apps, but unit testing is a run-to-completion environment. In every such environment that I'm aware of, it's perfectly acceptable for the main thread to join() on (i.e. block waiting for) the worker threadsExtracellular
I ran into some deadlocking issues trying this, and making the unsafeWaitFor block argument @Sendable fixed it. If anyone knows why please share!Reta
E
6

You could maybe argue that asynchronous code doesn't belong in setUp(), but it seems to me that to do so would be to conflate synchronicity with sequential...icity? The point of setUp() is to run before anything else begins running, but that doesn't mean it has to be written synchronously, only that everything else needs to view it as a dependency.

Fortunately, Swift 5.5 introduces a new way of handling dependencies between blocks of code. It's called the await keyword (maybe you've heard of it). The most confusing thing about async/await (in my opinion) is the double-sided chicken-and-egg problem it creates, which is not really addressed very well in any materials I've been able to find. On the one hand, you can only run asynchronous code (i.e. use await) from within code that is already asynchronous, and on the other hand, asynchronous code seems to be defined as anything that uses await (i.e. runs other asynchronous code).

At the lowest level, there must eventually be an async function that actually does something asynchronous. Conceptually, it probably looks something like this (note that, though written in the form of Swift code, this is strictly pseudocode):

func read(from socket: NonBlockingSocket) async -> Data {
    while !socket.readable {
        yieldToScheduler()
    }

    return socket.read()
}

In other words, contrary to the chicken-and-egg definition, this asynchronous function is not defined by the use of an await statement. It will loop until data is available, but it allows itself to be preempted while it waits.

At the highest level, we need to be able to spin up asynchronous code without waiting for it to terminate. Every system begins as a single thread, and must go through some kind of bootstrapping process to spawn any necessary worker threads. In most applications, whether on a desktop, smart phone, web server, or what have you, the main thread then enters some kind of "infinite" loop where it, maybe, handles user events, or listens for incoming network connections, and then interacts with the workers in an appropriate way. In some situations, however, a program is meant to run to completion, meaning that the main thread needs to oversee the successful completion of each worker. With traditional threads, such as the POSIX pthread library, the main thread calls pthread_join() for a certain thread, which will not return until that thread terminates. With Swift concurrency you..... can't do anything like that (as far as I know).

The structured concurrency proposal allows top-level code to call async functions, either by direct use of the await keyword, or by marking a class with @main, and defining a static func main() async member function. In both cases, this seems to imply that the runtime creates a "main" thread, spins up your top-level code as a worker, and then calls some sort of join() function to wait for it to finish.

As demonstrated in your code snippet, Swift does provide some standard library functions that allow synchronous code to create Tasks. Tasks are the building block of the Swift concurrency model. The WWDC presentation you cited explains that the runtime is intended to create exactly as many worker threads as there are CPU cores. Later, however, they show the below image, and explain that a context switch is required any time the main thread needs to run.

enter image description here

As I understand it, the mapping of threads to CPU cores only applies to the "Cooperative thread pool", meaning that if your CPU has 4 cores, there will actually be 5 threads total. The main thread is meant to remain mostly blocked, so the only context switches will be the rare occasions when the main thread wakes up.

It is important to understand that under this task-based model, it is the runtime, not the operating system, that controls "continuation" switches (not the same as context switches). Semaphores, on the other hand, operate at the operating system level, and are not visible to the runtime. If you try to use semaphores to communicate between two tasks, it can cause the operating system to block one of your threads. Since the runtime cannot track this, it will not spin up a new thread to take its place, so you will end up under-utilized at best, and deadlocked at worst.

Okay, finally, in Meet async/await in Swift, it is explained that the XCTest library can run asynchronous code "out of the box". However, it is not clear whether this applies to setUp(), or only to the individual test case functions. If it turns out that it does support an asynchronous setUp() function, then your question is suddenly entirely uninteresting. On the other hand, if it does not support it, then you are stuck in the position that you cannot directly wait on your async function, but that it's also not good enough just to spin up an unstructured Task (i.e. a task that you fire and forget).

Your solution (which I see as a workaround -- the proper solution would be for XCTest to support an async setUp()), blocks only the main thread, and should therefore be safe to use.

Extracellular answered 12/6, 2021 at 20:13 Comment(10)
This is a good write-up about the utility of async/await in Swift. This will be useful to people who may find this question later. I am still not satisfied though. How do you know that my workaround is safe because it just blocks the main thread? In fact, doesn't that specifically make it the opposite? If my set-up code decides to call any @MainActor method, it deadlocks. And there's no way to know at compile-time whether an async method you call will eventually call some @MainActor method, so I can't know at compile-time if this is safe.Horne
Don't confuse the main thread of your application with the main thread of the test code. The code that would normally run on the main thread of your application will be running on a worker thread within the context of the test. Since the only references to that semaphore are in the setUp function, nothing else can signal it at the wrong time, and nothing else can get stuck waiting on it when it shouldn't. It's not being used to synchronize multiple tasks, it's a one-time use, guaranteed to run, wake-up callExtracellular
I just now read your edit. I don't understand how the @MainActor would ever come into play, but if that makes you nervous then I suppose I have no answer for youExtracellular
I just updated my question again. Sorry for being so verbose, but I want to be clear with what I am concerned about. Do I really know that if the work requested by unsafeWaitFor does not ever call any @MainActor methods, it will be eventually scheduled and terminate, or is it possible that Swift's other threads are doing something else and will not schedule the work requested by unsafeWaitFor until the main thread is free? With GCD, we had this guarantee, but with async/await, I'm not so sure.Horne
this got ridiculous real fast. As soon as you nested unsafeWaitFor inside its own callback (which made it cross over between multiple asynchronous worker threads), you entered the unsafe zone, which is exactly why Rokhini said semaphores are unsafe with Swift concurrency. As for calling a MainActor function in your test setup, it's still not clear to me how that would ever happen. My instinct is that it would only happen if there was a design flaw, and the hangup would only occur in testing... which is the point of testing, but I don't understand it enough to have a strong opinionExtracellular
I think you're right about the @MainActor thing. Because this is still a possible problem with my old GCD setup (but never one I experienced), I think I am going to run some tests with the main thread intentionally blocked so I can make sure my code doesn't ever need the main thread, even for system stuff. I believe a similar problem will manifest if you call unsafeWaitFor from an actor and request work to be done on that actor, so you could also use tests like this to make sure that you don't need to actor to do some particular work.Horne
I don't think nesting unsafeWaitFor is ridiculous though. If unsafeWaitFor is used to implement a class/protocol requirement which is called recursively or concurrently, multiple unsafeWaitFors could be in-flight simultaneously, which could block all of Swift's threads. A sane example of this could be implementing Encodable or Decodable (both of which are synchronous right now) or a similar third-party protocol. It is very reasonable for these to be recursive, or to encode or decode from multiple threads for performance reasons. I want to know the exact limits.Horne
the only reason I said the use of the semaphore was safe was because it wasn't being used to block the worker threads. That's the exact limitExtracellular
I bet you're right, but I don't think we know this for certain. As far as I can tell, Apple has stated that the runtime contract is that all threads make forward progress, not "there is at least one thread making forward progress" or "at most one thread isn't making forward progress."Horne
The contract certainly cannot be "all threads make forwards progress", since lots of system frameworks will block their internal threads. My guess is that it's "any thread that Swift Concurrency will schedule tasks on" which is their internal thread pool, but also the main thread because of @MainActor. Which I guess doesn't help you, and Apple should really clarify this, but that's my read of it at least.Rochelrochell
T
3

To wait for your async setting up function you can simply use XCTestCase.expectation() with XCTestCase.wait() in your setUp method:

func doSettingUp() async {
    print("Start setting up... (wait 3 secs)")
    Thread.sleep(forTimeInterval: 3)
    print("Finish setting up")
}

class TestTests: XCTestCase {
    
    override func setUp() {
        print("setUp")
        
        let exp = expectation(description: "Test")
        Task {
            await doSettingUp()
            exp.fulfill()
        }
        wait(for: [exp], timeout: 10)
    }

    override func tearDown() {
        print("tearDown")
    }

    func test_Test() {
        print("test_Test")
    }
}

Outputs:

setUp
Start setting up... (wait 3 secs)
Finish setting up
test_Test
tearDown
Tessietessier answered 3/2, 2022 at 10:26 Comment(0)
I
0

I was running into this issue for Setup in XCTestCases as well. Specifically Login for my API.

The solution I came to was to split My Login Process into separate XCTestCases. They are run in alphabetical order. So I set up the the Next Test Case with a test in the Test Case above.

something like this:

  1. NetworkLogin (Login Username / Password)
  2. NetworkLoginRefresh (Refresh Token)
  3. NetworkTests (All API Calls besides Authentication Stuff)
  4. NetworkXLogout
Idealist answered 23/1, 2022 at 8:16 Comment(0)
N
-2

You can call

_runAsyncMain { *async stuff here* }

To run async functions top-level.

Nial answered 21/12, 2021 at 19:10 Comment(1)
As it’s currently written, your answer is unclear. Please edit to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers in the help center.Hugo

© 2022 - 2024 — McMap. All rights reserved.