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.
Thank you for spelling this out! It confirms some understanding I had, as well as clarifying a few questions.
However, building off of the example in this thread, I have a simple scenario I don’t fully understand:
// (Swift Language Version = 6.0, Approachable Concurrency = YES, iOS 26.0) nonisolated public class A { var a = 0 @concurrent func b(_ input: Int) async { a = input } } @MainActor func doSomething() { let local = A() Task { //implicitly: @MainActor isolation await local.b(1) // suspend this task while b() operates in background } Task { //implicitly: @MainActor isolation await local.b(2) // suspend this task while b() operates in background // EDIT: ^^ No data race error: Why? TSAN correctly detects at runtime. // Aside: this task may execute first relative to b(1)? I'm unsure if // there are guarantees on Task scheduling order. } Task.detached { // explicitly: global, concurrent executor await local.b(3) // <---- No data race error: Why? // Shouldn't this race with one the above two Tasks? } // Uncomment any of the below Tasks: gives a data race error as expected! //Task.detached { // await local.b(4) //} //Task { // await local.b(5) //} }
Apologies for the verbose example – The lines in question are:
await local.b(3) // <---- No data race error: Why?
Oct. 28 EDIT: and above this line // EDIT: ^^ No data race error: Why? TSAN correctly detects at runtime.
Moving the Task.detached { … } above any of the other tasks results in a data race error, implying there is some Task scheduling order things happening?
That does look wrong; the previous tasks should effectively force local into the main actor’s region, and then the detached task should be wrong. @Michael_Gottesman, does that analysis seem right?
Just want to add that even between your b(1) and b(2) there's a race.
Theoretically speaking, the caller and callee of b() are in different isolation domains, (the caller is @MainActor, the callee is @concurrent nonisolated), so the arguments passed to b, including local which corresponds to the implicit self, must be either Sendable or in a disconnected region. Neither is true for local, so the code should have been rejected.
You can easily verify the existence of this race by extending the operation a little bit longer and turning on TSAN:
public class A { var a = 0 @concurrent func b(_ input: Int) async { for _ in 0..<100 { // <- my modificatons a = input } } } @MainActor func doSomething() async { let local = A() Task { await local.b(1) } Task { await local.b(2) } try? await Task.sleep(for: .seconds(1)) } await doSomething()
@CrystDragon Gah, yep! Thank you and apologies. Edited my post for future readers.
In retrospect, my original comment: "Not a race: even though b() runs concurrently, the MainActor uses a serial executor, so it wont call b(2) until b(1) has returned." ... doesn't make any sense on a basic level.
Say MainActor starts the Task#1 first, and hits await local.b(1) -- that fires off the background task and suspends Task#1, but the MainActor will obviously continue running, eventually start Task#2, and evaluate await local.b(2) ... they're in two different tasks, so of course it could call b(2) before await local.b(1) returned.
@MainActor func test() { var s = 1 Task { @MainActor in for _ in 0..<100 { s += 1 } } Task.detached { // no error for _ in 0..<100 { s += 1 } } }
for some reason RBI currently does not diagnose certain cases in which the final use of a value that should not be (lowercase) sendable occurs in particular forms of closure captures like this.