Why does `async let` discern "the origin" of its right expression?

The compiler(*) emits errors while the following code is being compiled with Swift 6 language mode:

(*) 6.0.2 and DEVELOPMENT-SNAPSHOT-2024-11-09-a

// -swift-version 6

class MyValue {
  var value: Int
  init(_ value: Int) { self.value = value }
  func f() async {}
}

func globalFunc() -> MyValue {
  return MyValue(0)
}

class MyValueUser {
  var value: Int = 0

  func otherMethod() -> MyValue {
    return MyValue(self.value)
  }

  func doSomething() async {
    func innerFunc() -> MyValue { MyValue(self.value) }

    let direct: MyValue = MyValue(self.value)
    let fromGlobalFunc: MyValue = globalFunc()
    let fromOtherMethod: MyValue = self.otherMethod()
    let fromClosure: MyValue = ({ MyValue(self.value) })()
    let fromInnerFunc: MyValue = innerFunc()

    async let _ = direct.f()
    // ✅ No Error.

    async let _ = fromGlobalFunc.f()
    // ✅ No Error.

    async let _ = fromOtherMethod.f()
    // ❌ error: sending 'fromOtherMethod' risks causing data races
    // 📝 note: sending task-isolated 'fromOtherMethod' into async let risks causing data races between nonisolated and task-isolated uses

    async let _ = fromClosure.f()
    // ❌ error: sending 'fromClosure' risks causing data races
    // 📝 note: sending task-isolated 'fromClosure' into async let risks causing data races between nonisolated and task-isolated uses

    async let _ = fromInnerFunc.f()
    // ❌ error: sending 'fromInnerFunc' risks causing data races
    // 📝 note: sending task-isolated 'fromInnerFunc' into async let risks causing data races between nonisolated and task-isolated uses
  }
}

await MyValueUser().doSomething()

I'm not familiar with concurrency and I wonder where the difference (whether or not error occurs) comes from.

Because MyValue is non-Sendable? (Compiler says No)

  • Errors disappear when MyValueUser is Sendable even if MyValue remains non-Sendable.
  • Vice versa: Errors disappear when MyValue is Sendable even if MyValueUser remains non-Sendable.
  • In short, errors occur only when both MyValue and MyValueUser are non-Sendable.

Because of missing sending keywords? (Compiler says No)

  • The return type of globalFunc is also missing sending keyword, but it doesn't let an error occur.
  • Adding sending keyword to otherMethod and to innerFunc eliminates their errors, but changing the closure to ({ () -> sending MyValue in MyValue(self.value) })() doesn't fix the problem.

I have no idea😞

1 Like

This is one of explanations. To be more general, compiler cannot deduce for this cases if the MyValue is in disconnected region (which includes being Sendable). Making it so fixes the problem. This also works with MyValueUser because now the compiler can deduce that produced values are in disconnected region (because self in such one).

Also yes. You help the compiler by explicitly saying that they return value that is in a disconnected region (which they are even if neither of types is Sendable, compiler just cannot understand this on it’s own). The case with the closure is a but tricky, because now compiler also needs to understand where the closure itself is @Sendable, I guess you can fix this by explicitly marking it so.


So to sum up, the difference is just an ability of a compiler to reason with regions, and in some cases it is really hard for it to reason without the hint. Instance methods automatically involve self in all calls, so the compiler mostly relies on that in analysis, being a bit conservative to prevent potential holes, so sometimes we just need to help it.

2 Likes

Thank you so much for your explanation.
I can understand sendability more than I have so far, maybe.

:thought_balloon: I wish the compiler could handle fromClosure just like direct since the closure here is a so-called IIFE.