swiftswift-concurrencyswift6

Swift GlobalActor assumeIsolated like MainActor


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
         }
    }

Solution

  • 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 @MainActors 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()
    }