When I first saw the example in Non-Isolated Initialization section of Swift 6 migraiton guide, I thought it's an application of SE-0327 (while the proposal is mostly about actor initializer, it has a small section on GAIT). However, after doing experiments (see result below), I find the only reason the example works is because it uses default values, which seems to has nothing to do with SE-0327. So I have a few questions. Any explanation would be much appreciated.
Q1) SE-0327 mentioned the following, does it apply to GAIT also?
An initializer has a nonisolated self reference if it is:
non-async
or global-actor isolated
or nonisolated
I thought it was yes, but if so, test 1 should compile. So it's no. I wonder if there's any fundamental reason why the same can't be implemented for GAIT? For example, is there any reason why test 1 shouldn't work? It seems far more general/useful than the approach of using default values, which IMHO feels like a hack.
Q2) Why does using default value work around concurrency errors? I understand one of the key difference is that, when using default values, the code doesn't need to pass values across isolation domain through init parameters (otherwise it would still fail. See test 3). Also I read that properties with default values are initialized before init. The fact that test 2 works and test 1 doesn't seems to suggest that, in example 2, Foo.bar is initialized using default value in MainActor domain. But considering the fact that Foo.init can be called in any other domain (that's the reason why we need nonisolated init), I wonder how can that be implemented?
class Bar {
}
// Test 1: this doesn't compile
@MainActor
struct Foo {
var param: Int
var bar: Bar
nonisolated init(param: Int, bar: Bar) {
self.param = param
self.bar = bar // error: main actor-isolated property 'bar' can not be mutated from a nonisolated context
}
}
// Test 2: this compiles
struct Foo {
var param: Int
var bar: Bar = Bar()
nonisolated init(param: Int, bar: Bar) {
self.param = param
}
}
// Test 3: this doesn't compile
@MainActor
struct Foo {
var param: Int
var bar: Bar = Bar()
nonisolated init(param: Int, bar: Bar) {
self.param = param
self.bar = bar // Same error as in example 1
}
}
In my personal understanding, SE-0327 does apply to GAIT.
But there's a subtle difference between an actor and a GAIT. That is, if you initialize an actor with some external values, the compiler still sees it as a "cross-domain" operation, even if the initializer is nonisolated.
That means the following programming error will be correctly detected.
class Bar {
func doSomething() { /*operations that mutate shared state */ }
}
actor Foo {
let bar: Bar
nonisolated init(param: Int, bar: Bar) {
self.bar = bar
}
func doSomething() {
bar.doSomething()
}
}
nonisolated func caller() {
let someBar = Bar()
let foo = Foo(bar: someBar) // <- the compiler will detect that you cannot send someBar to foo
// because someBar is accessed later
Task.detached {
await foo.doSomething()
}
someBar.doSomething() // If the compiler allow this to pass, you can easily introduce contentions on someBar
}
However, that's not the case for GAIT, should the compiler accept the following code, there will be a runtime problem:
@MainActor
struct Foo2 {
let bar: Bar
nonisolated init(param: Int, bar: Bar) {
self.bar = bar
}
func doSomething() {
bar.doSomething()
}
}
nonisolated func caller() {
let someBar = Bar()
let foo = Foo2(bar: someBar) // the compiler cannot have any words on this
Task.detached {
await foo.doSomething() // opps!
}
someBar.doSomething() // this line will be run in parallel with other operations
The insight I gained from this is: for a GAIT, just marking its initializer nonisolated may not be enough, the arguments themselves should also be Sendable.
You can check the " Sendability" section in SE-0327 proposal text for more info.
Thanks for the insight. In my understanding the main purpose of SE-0327 is to make it possible to call actors and GAITs non-async init() without await keyword in other isolation domain. It doesn't replace the requirement of Sendable (or region based isolation).
What swift version do you use? I'm using swift 6.0.1 and I can't reproduce the result of your examples. Below are the results of them on my system.
Your first example (I removed caller() function):
class Bar {
func doSomething() { /*operations that mutate shared state */ }
}
actor Foo {
let bar: Bar
nonisolated init(param: Int, bar: Bar) { // error: 'nonisolated' on an actor's synchronous initializer is invalid
self.bar = bar
}
func doSomething() {
bar.doSomething()
}
}
Your second example (I removed caller() too. So it's effectively my test 1.):
class Bar {
func doSomething() { /*operations that mutate shared state */ }
}
@MainActor
struct Foo2 {
let bar: Bar
nonisolated init(param: Int, bar: Bar) {
self.bar = bar // error: main actor-isolated property 'bar' can not be mutated from a nonisolated context
}
func doSomething() {
bar.doSomething()
}
}
As SE-0327 was implemented in Swift 5.10. It's weird that we have different results even if we use different versions of Swift.
My apologies, in my first code snippet, nonisolated should not be there. And the second snippet is meant to fail compilation.
The difference between the 2 cases is: for an Actor, because the compiler can prove its initializer cannot be provided any unsafe shared state, it can allow programmers to freely store nonsendables as its fields; but for a GAIT, that's different, the compiler cannot prove anything, so the only way it can maintain safety is to disallow storing nonsendables.
Thanks, but I can't agree with your conclusion. The second example's log you gave isn't true. What happens in practice is that compiler fails to compile GAIT's init, that affects its diagnostic message when it compiles caller(). I don't think the absence of data race detection message can prove anything. For example, if you add nonisolated before init in your first example to make actor's init fails to compile, you'll find compiler doesn't output data race detection message in the first example either.
Or let me put is way, what's the reason that compiler can detect data race in the first example but can't do the same in the second example? I can't think of any.
You missed my point, the comments in my examples are illustrations, not compiler logs.
That's not true, we can verify by removing unrelated code.
For instance, this compiles.
@MainActor
struct Foo {
var param: Int
var bar: Bar
nonisolated init(param: Int, bar: Bar) {
fatalError()
}
func doSomething() {
bar.doSomething()
}
}
nonisolated func caller() {
let someBar = Bar()
let foo = Foo(param: 3, bar: someBar)
someBar.doSomething()
Task.detached {
await foo.doSomething()
}
}
but this does not:
actor Foo2 {
var param: Int
var bar: Bar
init(param: Int, bar: Bar) {
fatalError()
}
func doSomething() {
bar.doSomething()
}
}
nonisolated func help() {
let someBar = Bar()
let foo2 = Foo2(param: 3, bar: someBar) // <- Compiler error here
someBar.doSomething()
Task.detached {
await foo2.doSomething()
}
}
This clearly shows there's no "cross-domain" checking for GAIT synchronous initializers.
It's an interesting example. I wouldn't think of it myself. So there is difference indeed, though I suspect the difference is caused by region based isolation related code, instead of SE-0327. It appears a bug to me that the first example compiles. I read in another thread that the upcoming Swift release (6.10?) contains a lot of region based isolation related bugfixes. I'll try the example again when it's available. Thanks.
This has been great discussion! The combination of RBI and the flow-sensitive behavior of actor initializers (which does not apply to GAITs) make this all really complex.
But one thing I wanted to call out here is, I believe this specific error is overly-conservative. While these situations are not semantically-equivalent, I cannot see how the assignment could result in a race.
I think it is likely relaxing this, however, could be non-trivial and might even require an evolution proposal. Still, seems worth a shot so I filed a bug about it.