swiftswift-concurrency

Mutating actor state in the nonisolated method required by protocol


How Actor can Conform to protocol and mutate its internal state in nonisolated method that is required by this protocol. There is no write access in nonisolated method to isolated property by actor.

protocol P : NSObjectProtocol, Sendable {
    func mutateState()
}

actor A: NSObject, P {
    var number: Int = 0

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

I was trying to use queue to synchronize access like this:

actor A: NSObject, P {
    private let queue = DispatchSerialQueue(label: "example", qos: .utility)

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        queue.asUnownedSerialExecutor()
    }

    var number: Int = 0

    nonisolated func mutateState() {
        queue.async { [self] in
            number += 1 // <- Actor-isolated property 'number' can not be mutated from a Sendable closure
        }
    }
}

I know last example is correct from data race point of view but compiler seems to don't know this. So my question is how to do this correctly co Compiler will be happy. In the project we have SWIFT_STRICT_CONCURRENCY configured to Complete. The P protocol is not owned by us and we can't modify this. Of course this is some extracted example in the simples form just to nail problem. The real protocol we try to implement is AVAssetWriterDelegate from AVFoundation. When we try to replace actor to final class then another problem: Stored property 'number' of 'Sendable'-conforming class 'A' is mutable.

Any guide how to handle such cases?


Solution

  • If you are fine with asynchronously incrementing number, then you can just wrap it in a Task,

    nonisolated func mutateState() {
        Task {
            await incrementNumber()
        }
    }
    
    func incrementNumber() {
        number += 1
    }
    

    Note that await number += 1 is not allowed by design, because it is syntactically unclear when the actor hop happens.

    If the increment needs to be done synchronously, you need to write a Sendable or @unchecked Sendable class, with synchronisation mechanisms. For example:

    import Synchronization
    
    final class A: NSObject, P {
        let number = Mutex<Int>(0)
    
        func mutateState() {
            number.withLock {
                $0 += 1
            }
        }
    }
    

    Side note: to convince Swift that the code will be isolated to self at runtime, you can use assumeIsolated.

    queue.async {
        self.assumeIsolated {
            $0.number += 1
        }
    }
    

    But obviously in this case you can just create a Task and don't need to use your own queue.