swiftasync-awaitstructured-concurrency

Call to actor-isolated instance method in a synchronous actor-isolated context


I have an actor protocol declared like this:

protocol MyActorProtocol: Actor {
    func foo()
}

There's an actor which conforms to the protocol:

actor MyImplementation1: MyActorProtocol {
    func foo() {}
}

Now I need to add a proxy:

actor MyImplementation1Proxy: MyActorProtocol {
    let impl: MyActorProtocol
    init(impl: MyActorProtocol) {
        self.impl = impl
    }

    func foo() {
        // Error-1: Call to actor-isolated instance method 'foo()' in a synchronous actor-isolated context
        // impl.foo()


        // Error-2. 'await' in a function that does not support concurrency
        // await impl.foo() 


        // Success-3. only this passes the compiler check
        Task { await impl.foo() }
    }
}

I want to understand such points:

  1. Why is it possible for actor protocol to have non-async methods declared, without an explicit nonisolated keyword?
  2. Let's say it's possible to have non-async methods, then why would I ever have Error-1 in my code?
  3. Given MyImplementation1Proxy also conforms to MyActorProtocol, and MyImplementation1.foo must be called in Task (for whatever reason), then it feels MyImplementation1.foo is "kind of async", so MyImplementation1Proxy.foo should have this "kind of async context" as well, so why do I have Error-2?
  4. From Error-2 looks like the method is just "non async", but when I tried to introduce a non-actor implementation, got Call to actor-isolated instance method 'foo()' in a synchronous nonisolated context, which is fair but again leads to question 1:
class MyImplementation2 {
   let impl: MyActorProtocol
   init(impl: MyActorProtocol) {
       self.impl = impl
   }

   func bar() {
       impl.foo()
   }
}

Thanks in advance.


Solution

  • You are conflating isolated vs non-isolated and async vs sync, which are orthogonal distinctions.

    A method isolated to some actor instance can be synchronously called from a context that is isolated to that actor instance. Otherwise, it has to be called asynchronously, i.e. with await. Calling an isolated method from a context not isolated to that actor also involves an "actor hop", which has some requirements about Sendable, but I digress.

    An async method must be called asynchronously everywhere. You can only use await in the body of an async method.

    You can declare all four combinations of these in an actor, and there is no reason why any of these combinations can't be required by a protocol.

    actor Foo {
        func f1() { // non-async isolated
            
        }
        
        nonisolated func f2() { // non-async non-isolated
            
        }
        
        func f3() async { // async isolated
            
        }
        
        nonisolated func f4() async { // nonisolated async
            
        }
    }
    

    You cannot do await impl.foo() simply because foo is not async (though it is isolated).

    impl.foo() is not valid either, because impl.foo is isolated to impl, but you are doing this call from a context that is isolated to self (the proxy). These are different actors. Note that this is not just because their types are different, but because self and impl are different instances. This is the same reason as why you cannot do something like this:

    actor Foo {
        func foo() {
        }
        func bar() {
            // Foo().foo() is isolated to the new instance of Foo, not self!
            Foo().foo()
        }
    }
    

    To make this proxy work, you have to convince Swift that once execution is isolated to self (the MyImplementation1Proxy), it is also isolated to impl.

    You should implement unownedExecutor to return that of impl's. Then, wrap the call with assumeIsolated.

    actor MyImplementation1Proxy: MyActorProtocol {
        let impl: MyActorProtocol
        
        nonisolated var unownedExecutor: UnownedSerialExecutor {
            impl.unownedExecutor
        }
        
        init(impl: MyActorProtocol) {
            self.impl = impl
        }
    
        func foo() {
            impl.assumeIsolated {
                $0.foo()
            }
        }
    }
    

    When someone calls MyImplementation1Proxy.foo, the call is executed by MyImplementation1Proxy.unownedExecutor, which happens to be the same executor as whatever its impl is using. assumeIsolated checks that indeed, the current executor is impl.unownedExecutor, and runs foo.