Search code examples
swiftconcurrencyswift6

Actor conformance to protocol with an async func requirement


I have a protocol with an sync function requirement

class NonSendable {}

protocol P1 {
  func doSomething(_ nonSendable: NonSendable) async
}

When I conform to this protocol using an actor, while the swift concurrency checking is on, I get a warning a warning

actor P1Actor: P1 {
 // Warning: Non-sendable type 'NonSendable?' returned by actor-isolated instance method 'doSomething' satisfying protocol requirement cannot cross actor boundary
 func doSomething(_ nonSendable: NonSendable) {}
}

But when I change the actor to a struct or a class the warning goes aways.

struct P1Struct: P1 {
 // No warning
 func doSomething(_ nonSendable: NonSendable) async {}
}

I don't understand why I am getting the warning in the actor. I am confused because doSomething will be triggered from an async context in all cases, so why do the parameters need to be Sendable when the function is isolated to an actor?


Solution

  • Recall that a value of a non-Sendable type cannot cross actor boundaries.

    When you call doSomething from a non-isolated context, this is exactly what the argument passed to doSomething will be doing - being sent to P1Actor.

    Surely you'd agree that this violates the rules of Sendable:

    class Foo {
        let x = NonSendable()
        func foo() async {
            let p1 = P1Actor()
            // x is not Sendable but its being sent to p1!
            await p1.doSomething(x) // error here!
        }
    }
    

    If doSomething doesn't satisfy any protocol requirements (e.g. if P1Actor doesn't conform to P1), Swift still allows you to declare this method, because it can emit errors at the call site (like the above example). After all, doSomething can still be safely called from a context isolated to self, and there is no actor hops.

    But if doSomething is a protocol requirement, there are cases where Swift can no longer tell, at the call site, whether a call to doSomething sends the non-sendable value to another actor. Consider:

    class Foo {
        let x = NonSendable()
        func foo(p: any P1) async {
            // is x being sent across actor boundaries? It depends on whether p is an actor!
            await p.doSomething(x)
        }
    }
    

    At compile time it is not known whether p is an actor or not, so Swift must forbid you from declaring doSomething in the first place.


    One way to fix this is to make the protocol witness nonisolated. Have it extract sendable things out of the non-sendable parameter value, then call the actual isolated implementation. As an example:

    actor P1Actor: P1 {
        nonisolated func doSomething(_ nonSendable: NonSendable) async {
            await someIsolatedImplementation(
                nonSendable.someString, nonSendable.someNumber, nonSendable.someFlag
            )
        }
        
        // the parameters of this are all sendable!
        func someIsolatedImplementation(_ a: String, _ b: Int, _ c: Bool) {
            // do actual work here...
        }
    }