A non-sendable class can bypass Swift6's concurrency check and cause data race?

class NonSendableClass {
  var data: Int
  init(data: Int) {
    self.data = data
  }
}

//@available(*, unavailable)
//extension NonSendableClass: Sendable { }

// Make Foo explicitly run on a different actor
actor SomeActor{
  static func Foo(_ obj: NonSendableClass) async -> Int{
    try? await Task.sleep(nanoseconds: UInt64.random(in: 0..<1000))
    obj.data += 1
    return 0
  }
}

@MainActor
func main() async {
  let nonSendableObject = NonSendableClass(data: 0)
  let nonSendableObject2 = nonSendableObject
  for i in 0...1000{
    // SomeActor.Foo will increment this value by 1
    let task = Task { await SomeActor.Foo(nonSendableObject) }
    try? await Task.sleep(nanoseconds: UInt64.random(in: 0..<1000))
    // Decrement this value by 1
    nonSendableObject2.data -= 1
    let _ = await task.value
    // I would expect at the end of each iteration the value should be 0 +1 -1 = 0
    print("Iteration \(i): \(nonSendableObject2.data)")
  }
}

await main()

If you run this code multiple times you will see the result is not always 0, which implies there's data race.

I'm not sure what's the root cause for swift not being able to tell this is a potential data race but I've observed some interesting behaviors:

  1. With swiftLanguageVersions: [.v5], there's no warning, but if I uncomment
//@available(*, unavailable)
//extension NonSendableClass: Sendable { }

then the compiler complains

"Passing argument of non-sendable type 'NonSendableClass' outside of main actor-isolated context may introduce data races; this is an error in the Swift 6 language mode"

which makes sense because I just marked NonSendableClass as not sendable, and Swift 5 doesn't have regional isolation it won't suppress the warning here (a bit more about this regional isolation later).

However if I comment these two lines, NonSendableClass should still be not sendable since it's a class but not final with mutable field data, but this time no more warnings with Swift 5.

Btw I'm pretty sure NonSendableClass is not sendable because once I make it

final class NonSendableClass: Sendable {

the compiler explains

"Stored property 'data' of 'Sendable'-conforming class 'NonSendableClass' is mutable"

  1. With swiftLanguageVersions: [.v6], there's still no warning, and even if I uncomment
//@available(*, unavailable)
//extension NonSendableClass: Sendable { }

there's still no warning. I suspect it's because of the regional isolation as explained here: `-strict-concurrency=complete` relaxes concurrency check. Compiler sees nonSendableObject is no longer used so it thinks it's safe to pass it to another actor, whereas nonSendableObject2 turns out to be pointing to the same object, making it unsafe and triggers the data race.

I think Swift 5's behavior is already problematic (not conservative enough) because it fails to decide NonSendableClass is not sendable if I don't explicitly say this, but at least it complains passing nonSendableObject to SomeActor.Foo if I explicitly declare NonSendableClass as not sendable.
But with Swift 6 it gets worse because even in this scenario the warning is gone, possibly due to regional isolation?

Since Swift 5 doesn't focus on concurrency safety, I know even if the compilation passes there could still be concurrency issues so I can bear with that. But now Swift 6 is really strict about concurrency safety so I guess I would expect myself at least seeing a warning somewhere in this code?

2 Likes

Do you have strict concurrency checks on in that case?

I’m not sure if that’s region based isolation (can be wrong though), since you have a shared instance passed in calls. While I cannot say exact reason why there is no warning and if that’s an issue, some thoughts are

  • Task.init inherits actor context, so there might be no passing into different isolation from compiler perspective (and with the help of region based isolation), with Task.detached error is in place.
  • Continuing previous, it also might be lack of diagnostic since @_inheritActorContext isn’t official attribute, but I am not aware on the internal tradeoffs using it.
  • I also have thoughts that there might be no dangerous passing of non-Sendable object that can cause data race, but rather a higher-level race condition, because each loop iteration has the same output across one run, while I’d expected to have different results even among iterations.

But that’s an interesting case for sure!

Do you have strict concurrency checks on in that case?

I just tried and the outcomes are (under Swift 5)

Without those two lines explicitly declaring NonSendable:

  • .enableExperimentalFeature("StrictConcurrency"), .unsafeFlags(["-Xfrontend", "-strict-concurrency=complete"]) and not setting both all don't trigger warning.

With those two lines explicitly declaring NonSendable:

  • .enableExperimentalFeature("StrictConcurrency"), .unsafeFlags(["-Xfrontend", "-strict-concurrency=complete"]) have no warning
  • Not setting both will trigger the warning

I'm not sure if it's regional isolation either (I'm kinda new to Swift) but after all the data race does occur while Swift 6 compiler emits no warning, so I think there must be something fishy?

Btw, I think Swift is able to tell if NonSendableClass is Sendable or not because this works:

class NonSendableClass {
  var data: Int
  init(data: Int) {
    self.data = data
  }
}

//@available(*, unavailable)
//extension NonSendableClass: Sendable { }

func printSendable(_ obj: Sendable) async -> Void{
  print("\(obj) is Sendable")
}

let nonSendable = NonSendableClass(data: 0)
// Swift 5:
// Type 'NonSendableClass' does not conform to the 'Sendable' protocol; this is an error in the Swift 6 language mode
// Swift 6:
// Error: Type 'NonSendableClass' does not conform to the 'Sendable' protocol
await printSendable(nonSendable)

But this got me confused, if Swift 5 (and Swift 6) knows it's not sendable even if I comment lines explicitly declaring this, why in my first example Swift 5 will only complain "Passing argument of non-sendable type" if I don't comment these two lines?

Also

  • I also have thoughts that there might be no dangerous passing of non-Sendable object that can cause data race, but rather a higher-level race condition, because each loop iteration has the same output across one run, while I’d expected to have different results even among iterations.

Would you mind elaborate a bit more on this part? I'm not sure if I understand this... what does

I also have thoughts that there might be no dangerous passing of non-Sendable object that can cause data rac

mean?

And why would you expect "different results among iterations"? I think in my example, if no data race occurs one iteration will first add 1 then subtract 1, which makes the result unchanged, unless there's data race (which rarely occurs), so the most times the result should be the same but only in a few iterations the result is changed by 1 (implies data race in this iteration)?

  • Task.init inherits actor context, so there might be no passing into different isolation from compiler perspective (and with the help of region based isolation), with Task.detached error is in place.

Maybe there's other way to trigger this data race without using Task (I'm not very familiar with Swift though).
Is there a way other than Task to launch an async function on some other actors and immediately return and execute the next statement without blocking? I can't just use await here because I need to make SomeActor.Foo() and main()'s execution intertwined with each other

Nice catch. The specific problem is that the call to SomeActor.Foo inside the unstructured main-actor-isolated task runs on the generic executor because it's a nonisolated async function. So, the call from the main actor leaves the main actor to run the function. Meanwhile, the original task is still allowed to access the alias of the non-Sendable object. The call to the nonisolated async function should be diagnosed as a region isolation error. After passing nonSendableObject to the nonisolated async context, the main actor should lose access to all values in its region, which includes nonSendableObject2.

4 Likes

Here's a reduced example. The Task.yield() is there to give the unstructured task the opportunity to run before main returns, so that the call to f can run concurrently with the rest of main. This code gets correctly flagged by TSAN.

class NonSendableClass {
  var data: Int = 0
}

func f(_ ns: NonSendableClass) async {
  await Task.yield()
  ns.data += 1
}

@MainActor
func main() async {
  let nonSendableObject = NonSendableClass()
  let nonSendableObject2 = nonSendableObject
  Task {
    await f(nonSendableObject)
  }
  await Task.yield()
  print(nonSendableObject2.data)
}

await main()
3 Likes

in case it helps with triage, this variant also appears to exhibit the bug (in Xcode 16, beta 5 at least). it makes f actor-isolated, and removes the local variable aliasing.

class NonSendableClass {
  var data: Int = 0
}

actor A {
  func f(_ ns: NonSendableClass) async {
    await Task.yield()
    ns.data += 1
  }
}

@MainActor
func main() async {
  let nonSendableObject = NonSendableClass()
  let a = A()
  Task {
    await a.f(nonSendableObject)
  }
  await Task.yield()
  print(nonSendableObject.data)
}
await main()

reported this as a bug here, in case it isn't yet tracked elsewhere.

1 Like