2
\$\begingroup\$

This is a subclass of Operation that attempts to combine the requirements of KVO compliance and thread safety. Do I have the KVO notifications and thread safety done correctly?

class ConcurrentOperation: Operation {
    private let stateQueue = DispatchQueue(label: "operation", attributes: .concurrent)
    private var _isExecuting = false
    private var _isFinished = false

    override var isExecuting: Bool {
        get {
            return stateQueue.sync { _isExecuting }
        }
        set {
            stateQueue.async(flags: .barrier) {
                self._isExecuting = newValue
            }
        }
    }
    
    override var isFinished: Bool {
        get {
            return stateQueue.sync { _isFinished }
        }
        set {
            stateQueue.async(flags: .barrier) {
                self._isFinished = newValue
            }
        }
    }
    
    override var isAsynchronous: Bool {
        return true
    }
    
    override func start() {
        if isCancelled {
            finish()
            return
        }
        print("starting...")
        willChangeValue(forKey: "isExecuting")
        isExecuting = true
        didChangeValue(forKey: "isExecuting")
        main()
    }
    
    override func main() {
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            if self.isCancelled {
                print("cancelled")
            } else {
                print("done")
            }
            self.finish()
        }
    }

    func finish() {
        if isExecuting {
            willChangeValue(forKey: "isExecuting")
            isExecuting = false
            didChangeValue(forKey: "isExecuting")
        }
        
        if !isFinished {
            willChangeValue(forKey: "isFinished")
            isFinished = true
            didChangeValue(forKey: "isFinished")
        }
    }
}

let op1 = ConcurrentOperation()
let op2 = ConcurrentOperation()

let opQueue = OperationQueue()
opQueue.maxConcurrentOperationCount = 1
opQueue.addOperation(op1)
opQueue.addOperation(op2)
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Yes, this looks fine re KVO and thread-safety.


A few stylistic observations:

  1. I would personally move the willChangeValue and didChangeValue into the setters. It feels a little cluttered to have them sprinkled about the implementation. This pattern in your code snippet harkens back to the original Objective-C implementation, but Swift provides us opportunities to tidy up this code.

  2. I know you got your keynames right, but I always use #keyPath(isExecuting) and #keyPath(isFinished) to have the compiler enforce the string values.

  3. I would make both isExecuting and isFinished read-only properties (with private(set)) as no external code has any business changing these values.

  4. Personally, I would pull the main implementation out of ConcurrentOperation and put it into its own subclass. Once you have this ConcurrentOperation defined, you will find yourself using it everywhere you do not want to repeat all this boilerplate code in every concurrent operation subclass.

Thus:

class ConcurrentOperation: Operation {
    private let stateQueue = DispatchQueue(label: "operation", attributes: .concurrent)

    private var _isExecuting = false
    private var _isFinished = false

    override private(set) var isExecuting: Bool {
        get {
            stateQueue.sync { _isExecuting }
        }

        set {
            willChangeValue(forKey: #keyPath(isExecuting))
            stateQueue.async(flags: .barrier) {
                self._isExecuting = newValue
            }
            didChangeValue(forKey: #keyPath(isExecuting))
        }
    }

    override private(set) var isFinished: Bool {
        get {
            stateQueue.sync { _isFinished }
        }

        set {
            willChangeValue(forKey: #keyPath(isFinished))
            stateQueue.async(flags: .barrier) {
                self._isFinished = newValue
            }
            didChangeValue(forKey: #keyPath(isFinished))
        }
    }

    override var isAsynchronous: Bool {
        return true
    }

    override func start() {
        if isCancelled {
            finish()
            return
        }
        isExecuting = true
        main()
    }

    override func main() {
        fatalError("This must be overridden")
    }

    func finish() {
        if isExecuting {
            isExecuting = false
        }

        if !isFinished {
            isFinished = true
        }
    }
}

And

class TwoSecondOperation: ConcurrentOperation {
    override func main() {
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            if self.isCancelled {
                print("cancelled")
            } else {
                print("done")
            }
            self.finish()
        }
    }
}

Going a step further, we generally strive for cancelable Operation subclasses, where possible. E.g., in this example, rather than using a closure with asyncAfter, we should instead used a DispatchWorkItem. Then we could make this operation truly cancelable (rather than having to wait two seconds before the cancelation is recognized).

For example, perhaps something like the following:

class TwoSecondOperationImproved: ConcurrentOperation {
    private var item: DispatchWorkItem?

    override init() {
        super.init()

        item = DispatchWorkItem {
            if self.isCancelled {
                print("cancelled")
            } else {
                print("done")
            }
            self.finish()
        }
    }

    override func main() {
        if let item = item {
            DispatchQueue.global().asyncAfter(deadline: .now() + 2, execute: item)
        }
    }

    override func cancel() {
        super.cancel()
        item?.cancel()
        finish()
    }

    override func finish() {
        item = nil
        super.finish()
    }
}

In short, if the asynchronous task being wrapped in ConcurrentOperation offers cancelation, make sure to override cancel, too.


If you prefer, you can also use locks to synchronize the properties of ConcurrentOperation:

class ConcurrentOperation: Operation {
    private let lock = NSLock()

    private var _isExecuting = false
    private var _isFinished = false

    override private(set) var isExecuting: Bool {
        get {
            synchronized { _isExecuting }
        }

        set {
            willChangeValue(forKey: #keyPath(isExecuting))
            synchronized { _isExecuting = newValue }
            didChangeValue(forKey: #keyPath(isExecuting))
        }
    }

    override private(set) var isFinished: Bool {
        get {
            synchronized { _isFinished }
        }

        set {
            willChangeValue(forKey: #keyPath(isFinished))
            synchronized { _isFinished = newValue }
            didChangeValue(forKey: #keyPath(isFinished))
        }
    }

    override var isAsynchronous: Bool {
        return true
    }

    override func start() {
        if isCancelled {
            finish()
            return
        }
        isExecuting = true
        main()
    }

    override func main() {
        fatalError("This must be overridden")
    }

    func finish() {
        if isExecuting {
            isExecuting = false
        }

        if !isFinished {
            isFinished = true
        }
    }

    private func synchronized<T>(block: () throws -> T) rethrows -> T {
        try lock.synchronized { try block() }
    }
}

extension NSLocking {
    func synchronized<T>(block: () throws -> T) rethrows -> T {
        lock()
        defer { unlock() }
        return try block()
    }
}
\$\endgroup\$
8
  • \$\begingroup\$ What I don't understand is why it's safe to call didChangeValue(forKey: #keyPath(isFinished)) in the setter right after stateQueue.async(flags: .barrier) {}. If it's asynchronous, how can we be certain the KVO will be notified before the new value is applied in the closure? Wouldn't it be safer to move the KVO notifications inside the closure? And as a side note, would it really be so bad to dispatch the setter's queue synchronously? \$\endgroup\$ Commented Dec 28, 2021 at 6:46
  • \$\begingroup\$ It’s safe to call didChangeValue outside the closure because the observer will use the getter to determine what the value actually is, and that will wait for the barrier anyway. For this reason, it’s actually important to call willChangeValue and didChangeValue outside of the synchronization because you can otherwise deadlock/crash. That having been said, I actually use locks in my code (i.e., everything synchronous) because although reader-writer feels like it should be more efficient, in practice it isn’t; it’s slower. And simple lock synchronization is easier to reason about, IMHO. \$\endgroup\$
    – Rob
    Commented Dec 28, 2021 at 8:11
  • \$\begingroup\$ When you say you use locks in your code, do you just mean that all of your thread-safe properties have synchronous dispatching in their setters? Can you append your answer to include an example of what this would look like? \$\endgroup\$ Commented Dec 28, 2021 at 16:30
  • \$\begingroup\$ I should have mentioned that I already added that to the end of the answer… \$\endgroup\$
    – Rob
    Commented Dec 28, 2021 at 16:47
  • 1
    \$\begingroup\$ I've read a lot of your posts on SO and you are definitely a guru in concurrency and your help is very much appreciated! \$\endgroup\$ Commented Dec 28, 2021 at 18:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.