I have the following GlobalActor
declared in Swift and I notice unlike MainActor
or even a local actor, there is no corresponding assumeIsolated
API available. Am I missing something?
@globalActor
actor MyGlobalActor: GlobalActor {
static let shared = MyGlobalActor()
}
/* In real code, this protocol is from iOS library and I can not modify it */
protocol TestGlobalActorProtocol {
func testFunc()
}
@MyGlobalActor final class TestGlobalActor: TestGlobalActorProtocol {
func actorFunc() {
}
nonisolated func testFunc() {
MyGlobalActor.assumeIsolated(self) {
actorFunc()
} //Build fails
}
}
assumeIsolated
is a method declared specifically for MainActor
. It's not declared in the GlobalActor
protocol. In fact, you cannot declare such a method with the current language features. The closure parameter that this hypothetical method takes, needs to be isolated to "whatever global actor Self
is". There is no way of expressing that.
You can write such an assumeIsolated
for your own global actor too, by looking at how MainActor.assumeIsolated
is implemented in the standard library (source).
public static func assumeIsolated<T : Sendable>(
_ operation: @MainActor () throws -> T,
file: StaticString = #fileID, line: UInt = #line
) rethrows -> T {
typealias YesActor = @MainActor () throws -> T
typealias NoActor = () throws -> T
/// This is guaranteed to be fatal if the check fails,
/// as this is our "safe" version of this API.
let executor: Builtin.Executor = Self.shared.unownedExecutor.executor
guard _taskIsCurrentExecutor(executor) else {
// TODO: offer information which executor we actually got
fatalError("Incorrect actor executor assumption; Expected same executor as \(self).", file: file, line: line)
}
// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) {
(_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn()
}
}
At the end of the day, you can see that this is just unsafely casting (unsafeBitCast
) the isolated closure that is passed in, to a non-isolated closure. This works because global actor isolation of a closure (the @MainActor
part) doesn't affect how the closure itself is represented in memory.
The built-in method also does an executor check so that it can crash immediately if it is not running on the correct global actor. Your own assumeIsolated
cannot do exactly the same because the executor
in Self.shared.unownedExecutor.executor
is internal
.
You can instead just call shared.preconditionIsolated()
instead. It also calls the intrinsic _taskIsCurrentExecutor
under the hood.
Here is the implementation. You basically replace all the @MainActor
s with @MyGlobalActor
, and replace the part in the middle that does the executor check with shared.preconditionIsolated()
.
static func assumeIsolated<T : Sendable>(
_ operation: @MyGlobalActor () throws -> T
) rethrows -> T {
typealias YesActor = @MyGlobalActor () throws -> T
typealias NoActor = () throws -> T
shared.preconditionIsolated()
// To do the unsafe cast, we have to pretend it's @escaping.
return try withoutActuallyEscaping(operation) {
(_ fn: @escaping YesActor) throws -> T in
let rawFn = unsafeBitCast(fn, to: NoActor.self)
return try rawFn()
}
}
As a matter of style, I prefer global actors to actually not being actors themselves. Otherwise you can end up with multiple instances of the global actor, but global actors are supposed to be like singletons, which is quite confusing. Remember that the type you attach @globalActor
to, is just a "marker". What matters is the actor returned by the shared
property.
@globalActor
enum MyGlobalActor {
private actor Shared {} // this is the *actual* actor that serialises access
static let shared: some Actor = Shared()
}