Looking at the a method, when creating a Task, it inherits the actor. Because of this, ns can be freely used within the Task, and since it exists in a disconnected region (not stored inside the actor), there should be no issue when passing it through actor isolation. Therefore, I believe a compilation error should not occur.
If there are any points I am missing or any misunderstandings on my part, could you please let me know?
class NonSendable {}
actor MyActor {
var ns = NonSendable()
nonisolated func a(_ ns: sending NonSendable) {
Task {
await b(ns) <- ‼️ Sending 'ns' risks causing data races
}
}
func b(_ id: sending NonSendable) { }
}
Your method a is nonisolated which means the context of the body is not isolated to your actor. Task.init is inheriting the actor context of the surrounding context. This means in your example your Task is also nonisolated and won't inherit any actor isolation.
If you remove the nonisolated this should compile again.
You're right about removal of nonisolated as a way to solve the problem, but I think there's another interesting issue here. Despite pretty much everything using sending in the code snippet:
func a(_ ns: sending NonSendable) takes a sending value.
Task.init takes a sending () async -> Success operation parameter.
func b(_ id: sending NonSendable) takes a sending value.
The code does not compile. It seems clear to me that the intent of this code is to send a NonSendable value to a(), which will create a Task in which the value will be sent again to b(). So why does it not compile?
I believe the issue here is that the compiler has no way of knowing that the operation closure in Task.init will only be called once. So, there's no way for the compiler to prove that Task won't call operation again after the first execution of the closure sends the value to b(), which would be a concurrent access to ns and thus a data race.
Some supporting evidence: if the Task is removed and a() is made async, it's possible to send the value as expected, despite a() being nonisolated:
class NonSendable {}
actor MyActor {
nonisolated func a(_ ns: sending NonSendable) async {
await b(ns) // ✅ ns is sent from the caller of a() to b() successfully
}
func b(_ id: sending NonSendable) { }
}
It seems unfortunate that the original code does not compile IMO, unless I'm missing something. There should be a way to tell the compiler oh, this is only going to be called once! to inform region based isolation because AFAICT there's no data race here unless Task's operation is called more than once.
You are correct with your reasoning that the compiler currently cannot tell that the closure of Task.init/Task.detached and the various task group APIs is only called once. Since the compiler can't tell that it cannot safely assume that a sending value will only be send to exactly one isolation region and has to defensively assume it might be send to many. We would need the concept of called once closures here.
In the meantime, you should be able to reassign the parameter to a nonisolated(unsafe) or stick it in a box that is @unchecked Sendable to get around this. This is safe since the closure of Task.init is only called once.
Thank you for your kind response! :)
I was already aware that removing nonisolated would prevent the error. However, I was in a situation where I had to use nonisolated. Like Andropov said, I found it strange that a NonSendable value annotated with sending could not be passed.
For now, as you mentioned, in such situations, we can work around it by assuming the closure is executed only once and using either nonisolated(unsafe) or wrapping it in @unchecked Sendable.
@Andropov@FranzBusch
Thank you for introducing me to the concept of "called once closures"!