I'm not new to Swift concurrency but I'm still having trouble fully understanding some parts, especially the sending keyword usage.
Please consider the following function which aims to return synchronously the result of an asynchronous closure. I'm aware this is not a good practice, but that's really not the point, it's for the sake of understanding why it doesn't compile.
func runSynchronously<T>(_ execute: sending @escaping () async -> sending T) -> sending T {
nonisolated(unsafe) var result: T?
let semaphore = DispatchSemaphore(value: 0)
Task { // Compilation error : Sending value of non-Sendable type '() async -> ()' risks causing data races
result = await execute()
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
return result!
}
If I add a constraint to make T sendable then the compiler doesn't complain.
public func runSynchronously<T: Sendable>(_ execute: sending @escaping () async -> T) -> T {
var result: T?
let semaphore = DispatchSemaphore(value: 0)
Task {
result = await execute()
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
return result!
}
Can somebody please explain why I can't loosen the restriction of having T: Sendable to simply sending T ?
The compiler is emitting a very useful diagnostic note:
func runSynchronously<T>(_ execute: sending @escaping () async -> sending T) -> sending T {
nonisolated(unsafe) var result: T? // ℹ️ NOTE: - Access can happen concurrently
let semaphore = DispatchSemaphore(value: 0)
Task { // ❌ Sending value of non-Sendable type '() async -> ()' risks causing data races
result = await execute() // ℹ️ NOTE: - Access can happen concurrently
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
return result! // ℹ️ NOTE: - Access can happen concurrently
}
The key is that the compiler can't reason about the semaphore. All it's seeing is:
A result variable, which is notSendable, so it isn't safe to be used concurrently.
There's a use of result in the Task.init closure.
It would be possible to use sending to send result to the Task's isolation domain, if there were no later uses of result after sending it.
There's another use of result in the body function, in return result!.
Even if you use sending to send a non-sendable result to the Task.init closure (a different isolation domain), the compiler would enforce that there are no latter uses of result after sending in the current isolation domain. But then you have return result!.
I thinknonisolated(unsafe) should be suppressing that checking though
The notes are useful, however the error message itself is a bit misleading in my opinion. It is not really about the closure being non-Sendable but rather doing something non-Sendable inside the closure, no?
For example if you annotate the closure with @MainActor:
func runSynchronously<T>(_ execute: sending @escaping () async -> sending T) -> sending T {
nonisolated(unsafe) var result: T?
let semaphore = DispatchSemaphore(value: 0)
Task { @MainActor in
result = await execute() // ❌ Sending 'result' risks causing data races
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
return result!
}
Both, I think? You can't use sending to send the () async -> () closure to the Task.init because of the race condition.
I think in any case the problem is the nonisolated(unsafe) for var result: T seems to be ignored by the compiler if defined within the function scope. It works when using a global variable:
Don't do this
nonisolated(unsafe) var result: Any?
// ⚠️ Don't do this! This is very bad!
func runSynchronously<T>(
_ execute: sending @escaping () async -> T
) -> T {
let semaphore = DispatchSemaphore(value: 0)
Task {
result = await execute()
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
return result as! T
}
Or when using a different unsafe opt-out, like @unchecked Sendable:
Don't do this either
final class UnsafeBox<T>: @unchecked Sendable {
var result: T!
}
// ⚠️ Don't do this! This is bad!
func runSynchronously<T>(
_ execute: sending @escaping () async -> T
) -> T {
let semaphore = DispatchSemaphore(value: 0)
var resultBox = UnsafeBox<T>()
Task {
resultBox.result = await execute()
semaphore.signal()
}
_ = semaphore.wait(timeout: .distantFuture)
return resultBox.result
}
If you absolutely need something synchronously, and that thing is coming from an async function, your best solution may be to make the async function synchronous. The specifics will vary depending on your use case.
For example, last time I faced something similar I had some actor protected state that I wanted to access during the app launch sequence (where things couldn't be made async). So what I did is rewrite that bit of state to be protected by a Mutex instead, so I could access it synchronously.
A semaphore just won't work. One fatal flaw (out of many): if both the caller and the Task are on the Main Actor, the code above —even if you get it to compile— will immediately deadlock. The semaphore will be waiting on the main thread, but the Task that would signal that semaphore is also scheduled to run on the main thread, which is blocked.
Not really, you can also deadlock that fairly easily. The closure you pass to f will now be executed in the SyncActor. If that closure somehow ends up invoking f as well (for example, because it calls a function that uses f internally), you end up in the same situation:
func functionThatUsesFInternally() {
// ...
f { ... }
}
f {
functionThatUsesFInternally() // <-- This will deadlock
}
In the inner invokation of f, semaphore.wait is called from an actor (the SyncActor), and the asynchronous block that would signal that semaphore is scheduled in the same actor.
Even in nonisolated contexts, you also have the problem of thread pool exhaustion: there's a limited number of threads in the Swift Concurrency thread pool, and every time you call semaphore.wait from one of those threads, you render one of those threads useless (until the semaphore is signaled). If the block of work that would signal that semaphore is scheduled to run in the shared thread pool, you have to hope that there are still enough threads available so that block can be scheduled.
If there aren't, because all the threads in the thread pool are now stuck in semaphore.wait, you can't schedule any further work anywhere in the app, making it unusable. And it's not difficult to end up in this scenario if you define a runSynchronously function in your app, all it takes is a few calls to that function happening close in time.
I'm sorry I don't understand where the race condition is. The execute closure isn't used anywhere else than in the task or did I misunderstand something ?
I think that basically a a closure that captures a variable that is "involved" in a race condition itself becomes ineligible to be used sending.
We often tend to forget that a closure is not just defined by its signature in this context, but also by the variables it captures (including implicitly captured in this case). result is basically implicitly captured by the closure and since that is not done sending (by definition, as it's inout here), the entire closure cannot be passed sending.
Oh I think I got it, you were referring to the operation closure parameter from Task.init, not the execute closure parameter from runSynchronously function. Sorry for the confusion.
What is happening is that nonisolated(unsafe) is considered to only apply to the actual thing it is applied to (result). The closure is not the same as result of course causing the error to be emitted even though result and closure are in the same region. That being said, I do think it is reasonable to infer that a closure is nonisolated(unsafe) if everything that it captures is nonisolated(unsafe). That is a fix that is on my plate right now.