Thanks. Now that you said it, I find the topic has been explained in a few places in SE-0306. I didn't know it's by design.
Not trying to resurrect the thread, but here's another example of a progmatic approach in compiler and its drawback.
var global: Int = 0
@MainActor
struct S {
var value: Int = 0
func add() {
global += value
}
}
// Since S is declared to be MainActor isolated, why is it OK to access its instance in this function?
func doSomething(_ s: S) async {
await s.add()
}
@MainActor
func test() async {
var s = S()
await doSomething(s)
}
The code compiles, as everyone would expect. But I find it difficult to explain why it's OK to access argument s
in doSomething(_:)
. A simple answer: it's because S
is sendable. This is incorrect, because just being sendable doesn't mean the value is accessible. For example, global
variable in above code is sendable but not accessible to doSomething(_:)
synchronously. A more complete answer: it's because a) S
is Sendable
, and b) once a value crosses isolation domain (e.g. caller pushes argument onto callee's stack), it gets isolated to the new domain. My question is about the part b.
-
First, what do we mean when we declare struct
S
to be MainActor isolated? I think it means all its instances are supposed to be MainActor isolated. Unfortunately this contradicts with part b. -
Second, in my understanding sendability and isolation are two separate things in Swift concurrency and rules in Evolution proposals don't usually mix them. For example, if a value is sendable but in a different isolation domain than caller, then caller can't send it synchronously. I'd think the same should be true on callee side. Take the above code as an example, since a
S
instance is MainActor isolated, it shouldn't be accessed synchronously bydoSomething(_:)
even if it has been sent to it. -
Third, the rule that "when a sendable value crosses isolation domain, it gets isolated to new domain" is vague. For example, what about the value's properties and methods which have explicit isolation? Their isolation certainly shouldn't change.
So, after thinking about this for a while, I haven't found a satisfying explanation. My guess is that this behavior isn't covered in any Evolution proposal and is the result of compiler's pragmatic decision. IMO the above code should fail. User should rewrite it as below:
nonisolated struct S {
var value: Int = 0
@MainActor func add() {
global += value
}
}
The new definition reflects the nature of the value (it is accessible from different isolation domain) and it's simple to explain. Since S
is non-isolated, its instances has dynamic isolation. When a value is sent by caller to callee, the value is in callee's isolation.