Guarantees on global actor type and shared instances

In another thread, a conversation came up about global actors. At issue was that, today, the compiler cannot assume that an instance of a global actor type actually corresponds to the shared instance itself.

I'm just going to reproduce the motivating problem from that post here:

@MainActor
func mainIsolatedFunc() {}

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

A similar problem also manifests itself within the standard library with MainActor.run. This is not an extension on GlobalActor, because today such an extension cannot be written.

It seems to me like really this boils down to Swift's inability to guarantee the contract of a singleton type. Independent of how you feel about the singleton pattern, it is undeniably in very wide use. It is used extensively in Apple's SDKs and with global actors, is a core part of the concurrency system.

If we imagine:

a) it was possible to guarantee that all instances of a type corresponded to a single, shared instance
b) global actors used this facility

would it help?

I can see it would address the example above. And I think it would also help with a GlobalActor.run implementation.

extension GlobalActor {
  func run<T>(
    resultType: T.Type = T.self,
    body: @Sendable (isolated Self.ActorType) throws -> T
  ) async rethrows -> T where T : Sendable {
    // with this facility, it could be statically proven that `body` will always be
    // isolated to GlobalActor.shared. Is that enough?
    }
}

Would a change like this be enough to sufficiently address the inability to express global actors with generics? I don't feel like I have a good handle on the limits people have run into here.

Another interesting and related issue to global actors specifically is that the @globalActor annotation does not have to be applied to the actor type. This feels like an unusual level of flexibility. What are the use-cases for that?

Again from that thread:

actor A {}

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

This is all just exploratory. If you can come up with holes and/or mistakes I'd really appreciate it. Thank you!

3 Likes

Are you suggesting that the "generalization" of

@MainActor
func f(_ work: @MainActor () -> Void)

is

func f<G: GlobalActor>(
    isolation: isolated G.Type /* implicitly = G.self if called in G's isolation */,
    _ work: (isolated G.Type) -> Void
)

or should it be

func f<G: GlobalActor>(
    isolation: isolated G /* implicitly = G.shared if called in G's isolation */,
    _ work: (isolated G) -> Void
)

Or that these two might both be allowed & equivalent?

(Personally if we're bikeshedding syntax already, I hate the isolated parameters & @GlobalActor annotations and wish they were an "effect" on a function like throws or async):

func f<G: GlobalActor>(_ work: () isolated(G) -> Void) isolated(G)
1 Like

Hmm I think I’m suggesting that they would be equivalent, and could be generalized further to all actor types and not just GlobalActor.

func f<A: Actor>(
  isolation: isolated A,
  _ work: (isolated A) -> Void
)

However, this is all very loose in my mind so I don’t know if I’m missing anything. It could all end up being not a great idea.

As for the effect-based isolation, I think that’s a super interesting idea! I’d love to talk more about that (in a different thread perhaps?)