Non-matching isolation with actor-isolated parameters

Hey everyone,
I stumbled upon a weird limitation of the actor-isolated parameters introduced by SE-0313.

@MainActor
func mainIsolatedFunc() {}

func execute(isolation: isolated MainActor) {
    mainIsolatedFunc() // Error: Call to main actor-isolated global function 'mainIsolatedFunc()' in a synchronous actor-isolated context
}

I understand that global actors are special, but it’s still counter intuitive to me. Shouldn’t the isolated MainActor isolate the function to MainActor and allow synchronous calls to other MainActor-isolated APIs?
Is this a bug (unimplemented edge case) or is this expected behavior?

3 Likes

Interesting find.

This compiles though:

@MainActor
func f() {}

@MainActor
func g() {
    f ()
}

This also compiles:

func f (isolation: isolated MainActor) async {
    await g ()
}

As you discovered, these don't:

func f (isolation: isolated MainActor) {
    g () // Error: Call to main actor-isolated global function 'g()' in a synchronous actor-isolated context
}
func f (isolation: isolated MainActor) async {
    g () // Error: Main actor-isolated global function 'g()' cannot be called from outside of the actor
}

There's no notion built into the compiler that MainActor "definitely has exactly one instance". So what if you could make a few instances of the "global actor" actor type, and they'd be independent and you'd end up running on different actors.

Global actors could in theory get special treatment that "we promise noone will make more instances of it" but this isn't enforced so you could totally

@globalActor public final actor MyActor: GlobalActor {
  public static let shared = MyActor()
}

func test(me: isolated MyActor) {}

test(me: MyActor()) // OH NO, different actor instance than MyActor.shared (!)

to violate the uniqueness required to make this truly safe...

MainActor could get a special carve out for this in theory, but this isn't something that we've implemented / discussed.

I think we have issues about it somewhere... in general it is the "global actor @ThatActor equivalence to ThatActor isolation" problem.

6 Likes

I think the question is how we, potentially, want to implement this in the future. Creating an ad hoc solution now may backfire later when we potentially introduce such a feature more broadly.

1 Like

You'd have to ban creating new instances of whatever is used as return type of the shared property. Note that this isn't strictly the same actor as the @globalActor annotated type. This design is unfortunately making it hard to lock down safely.

Doing to unsafely is problematic, by "just trusting" it always is one instance we can get random races if someone were to cause this.

Happy to hear ideas how to solve this and see a pitch/proposal if someone has some nice idea here.

1 Like

This is an excellent point. I don’t think many folks are aware that the type of shared and the type annotated with @globalActor need to be the same.

actor A {}

@globalActor
struct SomeGlobalActor {
    static let shared = A()
}

In fact, this has been an area of interest of mine for quite a while.

I think having such a compile-time guarantee could be useful for some situations where people have wanted to make things generic over actors that can include global actors. A good reference example is MainActor.run. This is not defined on GlobalActor, but is instead a specialized function that has to be re-implemented for each global actor.

When I’ve worked on it, I’ve come to realize that there’s really nothing special about the global actor specifically. This is really a static constraint on singletons in general. Global actors just happen to be a really important form of singleton.

I have the very rough beginnings of a proposal here. But the truth is that I’m torn. Because on the one hand, I’m not sure I love the idea of baking singletons into the language so deeply. But on the other hand, global actors are already here and very important. But on the other, other hand, is this a common/severe enough problem to put work into?

I do like the idea of potentially opening the door to generic support for global actors, but I’m not even sure this would be enough. And if it is not, I cannot decide if it’s worth it.

The wart about being unable to write generic code over global actor isolation is a problem worth solving, so I'd be open to proposals on this. Pending actual LSG review but I think it's a real problem worth solving -- we hate copy pasting implementations around and even more so having to tell end users they have to do the same.

We can't write an assumeIsolated "on GlobalActors" for this reason, so whenever someone needs it, they need to copy paste code from stdlib. It's not super frequent, but an annoying wart that would be nice to fix.

2 Likes

Barring a general solution, could we allow isolated @MainActor to solve the global actor version? That could at least guarantee the global instance is used.

1 Like