As far as I'm concerned, it's just two sides of the same coin. The concepts of Sendable checking and RBI analysis orchestrate pretty well. Especially thanks to the sending keyword, which connects them.
Were A in OP a Sendable type, then the closure { a = 0 } is @Sendable, and it satisfies the sending requirement of Task.init. But it's not, it means the closure passed to Task.init is not @Sendable. Then, to make it valid for a sending parameter, the code must pass RBI analysis. Unfortunately that's not possible either -- as what you have said in your posts-- because self is task-isolated, the closure { a = 0 } cannot be in a disconnected region.
Your response basically hit the nail on the head when you were talking about having to think about things that are just isolated to the task vs. things that isolated to actors. Tasks and actors are different concurrent domains. If a task is using a non-sendable value, but the same value is also accessible to any code that's isolated to a particular actor, then there's a potential data race unless the task and the actor are synchronized — which is to say, unless the task is running isolated to the actor. If the task leaves the actor, it must either leave the value behind or ensure that it's no longer accessible to the actor.
A @concurrent function doesn't run concurrently with the task that called it, but it does run concurrently with any actor that the task might have been isolated to prior to calling the function. So if a local value x is non-sendable, and it's potentially accessible to an actor, you cannot pass it as an argument to a @concurrent function, just like you can't pass it as an argument to a @GlobalActor1 function if you're @GlobalActor2. Deciding whether any particular local value x is potentially accessible to an actor is the core of region-based analysis.
Within a nonisolated function (@concurrent or not), you can assume that a non-sendable value is currently accessible only to this task, but that doesn't mean you have free rein over it. If the value is accessible to your caller, then your caller might keep using it after you return [1], so you can't do anything that would potentially cause data races with those uses.
I can see the confusion between your example and Nicolas's. Both methods can only be called on a value that's disconnected from any surrounding isolation (if there is any). The difference is that Nicolas's function has to consume the disconnectedness: the caller is never allowed to use the self argument again after the call, and that's an important difference that has to be reflected in the signature (with the sending modifier, which communicates exactly this transfer of responsibility.) In your function, the self argument goes right back to being accessible to the caller, which is just the normal assumption that Swift makes for nonisolated functions.
If you're not @concurrent, the value might even be accessible to an actor; at any rate, it has to be assumed that it is. ↩︎
Thanks for your explanation. It all makes sense and is extremely helpful. While I might have the intuition when I see code, I have never read the constraints on what a nonisolated function can do and how it can be called in any documents. IMO nonisolated value/function is a complicated but less documented topic in Swift concurrency.
Returning to OP's original question, I'd like to suggest organizing code like the following. It puts the core business logic in nonisolated class A (this is what OP wants to achieve, I believe), and creates Task in actor.
class A {
var value = 0
nonisolated(nonsending)
func doWork() async {
try? await Task.sleep(for: .seconds(1))
value = 0 // Modify instance state
}
}
actor Foo {
let a = A()
func run() {
for _ in 0..<10 {
Task {
await a.doWork()
}
}
}
}
If you don't want that class to be an Actor and u want that works with Swift Concurrency u can mark that class as @unchecked Sendable. This will make it pass the Sendable check but u have to take care this function is not called by different threads at same time.