Does @concurrent functions guarantee temporary exclusive access to the received non-sendable arguments?

When a function is annotated as @concurrent, can it make sure that the function body temporarily has exclusive access to the arguments that are not Sendable?

For example:

@concurrent
func process(_ arg: NonSendable) async {
    
}

During the execution of this function, is it possible that someone else can access the non-sendable argument concurrently without using unsafe features? If that's not possible, is it safe to do something like the following?

@concurrent
func process(_ arg: NonSendable) async {
    nonisolated(unsafe) let arg = arg
    await withCheckedContinuation { continuation in
        DispatchQueue.global().async {
            arg.doSomething()
            continuation.resume()
        }
    }
}

I think your specific example is safe, but the general pattern is unsafe because

  • It assumes DispatchQueue.async doesn't store or run the closure multiple times. The assumption is reasonable, but compiler can't guarantee it.
  • Compiler can't prevent you from writing code like:
        DispatchQueue.global().async {
            arg.doSomething()
            DispatchQueue.global().async {
                arg.doSomething()  // Potential data race!
            }
            continuation.resume()
        }

It you don't have to use DispatchQueue.async, you can call another @concurrent function in process:

@concurrent
func process(_ arg: NonSendable) async {
    await send(arg)
}

@concurrent
func send(_ arg: NonSendable) async {
    arg.doSomething()
}
2 Likes

I get what you mean, but in such an example, the parallel access is introduced by the process function itself, not by something outside. It is not safe even if we receive the argument as sending NonSendable.

So assume that I don't introduce parallel access myself within the body of process and don't escape this argument outside the function body, there can't be someone else "outside" accessing this value concurrently right? Like having an exclusive borrow?

Yes, you have exclusive access to a non-Sendable parameter in @concurrent function. I'd go further to say this is true in every valid function in Swift concurrency, because otherwise there would be data race.

I think we need to clarify the terms. IMO "exclusive access" is often used in the context of law of exclusivity. It's possible to break that rule even in a single threaded code. What you meant is thread-safe, or no data race. Yes, I think that's true in your function and every valid function in swift concurrency.

I don't think there is something called "exclusive borrow", unless you meant inout. As explained above, I think what you really cared about was data race rather than ownership.

Closure currently can't re-send a sending parameter. The called(once) closure being reviewed will lift the limitation.

2 Likes

It's true we don't have that, but there was a pitch discussing an exclusive parameter modifier, and I was just borrowing the term there.

I think the sending closure already does the job? Like the new Task.init. But it's not very helpful for the example of the process function. Even if DispatchQueue.async receives a sending closure instead of a @Sendable closure, it will still require arg to be sending NonSendable and forbid the following codes:

let value = NonSendable()
await process(value)
value.doSomething()   // Error

So to allow such pattern, I need both @concurrent and nonisolated(unsafe) (nonisolated(nonsending)is not safe here)

Actually the thing closest to my need is withTaskExecutorPreference with isolation param set to nil, but I don't want to raise the min deployment target to macOS 15 for now.

I see what you meant. There are two reasons why your code doesn't work.

  1. First you should declare the parameter as sending

    - func process(_ arg: NonSendable) async {
    + func process(_ arg: sending NonSendable) async {
    
  2. The reason why DispatchQueue.async doesn't work as Task.init is because the former takes a @escaping @Sendable closure but the latter takes a sending @escaping closure. It's OK for a sending closure to capture a sending value, but it's not OK for a Sendable closure to do that.

    class NonSendable {}
    
    func foo1(_ fn: sending @escaping () -> Void) {}
    
    func foo2(_ fn: @escaping @Sendable () -> Void) {}
    
    @concurrent
    func test1(_ ns: sending NonSendable) async {
        foo1 { print(ns) } // This compiles
    }
    
    @concurrent
    func test2(_ ns: sending NonSendable) async {
        foo2 { print(ns) } // This doesn't
    }
    

    A Sendable closure can be sent to and stored at multiple isolations. If it was allowed to capture a sending value, that would effectively send the value to multiple isolations, which would be a data race. A sending closure doesn't have this issue because it currently can't re-send a captured sending value.

Unfortunately the new Disconnected struct doesn't help in this case, because its exchange method is mutating.

@concurrent
func test3(_ ns: sending NonSendable) async {
    var disconnected = Disconnected(ns)
    foo2 { 
        let ns = disconnected.exchange(newValue: NonSendable()) // This doesn't compile
        print(ns) 
    }
}

I think the only safe solution will be the new called(once) closure.

We can separate the questions of (1) what the language guarantees and expects of different functions and (2) what the compiler can prove automatically is safe from that, and whether there’s a way to get it to do that in this case.

For non-Sendable values, we have to reason about what the language might let you do under region-based isolation. Here the value is an argument to a @concurrent function, so on entry it must be part of a disconnected region referenced only by the current task. Under RBI, the caller is going to assume that it might become entangled with other arguments and the return value, but that those values will jointly remain disconnected. So those are our guarantees and expectations.

Since we pause the current task while we do this dispatch, and the argument is referenced only by that task, there are no concurrent uses of the value during the dispatch. As long as the work we do there doesn’t entangle the value with non-Sendable values in other regions, we are living up to our expectations. But here we’re calling what seems to be a @MainActor method on a non-Sendable value, which would normally expect that value to already be part of that actor region and thus to be free to further entangle the value. So for this to be correct, we must know something extra about the specific behavior of that method w.r.t its self parameter. As long as it does indeed not entangle that value with the actor, it’s fine.

To prove this correct as currently structured, you would need two new things from Swift. First, you would need the compiler to know that it’s okay to “lend” values that are currently in disconnected regions to actor code as long as that code doesn’t entangle the value with the actor. Second, you would need the method to declare that it preserves the disconnectedness of a specific argument. The closest you can get to this today would be to send the value to the actor and then send it back, but this requires the value to be fully disconnected from the original task; this means the caller would have to drop any other values in that region that it might have.

2 Likes

So we are fine here as long as the doSomething method does not interact self with something belongs to another actor? Though I don't immediately have a concrete example in mind.

Like changing the process function to (sending NonSendable) async -> sending NonSendable?

Just the one actor is enough: doSomething running on the main actor, so we need to take special care to not create references back or forth between self and any storage isolated to the main actor.

Yes, if you can accept the extra constraints that imposes, Swift will enforce that you keep the values disconnected.

1 Like

I didn't enable main actor by default and didn't annotate it with @MainActor, so I think it's just running on where it's called? Otherwise it's not possible to call it directly within DispatchQueue.global().async in my original example.

Oh, sorry, I misread your example. Yes, it would need to do more to entangle the value with some actor, then.

1 Like

That makes sense. Thanks for the explanation!