39

I'm bridging the sync/async worlds in Swift and doing incremental adoption of async/await. I'm trying to invoke an async function that returns a value from a non async function. I understand that explicit use of Task is the way to do that, as described, for instance, here.

The example doesn't really fit as that task doesn't return a value.

After much searching, I haven't been able to find any description of what I'd think was a pretty common ask: synchronous invocation of an asynchronous task (and yes, I understand that that can freeze up the main thread).

What I theoretically would like to write in my synchronous function is this:

let x = Task {
  return await someAsyncFunction()
}.result

However, when I try to do that, I get this compiler error due to trying to access result:

'async' property access in a function that does not support concurrency

One alternative I found was something like:

Task.init {
  self.myResult = await someAsyncFunction()
}

where myResult has to be attributed as a @State member variable.

However, that doesn't work the way I want it to, because there's no guarantee of completing that task prior to Task.init() completing and moving onto the next statement. So how can I wait synchronously for that Task to be complete?

gds
  • 578
  • 1
  • 4
  • 7
  • 9
    I understand the request, but if you forgive me for saying this, I'd advise against this pattern altogether. If you've got existing asynchronous API, introducing a synchronous pattern during the transition is a step backward. There are undoubtedly better transition strategies. Maybe you can edit the above w/ simplified example of what your current code is doing this and we can probably offer better approaches. FWIW, WWDC video [Swift concurrency: Update a sample app](https://developer.apple.com/videos/play/wwdc2021/10194/) shows great techniques for incremental transition of existing codebase. – Rob Feb 02 '22 at 20:51
  • 2
    "because there's no guarantee of completing that task prior to Task.init() completing and moving onto the next statement" -- why not include the next statement *within* the `Task.init` closure if this is what you're looking for? That being said, I think the above advice is good. – jnpdx Feb 02 '22 at 20:58
  • @Rob - thanks the comments. I generally agree about the issue of introducing synchronous behavior. *However*, I still am interested in how in the concurrency framework one would do as I describe. If for no other reason than getting a better understanding of the system. It's definitely a missing component in terms of being able to complete the entire loop between synchronous and asynchronous if this can't readily be done. – gds Feb 03 '22 at 00:45
  • 7
    I'd suggest watching [Swift concurrency: Behind the scenes](https://developer.apple.com/videos/play/wwdc2021/10254/?time=1213) which introduces a central precept of the Swift Concurrency system: “This means that code written with Swift concurrency can maintain a runtime contract that threads are always able to make forward progress.” But you ask how to prevent forward progress and block a thread, which is antithetical to this core design principle. The goal of the new concurrency system is to allow us to write code that mirrors traditional synchronous patterns, but entirely asynchronously. – Rob Feb 03 '22 at 02:53
  • 2
    This is the most common question in regards to structured concurrency and the short answer is that you can’t, and if you could, it would defeat the purpose of SC. You can cheat the system with Combine https://stackoverflow.com/a/70350527/412916, https://www.swiftbysundell.com/articles/calling-async-functions-within-a-combine-pipeline/ which is probably a bad idea. – Jano Feb 03 '22 at 11:02
  • Why can't you do something like `while self.result == nil { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1))`? – Taylor Dec 28 '22 at 00:04
  • I agree with the whole "don't fight the framework" mantra, and you shouldn't in general. However, the makers of frameworks can make unrealistic ivory-tower decisions. Obviously the creators of Kotlin coroutines saw the need for something like this, and they provided [`runBlocking`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-blocking.html). – funct7 Aug 22 '23 at 00:19

4 Answers4

24

You should not wait synchronously for an async task.

One may come up with a solution similar to this:

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

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

    semaphore.wait()
}

Although it works in some simple conditions, according to WWDC 2021 Swift Concurrency: Behind the scenes, this is unsafe. The reason is the system expects you to conform to a runtime contract. The contract requires that

Threads are always able to make forward progress.

That means threads are never blocking. When an asynchronous function reaches a suspension point (e.g. an await expression), the function can be suspended, but the thread does not block, it can do other works. Based on this contract, the new cooperative thread pool is able to only spawn as many threads as there are CPU cores, avoiding excessive thread context switches. This contract is also the key reason why actors won't cause deadlocks.

The above semaphore pattern violates this contract. The semaphore.wait() function blocks the thread. This can cause problems. For example

func testGroup() {
    Task {
        await withTaskGroup(of: Void.self) { group in
            for _ in 0 ..< 100 {
                group.addTask {
                    syncFunc()
                }
            }
        }
        NSLog("Complete")
    }
}

func syncFunc() {
    let semaphore = DispatchSemaphore(value: 0)
    Task {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        semaphore.signal()
    }
    semaphore.wait()
}

Here we add 100 concurrent child tasks in the testGroup function, unfortunately the task group will never complete. In my Mac, the system spawns 4 cooperative threads, adding only 4 child tasks is enough to block all 4 threads indefinitely. Because after all 4 threads are blocked by the wait function, there is no more thread available to execute the inner task that signals the semaphore.

Another example of unsafe use is actor deadlock:

func testActor() {
    Task {
        let d = Database()
        await d.updateSettings()
        NSLog("Complete")
    }
}

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

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

    semaphore.wait()
}

actor Database {
    func updateSettings() {
        updateDatabase {
            await self.updateUser()
        }
    }

    func updateUser() {

    }
}

Here calling the updateSettings function will deadlock. Because it waits synchronously for the updateUser function, while the updateUser function is isolated to the same actor, so it waits for updateSettings to complete first.

The above two examples use DispatchSemaphore. Using NSCondition in a similar way is unsafe for the same reason. Basically waiting synchronously means blocking the current thread. Avoid this pattern unless you only want a temporary solution and fully understand the risks.

Cosyn
  • 4,404
  • 1
  • 33
  • 26
  • 2
    You wrote this is dangerous because "the system expects you to conform to a runtime contract". Interestingly, I am converting old code with old synchronous api that previously called synchronous Apple API with the newer method that Apple now has. In that case I have no choice and according to you I am actually honoring the contract. My old code is used extensively without errors and during a transition to a new architecture we need to use exactly this: calling async in a blocking way. – Andreas Pardeike May 21 '22 at 18:42
  • 2
    I used this semaphore strategy to maintain an old API that was now using a new async based API internally. In my unit tests, the Task never fired and the semaphore ended up waiting forever. But by setting the priority to anything other than .default or .medium (which are the same thing) the unit test succeeded. My only explanation is that in the testing environment, the .medium priority attempts to use the same thread as the test is running on, which is waiting for the semaphore, but other priorities use different threads. This may vary in different environments, showing how unsafe this is. – Richard Venable Jun 30 '22 at 18:53
  • For those looking for references as to why semaphores are unsafe to use with Swift concurrency, see 25:58 into WWDC 2021’s [Swift concurrency: Behind the scenes](https://developer.apple.com/videos/play/wwdc2021/10254?time=1558). – Rob Jun 09 '23 at 12:23
  • I used the Semaphore to modernise `NSFileCoordinator().coordinate` with async/await but I wrapped it in `withTaskCancellationHandler` so the `Task` supports cancellation. – malhal Jun 28 '23 at 14:20
5

Other than using semaphore, you can wrap your asynchronous task inside an operation like here. You can signal the operation finish once the underlying async task finishes and wait for operation completion using waitUntilFinished():

let op = TaskOperation {
   try await Task.sleep(nanoseconds: 1_000_000_000)
}
op.waitUntilFinished()

Note that using semaphore.wait() or op.waitUntilFinished() blocks the current thread and blocking the thread can cause undefined runtime behaviors in modern concurrency as modern concurrency assumes all threads are always making forward progress. If you are planning to use this method only in contexts where you are not using modern concurrency, with Swift 5.7 you can provide attribute mark method unavailable in asynchronous context:

@available(*, noasync, message: "this method blocks thread use the async version instead")
func yourBlockingFunc() {
    // do work that can block thread
}

By using this attribute you can only invoke this method from a non-async context. But some caution is needed as you can invoke non-async methods that call this method from an async context if that method doesn't specify noasync availability.

Soumya Mahunt
  • 2,148
  • 12
  • 30
  • 2
    I'm fairly certain that this linked library is doing exactly what Apple/the Swift team say not to do, albeit in a more indirect way. Tho it appears you're aware of that, I'd be highly cautious of using this kind of code that tries to bend the new async/await paradigm to fit the more traditional async paradigms. – mredig Sep 23 '22 at 22:56
  • Why wouldn’t op.waitUntilFinished() block the thread? If it doesn’t then it must do the same as an await, without saying await in front of it which makes it kind of pointless for this use case as othe riginal block of code wouldn’t really be synchronous. waitUntilFinished() docs say this: “Blocks execution of the current thread until the operation object finishes its task.” See my answer for a proper solution to what was problably the original probem. – oxygen Jun 29 '23 at 21:16
1

I wrote simple functions that can run asynchronous code as synchronous similar as Kotlin does, you can see code here. It's only for test purposes, though. DO NOT USE IT IN PRODUCTION as async code must be run only asynchronous

Example:

let result = runBlocking {
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    return "Some result"
}
print(result) // prints "Some result"
sainecy
  • 39
  • 3
-9

I've been wondering about this too. How can you start a Task (or several) and wait for them to be done in your main thread, for example? This may be C++ like thinking but there must be a way to do it in Swift as well. For better or worse, I came up with using a global variable to check if the work is done:

import Foundation

var isDone = false

func printIt() async {
    try! await Task.sleep(nanoseconds: 200000000)
    print("hello world")
    isDone = true
}

Task {
    await printIt()
}

while !isDone {
    Thread.sleep(forTimeInterval: 0.1)
}
  • 5
    Don’t do this. Busy waiting like this makes no sense, and kills battery and performance. Waiting synchronously on an async task is a bad idea in any case. But if you really need to do that, and you probably don’t, you could at least use a semaphore or dispatch group, where the waiting is done by the kernel, without a senseless looping burning energy – Alexander Apr 17 '22 at 12:31
  • 3
    *For better or worse…*. worse, even **worst**. Never do that. – vadian Apr 17 '22 at 12:31
  • 1
    Was just a toy example to simulate doing something in the main thread while waiting on the worker tasks to finish. Anyway, it wouldn't work with more than one worker task anyway. Thanks for the pointer towards DispatchGroup. I think I found a way to do it by creating a DispatchGroup, calling dispatchGroup.enter() right before kicking off each Task, calling dispatchGroup.leave() at the end of the async function and then calling dispatchGroup.wait() in the synchronous thread (which waits for all tasks to finish). – omegahelix Apr 17 '22 at 17:10
  • 1
    Never, ever `sleep()` on the main thread. Use the run loop functions if you need to wait until some event happens. – Cristik Sep 10 '22 at 08:02