Problem with initializing a @MainActor class with a shared instance of a different global actor

I’m struggling with the following piece of code in Swift 6.2 that throws the “Global actor 'ServiceActor'-isolated default value in a main actor-isolated context“. If I make Service to be just a regular actor it appears to be fine, but I wanted to gather a few services under one actor. I had tried a few things and could not get to a nice place.

@globalActor
actor ServiceActor {
    static let shared = ServiceActor()
}

@ServiceActor
final class Service {
    static let shared = Service()
}

@MainActor @Observable
final class ViewModel {
    
    private let service: Service
    
    init(service: Service = .shared) { // error here
        self.service = service
    }
}

struct MyView: View {
    
    @State private var viewModel = ViewModel()
    
    var body: some View {}
}

Since the shared Service instance in your example is immutable and Sendable you can just make it nonisolated to let you access it from any context:

@ServiceActor
final class Service {
    nonisolated static let shared = Service()
}

Of course you will still need to await any access to isolated parts of the Service instance itself from a @MainActor context but I'm assuming that's not an issue since you explicitly use a dedicated global actor for the service.

4 Likes

Thanks @Jnosh This does work although I don’t understand why is this different when I’m using a normal actor.

With a global actor annotation on a non-actor type, not only are instances of that type isolated to that global actor but static methods and properties also default to the global actor isolation. All the instances of the type and the static methods and properties share the same isolation.

With an actor type on the other hand, each instance of the actor forms its own isolation domain. So each actor instance has unique isolation (at least statically, dynamically we could have more complex behavior when customizing the executor).
But since each instance is separately isolated that means there is no applicable isolation that could be used for static methods and properties on the actor type so those default to nonisolated.

actor MyActor {
    // an instance property is isolated to the actor instance it belongs to
    // but the same property on two different actor instances would
    // not have the same isolation
    var myProp: Int

    // a static property is nonisolated by default since there is no default shared isolation 
    // that could be used, only individual actor instances are isolated
    static let shared = MyActor()
}

@MyGlobalActor
final class MyGlobalActorIsolatedClass {
    // properties are isolated to @MyGlobalActor by default
    // but unlike with an actor, this property has the same isolation across all instances
    var myProp: Int

    // since we have a single isolation domain, it is also applied to static properties
    // so this also defaults to @MyGlobalActor isolation unless otherwise specified
    static let shared = MyActor()

    // a manually defined initializer would also default to @MyGlobalActor isolation
    // so `shared` might even need to be @MyGlobalActor isolated to call the initializer
    // (For a synthesized initializer Swift generates a nonisolated one *if it can*.)
    init {}
}
7 Likes

Right, that makes sense, thanks!

I might be missing something, but a GAIT (global actor isolated type) instance is sendable, why did OP's original code not work?

This compiles (I added conformance explicitly):

@globalActor
actor ServiceActor {
static let shared = ServiceActor()
}

@ServiceActor
- final class Service {
+ final class Service: Sendable {
static let shared = Service()
}

And this compiles:

@MainActor
func foo(_ service: Service) {}

@MainActor
func bar1() { foo(Service()) }

@MainActor
func createService() -> Service { Service() }

@MainActor
func bar2() async { foo(await createService()) }

It's just that passing Service.shared instance to foo doesn't compile:

@MainActor
func bar3() { foo(.shared) }

Could it be a bug?

Another example. This compiles:

final class NS: Sendable {
    static let shared = NS()
}

actor A {
    func test(_ x: NS) {}
}
await A().test(NS.shared)

But this doesn't:

actor B {
    init(_ x: NS) {}
}
B(NS.shared)

It seems weird.

Service being Sendable doesn't really matter in this instance, the issue isn't moving Service across isolation domains but calling the @ServiceActor isolated property getter in the first place.

// Sendable conformance is inferred for for the local module so adding it shouldn't make a difference
@ServiceActor
final class Service {
    // Implicitly @ServiceActor isolated so we can't call the getter for this property
    // from a non-@ServiceActor isolated context.
    // The returned Service instance is Sendable, but creating or retrieving
    // it might require @ServiceActor isolation.
    static let shared = Service()
}

@MainActor
func createService() -> Service {
    // This calls the Service initializer.
    // Since we didn't specify an explicit one, Swift synthesizes an initializer.
    // The synthesized initializer is nonisolated in this case so this works.
    Service()
}

If we add an explicit initializer to Service:

@ServiceActor
final class Service {
    // implicitly @ServiceActor isolated
    // we would need to add nonisolated manually
    init() {}
}

@MainActor
func createService() -> Service {
    // Now this no longer compiles
    Service()
}

In theory Swift could automatically infer the shared property as nonisolated in this case. But that would get complex in the general case, could be quite confusing in practice and would generally be restricted to the local module since changes to the implementation of shared could change the inferred isolation and break all clients.


Another example. This compiles:

final class NS: Sendable {
    static let shared = NS()
}

actor A {
    func test(_ x: NS) {}
}
await A().test(NS.shared)

But this doesn't:

actor B {
    init(_ x: NS) {}
}
B(NS.shared)

It seems weird.

Both of these examples compile for me (with Swift 6.2.1).

1 Like

Thanks, that's the part I misunderstood.

Sorry, my mistake.

Deleted

What I wanted to demonstrate was the following. I wasn't sure why it's OK to not use await before NS.shared but now I realize that 1)The await in test1b was needed but can be skipped, 2) The await in test2 and test3 wasn't needed because the code was in @MainActor.

Setup:

@MainActor
final class NS: Sendable {
    static let shared = NS() // `shared` is @MainActor isolated
}

@MainActor
func foo(_ ns: NS) {}

@MainActor
func fooAsync(_ ns: NS) async {}

Test 1:

@globalActor
actor MyGlobal {
    static let shared = MyGlobal()
}

@MyGlobal
func test1a() { foo(NS.shared) } // Not OK, as you explained

@MyGlobal
func test1b() async { await fooAsync(NS.shared) } // OK

Test 2:

actor MyCustom {
    func test2(_ x: NS) {}
}

await MyCustom().test2(NS.shared) // OK

Test 3:

actor MyCustom {
    init(_ x: NS) {}
}

_ = MyCustom(NS.shared) // OK