Understanding sending keyword

Hi everyone,

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 ?

2 Likes

That is... an understatement! :sweat_smile:

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 not Sendable, 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 think nonisolated(unsafe) should be suppressing that checking though :thinking:

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!
}

... you get a more obvious error message

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
}
2 Likes

If making the renegade function async is not be viable, what's the alternative? :slight_smile:

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.

4 Likes

Thank you.

Would this be less controversial?

func f (...) {
    let semaphore = DispatchSemaphore(value: 0)
    Task {@SyncActor in
        ...
        semaphore.signal()
    }
    _ = semaphore.wait (timeout: .distantFuture)
}

@globalActor
private actor SyncActor {
   ...
}

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.

This is covered beautifully in WWDC21's Swift Concurrency: Behind the scenes talk.

2 Likes

Many thanks for your answers !

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.

2 Likes

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.

Thanks for pointing that out.

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.

3 Likes

That's good news, thanks for the future fix !