@globalActor
actor LibraryAccount {
static let shared = LibraryAccount()
var booksOnLoan: [Book] = [Book()]
func getBook() -> Book {
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 generates the following warning:
Non-sendable type 'Book' returned by call to actor-isolated instance method 'getBook()' cannot cross actor boundary
However, the closure is itself marked with the same global Actor, there should be no Actor boundary here. Interestingly, when removing the await from the offending line, it will emit an error:
Expression is 'async' but is not marked with 'await'
This looks like it does not recognize that this is the same instance of the Actor as the one guarding the closure.
What's going on here? Am I misunderstanding how GlobalActors work or is this a bug?
CodePudding user response:
Global actors aren't really designed to be used this way.
The type on which you mark @globalActor
, is just a marker type, providing a shared
property which returns the actual actor instance doing the synchronisation.
As the proposal puts it:
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.
Therefore, what you write after static let shared =
doesn't necessarily have to be LibraryAccount()
, it can be any actor in the world. The type marked as @globalActor
doesn't even need to be an actor itself.
So from Swift's perspective, it's not at all obvious that the LibraryAccount
global actor is the same actor as any actor-typed expression you write in code, like LibraryAccount.shared
. You might have implemented shared
so that it returns a different instance of LibraryAccount
the second time you call it, who knows? Static analysis only goes so far.
What Swift does know, is that two things marked @LibraryAccount
are isolated to the same actor - i.e. this is purely nominal. After all, the original motivation for global actors was to easily mark things that need to be run on the main thread with @MainActor
. Quote (emphasis mine):
The primary motivation for global actors is the main actor, and the semantics of this feature are tuned to the needs of main-thread execution. We know abstractly that there are other similar use cases, but it's possible that global actors aren't the right match for those use cases.
You are supposed to create a "marker" type, with almost nothing in it - that is the global actor. And isolate your business logic to the global actor by marking it.
In your case, you can rename LibraryAccount
to LibraryAccountActor
, then move your properties and methods to a LibraryAccount
class, marked with @LibraryAccountActor
:
@globalActor
actor LibraryAccountActor {
static let shared = LibraryAccountActor()
}
@LibraryAccountActor
class LibraryAccount {
static let shared = LibraryAccount()
var booksOnLoan: [Book] = [Book()]
func getBook() async -> Book { // this doesn't need to be async, does it?
return booksOnLoan[0]
}
}
class Book {
var title: String = "ABC"
}
func test() async {
let handle = Task { @LibraryAccountActor in
let b = await LibraryAccount.shared.getBook()
print(b.title)
}
}