class NS {
func modify() {}
@concurrent func modifyAsync() async {}
}
actor A {
func run(_ fn: nonisolated(nonsending) () async -> Void) async {
await fn()
}
}
nonisolated func test() async {
let a = A()
let ns = NS()
await a.run() {
ns.modify() // OK
await ns.modifyAsync() // Not OK
}
}
The diagnostic:
note: sending nonisolated(nonsending) task-isolated 'ns' to @concurrent instance method 'modifyAsync()' risks causing data races between @concurrent and nonisolated(nonsending) task-isolated uses
It's OK to transfer a task-isolated value in a regular closure to global executors (as long as it isn't a sending parameter). So I wonder why a task-isolated value in a nonisolated(nonsending) closure isn't allowed? Note the closure neither take parameters nor return values, so the value doesn't interact with caller's isolation.
Below are my test results of all four combinations of nonisolated(nonsending) and sending in the closure's type. It appears that it's nonisolated(nonsending) that causes the issue and having sending or not have no influence.
has sending?
has nonisolated(nonsending)?
ns.modifyAsync() call
no
no
OK
yes
no
OK
no
yes
Not OK
yes
yes
Not OK
Note: a disconnected value in a nonisolated(nonsending) closure doesn't have this issue.
While thinking about this, I realized another related question. In which scenario a nonisolated(nonsending) closure is neither Sendable nor sending? IMO if a nonisolated(nonsending) closure is defined in a different isolation than its caller (I think this is typical because otherwise it would unnecessary to be nonisolated(nonsending)), then it must be either Sendable or sending. Am I missing something?
I don't know the answer to this question. I've been playing around with it a bit, and I cannot come up with a scenario where this situation would be unsafe but the version without nonsending would be ok. It is possible that this is simply an RBI limitation/bug, but also, these things aren't always obvious so that's not a certainty.
Yeah that's a good question. What occurs to me is that, when nonisolated(nonsending) is set as a default, these will look like regular old function types. And in that case, it seems like it would be common for them to be both defined and then used with the same isolation.
When ns is captured in the run method’s closure, it is transformed from a disconnected value into a task-isolated value (more precisely, a nonisolated(nonsending) task-isolated value). Later on, modifyAsync attempts to send the task-isolated self to the @concurrent "region", crossing an isolation boundary.
ns is no longer statically guaranteed to execute on the same executor as use(_:):
class NS {
@concurrent func modify() async {}
}
// May be called from the MainActor
nonisolated(nonsending) func use(_ ns: NS) async {
// Will execute on some `TaskExecutor`,
// "guaranteed" to be different from the MainActor
await ns.modifyAsync()
}
That makes sense. I didn't think of it. That explains why nonisolated(nonsending) doesn't imply sending.
Did you mean change like this? I can't reproduce it.
nonisolated func test() async {
let a = A()
let ns = NS()
- await a.run() {
+ await a.run() { [ns] in
ns.modify() // OK
await ns.modifyAsync() // Not OK
}
}
I have the same opinion as @mattie. By definition a task-local isolated value can be transferred to @concurrent function. Its just that it can't be transferred to another actor isolation. (I'm sure you were aware of this).
I also noticed that passing parameter to a nonisolated(nonsending) function produces the same diagnostic, which I believe is legitimate. However, I think the diagnostic of nonisolated(nonsending) closure is bug. Although a captured value in closure has similar behaviors as function parameter in many cases, they are very different in this case.
A parameter passed to a nonisolated(nonsending) function is in caller's isolation
A parameter captured by a nonisolated(nonsending) closure is in the same isolation where the closure is defined. Typically that's not the same as caller's isolation.
That's why IMO they shouldn't have the same behavior. I suspect it's very likely the author of the related code omitted the difference and used the same logic in both cases. I'll file a bug soon.
In this case, the parameter migth be in actor isolated region before it's passed to the function (it might be in disconnected region too, but we need to consider the strictest situation). That's why it shoudn't be transferred anywhere.
In this case, ns is defined in test() function, not in actor a. Also it's in disconnected region. That's why it can be sending(note the closure is nonsiolated and its isolation is inferred as sending). Ideally it would be disconnected in the closure too, but it's hardcoded task-isolated in the current implementation. I can't see any reason why it can't be passed to a @concurrent function. IMO the fact that the closure runs on actor a has no influence on it.
The key difference between the two cases:
In the first case, function parameter might be actor isolated so it can't be transferred anywhere.
In the second case, capture parameter is owned by the closure and doesn't interact with the closure's caller's isolation in any way, so the closure being nonisolated(nonsending) shouldn't influence ns's behavior.