Modifying non-sendable class in Task with Swift Approachable Concurrency and Swift 6

I just read about the Swift Approachable Concurrency build flag in Xcode and was optimistic that it would help me transition more code to Swift 6. I have to admit that I don’t fully understand Swift Concurrency yet, and even when I think that I may have finally understood it, a new use case comes up that seems to shatter my understanding.

Take the following code.

public class A { var a = 0 func b() { Task { a = 0 } } } 

In Swift 5 it compiles, but in Swift 6 I get this compiler error:

Passing closure as a 'sending' parameter risks causing data races between code in the current task and concurrent execution of the closure 

My expectation was that with Approachable Concurrency it would be valid in Swift 6.

Why is the Task not run in the same isolation context as b() and how can I do it without making class A an actor?

1 Like

Because there's no actor isolation there at all. Without a global actor annotation (e.g. @MainActor) on the class, the method, or one of the types the class inherits from, everything is implicitly nonisolated.

Unless you have the MainActor-by-default flag turned on?

1 Like

Default Actor Isolation is set to nonisolated. I wanted to avoid making the class an actor or constraining it to an actor, as my purpose was creating many of them and calling their methods (which perform intensive work) in parallel. Some methods just run synchronous code, but some of them call async functions, which is why I have to use Task.

If you want to mutate something concurrently, it needs to be Sendable. In a class' case, wrapping the mutable state into a struct which you keep inside a lock of some kind (Mutex, OSAllocatedUnfairLock, etc.) is the simplest way to do that.

final class Thing { struct State { var a = 0 } private let state = OSAllocatedUnfairLock(initialState: State()) func b() { Task { state.withLock { $0.a = 0 } } } } 

Perhaps I didn’t make clear what my intention is. I would like the Task to not be executed concurrently, but in the same isolation context of the caller, so that I don’t need to make the class Sendable. Is this possible? I would avoid using Task altogether here, but the problem is that I need to call some async functions (not shown in the example code).

Even if that is possible, the caller could return the object as a sending value, and then the object could be accessed from a new isolation context (while the Task is still running on the original one).

1 Like

What object do you mean?

I mean something along the lines of:

func foo() -> sending A { let a = A() a.b() return a } func bar() async { let a = await actor1.run { foo() } await actor2.run { a.a = 1 } // concurrent with the task spawned in a.b() } 

which I believe should be allowed under RBI.

1 Like

If this were possible, you would need to not just inherit the caller's isolation, but specifically to require the caller's isolation to be some actor and not just nonisolated, because otherwise the change to the stored property is definitely a data race.

3 Likes

This example with sending got me thinking and it helped me better understand the flaw in my reasoning. But what if we change A.a to be private? Could this scenario allow me to somehow modify self in the Task? I find it difficult to accept that such an apparently simple scenario cannot be modelled in an easy way. The only thing I want to do is be able to wait for another async function to finish and after that continue modifying self with the result of that async call. I cannot help but thinking that I must be missing something.

Why would it be a data race? I thought Approachable Concurrency would make sure that nonisolated doesn’t cause a change in isolation context.

Then they could just call A.b() again.

It means it doesn't by default. But you could still be in an @concurrent function or Task.detached block.

But if the Task there managed to always run in the same isolation context as the class itself, there would be no race condition.

I don’t get this, but perhaps that’s just beyond my current ability to comprehend Swift Concurrency. I thought everything runs in a certain isolation context, whether it’s explicit (in an actor or with @MainActor) or not (@concurrent and Task.detached I guess?), and my intention was to wait for an async function and then integrate the result by going back to the original isolation context of the class.

For @concurrent methods and Task.detached blocks with no explicit isolation, they run on the global concurrent executor. The global concurrent executor is, as the name suggests, concurrent, and does not provide isolation. This executor has multiple threads and its whole purpose is to schedule background work in cases where you don't care about running with a specific actor isolation or on a specific thread.

Before (without Approachable Concurrency), all nonisolated async methods ran like this. With Approachable Concurrency, the default is to run up to the first suspension point on the caller's executor (which may still be the global concurrent executor), and then resume after the first suspension point on the global concurrent executor no matter what. @concurrent is a way to opt back into the old behavior.

The point was that the instance could have moved to a new isolation (i.e. from actor1 to actor2 in my example above), and then any tasks from the previous isolation are now running concurrently with any tasks with the new isolation.

2 Likes
public class A { private var a = 0 func b() { nonisolated(unsafe) let s = self // force it to compile Task { @Sendable in s.a += 1 } } func printA() { print(a) } } @concurrent func exec() async { // in global concurrent executor // force it to compile nonisolated(unsafe) let a = A() await withTaskGroup { group in for _ in 0 ..< 200 { group.addTask { @Sendable in // in global concurrent executor a.b() } } } a.printA() // will get less than 200 } 

Make sure NonisolatedNonsendingByDefault is enabled and MainActor-by-default is disabled, then run this exec function. You can observe some data rase in this case.

1 Like

Approachable Concurrency (specifically, SE-0461) means nonisolated async functions are formally isolated the same way as their caller. That does not mean that all functions at all times are isolated to some actor.

For one, SE-0461 does not change the behavior of nonisolated synchronous functions, which continue to be considered formally not isolated to any actor.

For another, @concurrent functions are always formally not isolated to any actor. Functions can be explicitly @concurrent, but there are also a lot of situations where async functions will be inferred to be @concurrent if they don't say otherwise. For example, while the closure passed to Task {} picks up the isolation of the surrounding context[1], that's actually a special case, and e.g. the closure passed to Task.detached does not.


  1. unless the surrounding context is isolated to a specific actor reference that the closure doesn't actually use ↩︎

1 Like

What I don't understand is that, if the OP's code is invalid, why the following code is valid? At first sight they are similar in their nature (IMO my code is more likely to cause data race than OP's code).

public class A { var a = 0 @concurrent func b() async { a = 1 } } 

I have a hypothesis. While we usually refer to "actor isolation domain" when we talk about isolation, there is also "task isolation domain". See the following text in SE-0414 (emphasis mine). In OP's code, a is accessed in both task isolation domain and the inherited isolation domain, so the code is considered invalid. In contrast, my code doesn't creates a task so it's considered valid, although a function that creates a task and run b within it will fail to compile (unless it meets RBI requirement).

An isolation region is isolated to an actor's isolation domain, a task's isolation domain, or disconnected from any specific isolation domain

Just my guess and I'm curious what's the right way to understand the different behavior.

1 Like

Would you mind sharing how? I can‘t seem to think of a scenario where your code races.

My understanding is that nonisolated functions are not naturally prone to race problems. A race can only be possible where (A) a value is passed between isolation domains, or (B) a function runs "concurrently".

Neither your and OP's code touches the area of (A), so we focus on the situations in (B), where a function runs "concurrently". But @concurrent does not introduce concurrency by itself, for a function to run "concurrently", there must be some code that introduces concurrency, eg. async let, withTaskGroup, Task.init, etc. And in every single one of those cases, Sendable checking will step in to ensure safety.

4 Likes

I think of two more explanations regarding my question.

Explanation 1 (based on my understanding of how RBI works):

The difference is due to the way how RBI works. IIUC RBI analysis of a funciton is performed based on its parameter types, body, and signatures of callees. In OP's code b() looks like a plain old function if judged from its signature. If compiler allows b() to compile, it would be impossible for RBI to catch data race issue in caller's code (see @bbrk24's code for example). In my code b()'s signature indicates it's an async function executed on global executors, so RBI has enough information to make the decision in its caller code.

Explanation 2 (based on the rules in SE-0414 and SE-0430):

According to SE-0414 a non-sendable closure's region is the merge of its non-sendable captured parameters. In OP's code, class A's instance's region might be actor-isolated (i.e. when the instance is an actor's property) or disconnected (i.e. when the instance is a local variable), and so is the task closure capturing it. On the other hand, according to SE-0430 a sending function parameter requires that the argument value be in a disconnected region. The mismatch causes the compile failure.

There is no closure in my code, so it compiles.

I prefer explanation 2 over 1 because 1 requires knowledge of implementation details but 2 is based on documented rules. I also prefer explaiantion 2 over @CrystDragon's because Swift concurrency takes a great effort to develop concepts like sendable, sending, etc. and rules about them so IMO it's better to think in these terms.

Just to clarify, I didn't mean I find a data race scenario which compiler fails to catch. I meant my code could easily lead to usage like the following:

func run() async { let obj = A() Task { await obj.b() } await obj.b() } 

Or let me put it this way, can you think of any valid way to use my code other than the above usage (with the second await obj.b() commented out)? I doubt it. That's why I think, while my code is valid, it isn't very useful or a good pattern.

1 Like