olha
olha

Reputation: 2272

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.

Upvotes: 6

Views: 8079

Answers (2)

Sweeper
Sweeper

Reputation: 274500

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.

Upvotes: 6

Frankie Baron
Frankie Baron

Reputation: 110

Why is it possible for actor protocol to have non-async methods declared, without an explicit nonisolated keyword?

Actor methods are inherently synchronous. However those synchronous methods can be part of some broader async routine. Actors isolate their methods in order to protect their shared mutable state from being accessed by some other concurrent routines.

Mutation can occur only from an isolated method within an Actor. Isolated methods cannot be called from a synchronous context outside of an Actor.

actor MyActor {

    var state = 0

    func foo() {
        state += 1
    }

}

let actor = MyActor()
actor.foo() // Error: Call to actor-isolated instance method 'foo()' in a synchronous nonisolated context

If you want to call a method from a synchronous context outside of an Actor, you need to make it nonisolated. But this will imply no mutation, as Actors are meant to protect their state.

actor MyActor {

    var state = 0

    nonisolated func foo() {
        state += 1 // Error: Actor-isolated property 'state' can not be mutated from a non-isolated context
    }

}

let actor = MyActor()
actor.foo()

Nonisolated work only if we won't mutate Actor's state

actor MyActor {

    nonisolated func foo() {
        print("I'm not mutating here..")
    }

}

let actor = MyActor()
actor.foo()

To sum up:

Methods in Actors are not inherently async, but if you want to call them from outside and still respect isolation rules, it needs to be done from an asynchronous context. On the other hand, nonisolated methods must be immutable, so they respect isolation rules by design and they can be safely called from a synchronous context.

Let's say it's possible to have non-async methods, then why would I ever have Error-1 in my code?

Because MyImplementation1Proxy.foo() is synchronous and you are calling an actor-isolated method MyImplementation1.foo() there, thus not respecting MyImplementation1 isolation rules.

EDIT 1:

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

MyImplementation1.foo is not async, as I mentioned "Actor methods are inherently synchronous". And so MyImplementation1Proxy.foo is synchronous. But they need to be called from an asynchronous context anyway to respect their isolation rules, unless they are nonisolated. I know, it's a bit tricky.

If you want to use await inside of those methods, it's clear that you need to explicitly make them asynchronous, because they are not. Thus Error-2

Upvotes: 1

Related Questions