Global actor isolation doesn't recognize shared actor instance as isolated?

It would appear that when we isolate a type to a global actor, that isolated context does not appear to recognize access to that global actor’s shared instance as being a compatible/already-isolated context, forcing us to do gymnastics in order to get into the global actor’s own domain.


@globalActor
actor A: GlobalActor {
    static let shared: A = .init()

    func act(_ str: String) {
        print(str)
    }
}

@A
class C {
    var str = "str"

    func f() {
        A.shared.act(self.str) // Call to actor-isolated instance method 'act' in a synchronous global actor 'A'-isolated context
    }

    func g() {
        A.shared.assumeIsolated { a in
            a.act(self.str) // Global actor 'A'-isolated property 'str' can not be referenced from a nonisolated context
        }
    }

    func h() {
        // successful gymnastics
        let str = self.str
        A.shared.assumeIsolated { a in
            a.act(str)
        }
    }
}

Is there a more friendly way to obtain the current instance of the globally/currently isolated actor?

1 Like

A few comments:

  1. The idiomatic way is to define act() as a global funciton and apply @A to it. This should resolves the error in f().

  2. The "nonisolated context" in the diagnostic in g() appears to be a bug unrelated to assumeIsolated. I've filed a bug.

  3. The issue with g() can be demonstrated with simpler code. IIUC this is as expected because compiler see the parameter of the closure as an instance of A, which isn't necessarily A.share. I think you have to use the solution in this post to get the behavior you wanted.

@globalActor
actor A: GlobalActor {
    static let shared: A = .init()
}

@A
class C {
    var str = "str"

    func g() {
        let fn: (isolated A) throws -> Void = { a in 
            _ = self.str // error: global actor 'A'-isolated property 'str' can not be referenced from a nonisolated context
        }
    }
}
1 Like

Thank you for the advice, @rayx :slight_smile:

Did you mean this?

@A func act (_ str: String) async {
    await A.shared.act (str)
}

No, below is complete working code.

@globalActor
actor A: GlobalActor {
    static let shared: A = .init()

    static func assumeIsolated<T: Sendable>(_ operation: @A () throws -> T) rethrows -> T {
    shared.preconditionIsolated()

    return try withoutActuallyEscaping(operation) { escapingClosure in
      try unsafeBitCast(escapingClosure, to: (() throws -> T).self)()
    }
  }
}

@A
func act(_ str: String) {
    print(str)
}

@A
class C {
    var str = "str"

    func f() {
        act(str)
    }
    
    nonisolated func g() {
        A.assumeIsolated { 
            act(str) 
        }
    }
}
2 Likes

This seems to be overly complex and confusing. Global actors should only have one shared instance. That is clearly the implication of language-level support for Global Actor isolation. The Main Actor is guaranteed to have only one instance and this should be the case for Global Actors as well.

As an aside, I find global actors to be problematic -- they promote the use of singletons in what is supposed to be a general-purpose programming language. I understand that they are a generalization of main actors which work very well for Apple platforms and perhaps other single-threaded GUI systems. The net benefit of introducing Global Actors distinct from the OS-required Main Actor is not clear to me, but that ship has sailed. Singletons have well-known downsides including problems with testability and not scaling well to larger systems. IMO Global Actors should be de-emphasized in documentation, examples, and additional features and made as straightforward and foolproof as possible to fit their best use in smaller, simpler programs.

It feels unusual to put anything like this in globalActor body...
I'd actually prefer the "static let shared = ..." and "private init() {}" boilerplate to be autogenerated like so:

// pseudocode:
@globalActor actor A {}

There's only one of those, right? (BTW, for that reason it's preferable to mark its init private).
Maybe use "static func act"? And maybe move it inside one of the classes that are in your global actor isolation domain (in your example "class C").


Sometimes I use global actors in this context:

@MyActor class C {
    nonisolated func foo() {
        ...
        Task { @MyActor in
            ...
            bar()
            baz()
        }
    }

    func bar() { ... }
}

i.e. to force a particular isolation domain of a closure without introducing an extra task hop and suspension points. If there was a way to achieve the same without global actors I'd gladly use it instead, pseudocode:

actor C {
    nonisolated func foo() {
        ...
        // pseudocode:
        Task { isolated(self) in
            ...
            bar()
            baz()
        }
    }

    func bar() { ... }
}

As for the singletons - they have their uses, but I don't think they are inherently relevant here.

Typically you'd want global actors to put more than one instance (of the same type or even of different types) into the same isolation domain to keep the amount of concurrency / task hopping low. Example with global actor:

@globalActor actor A {
    static let shared: A = .init()
}

@A class C {
    var value = 1
    func add(_ otherActor: C) { // not async
        value += otherActor.value
    }
    func foo(_ otherActor: C) { // could be non async
        add(otherActor)
    }
}

without:

actor C {
    var value = 1
    func add(_ otherActor: C) { // not async
        value += otherActor.value // Error
    }
    func foo(_ otherActor: C) { // could be non async
        add(otherActor)
    }
}

fix:

actor C {
    var value = 1
    func add(_ otherActor: C) async { // changed to async
        value += await otherActor.value
    }
    func foo(_ otherActor: C) async { // now has to be async
        await add(otherActor)
    }
}

Presumably, the primary motivation for managing functions inside the global actor’s instance is to access & manage the global actor’s isolated instance state.

I won't recommend putting "extra" stuff inside globalActor as this was probably never intended use case.. As a workaround to storing common isolated state consider using a static variable on one of your global actor isolated types.


Having said that, what you are looking for is still possible if you explicitly isolate whatever extra you put in the global actor to be of that global actor isolation:

@globalActor actor A {
    static let shared = A()
    private init() {}

    // typically there's nothing extra here...
    @A var value = 1
    @A func act(_ str: String) {
        print(str)
    }
}

@A class C {
    var str = "str"

    func f() {
        A.shared.act(self.str) // ✅
    }
}