Calling a globalActor's method from a closure marked with that actor does not compile?

Hi, I am trying to understand some subtleties about global Actors and have stumbled upon this behavior:

I have the following example code (modified from an example from a WWDC21 talk)

@globalActor
actor LibraryAccount {
    static let shared = LibraryAccount()
    var booksOnLoan: [Book] = [Book()]
    func getBook() -> Book  { // not async
        return booksOnLoan[0]
    }
}

class Book {
    var title: String = "ABC"
}

func test() async {
    let handle = Task { @LibraryAccount in
        let b = await LibraryAccount.shared.getBook() // WARNING and even ERROR without await (although function is not async)
        print(b.title)
    }
}

This code does not compile with the error:

Expression is 'async' but is not marked with 'await'

This is strange because the Task should theoretically be running in the same Actor, right? Note that the actor's method is not itself async, so it should only require await when called from a different actor, if I am not mistaken.

Interestingly, even when I add await I still get the following warning I got in addition to the error (might only be emitted with -warn-concurrency enabled):

Non-sendable type 'Book' returned by implicitly asynchronous call to actor-isolated instance method 'getBook()' cannot cross actor boundary

Why is there an actor boundary here? Is my understanding of global Actors mistaken or might this be a bug?

Thanks for your time.

PS: Sorry, I tried to post this before but forgot the tags and it was hidden by the Spamfilter.

It looks like the compiler doesn't recognize that the global actor @LibraryActor is the same as the actor returned from LibraryActor.shared. In theory it would be possible to create multiple LibraryAccount actors, and the compiler would be correct in requiring an await if there were a possibility that this call were crossing between two separate actor instances. That shouldn't be the case here, but it seems the compiler doesn't see that.

You can work around it by annotating func getBook() with @LibraryActor. That tells the compiler that no matter which instance of LibraryAccount you're working with, getBook() should always run on the global instance.

In practice, I question this design a little bit. Does LibraryAccount really need to be a global actor? Is it likely that you're going to define external functions that need to run on the same actor (and access the same internal state) as this LibraryAccount, and thus that you're going to tag them with @LibraryActor?

I think you're on the money. I've never seen actors being used like this, and can imagine that it's an overlooked use case. It's probably worth reporting as a bug, if only to spark a discussion.

Thanks for the reply. The design is, in practice, very questionable indeed. It's merely an academic example that I created out of some example code from a WWDC talk because I wanted to experiment with global actors. I should probably have made that more clear.

Someone on stackoverflow also suggested that the static analysis cannot be sure that it's the same actor because the shared property might have been programmed to return a different instance the second time.

A global actor which did not synchronise operations enqueued on its shared instance would not fulfill the semantic requirements of a global actor - it would be totally broken, and a source of unsafe/undefined behaviour.

From SE-0316:

A global actor is a type that has the @globalActor attribute and contains a static property named shared that provides a shared instance of an actor.

A global actor type can be a struct, enum, actor, or final class. It is essentially just a marker type that provides access to the actual shared actor instance via shared. The shared instance is a globally-unique actor instance that becomes synonymous with the global actor type, and will be used for synchronizing access to any code or data that is annotated with the global actor.

In other words, the compiler should know that @LibraryActor and LibraryActor.shared are the same actor (they are "synonymous"). I'm inclined to agree with @Joannis_Orlandos, that it is likely oversight.

Alternatively, it might simply be too difficult to implement - if you use LibraryActor.shared, the domain of isolation provided by the shared actor is part of its value, not part of the type system. Different global actors might share the same type of underlying shared actor:

actor SomeActor {}

@globalActor
struct GlobalActorA {
  static let shared = SomeActor()
}

@globalActor
struct GlobalActorB {
  static let shared = SomeActor()
}

Here, both GlobalActorA and GlobalActorB are different isolation domains, but the type of the underlying shared actor is the same. Understanding that some actor of type SomeActor is actually the same as GlobalActorA requires the compiler to track where the instance came from.

2 Likes