[译]Parallel programming with Swift: Operations

855 阅读10分钟

用Swift进行并行编程:Operation

原文 《# Parallel programming with Swift: Operations》
作者 | Jan Olbrich
翻译 | JACK
编辑 | JACK

OperationQueue

1_rB7311Cd1kE0EPVmBFtVFA.jpeg 让我们回顾一下:OperationQueue 是 Cocoa 在 GCD 上的高级抽象。准确地说,它是 dispatch_queue_t 的抽象。它与队列一样,可以添加任务。在 OperationQueue 中,这些任务就是Operation对象。在执行操作时,我们需要知道它所在的线程。例如我们想更新 UI,我们需要在 MainOperationQueue 中进行。除此之外,我们使用自己创建的操作队列。

let operationQueue: OperationQueue = OperationQueue()

区别于 dispatch_queue_t,OperationQueue 能指定队列中可以同时执行的Operations的最大限制。

let operationQueue: OperationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1

Operation

你可以把 Operation 理解为 dispatch block 的高级抽象。但他们还是有一些区别的。例如,当一个 dispatch block 执行几毫秒时,一个 Operation 可能执行了几分钟甚至更长时间。由于 Operation 是类,我们可以子类化 Operation 对象来封装我们的业务逻辑。这样只要封装良好,当逻辑变化时,我们可以做到最小化的变更(例如数据库层)。

Operation的生命周期

1_IqPgAYOd0iXnxz60ezRe2Q.png

在 Operation 的生命周期中,会经历几个不同的阶段。当被添加到队列中时,它处于 Pending 状态,等待触发条件。一旦条件满足,它就会进入 Ready 状态,如果有一个空槽,它就会开始执行,进入 Executing 状态。完成所有任务后,它将进入 Finished 状态并从 OperationQueue 中删除。在每个状态下(除了 Finished),操作都可以被取消。

取消Operation的执行

取消操作非常简单。根据 Operation 的不同,取消可能会产生不同的结果。例如,进行网络请求时,取消可能会导致中断此请求。在导入数据时,可能意味着丢弃已传输的数据。 那么如何取消操作呢?你只需调用 cancel()。这将改变 isCancelled 属性。

let op = DemoOperation()
OperationQueue.addOperations([op], waitUntilFinished: false)

op.cancel()

注意,取消 Operation 会导致它丢掉所有条件尽快完成从 Executing 到 Finished 的状态改变。而进入完成状态是 Operation 从队列中移除的唯一方法。

如果你忘记检查取消标志(isCancelled),你可能会看到 Operation 依然在执行,即便你已经取消了它。还要注意,Operation 很容易受到竞争条件的影响。比如按下按钮并设置标志可能需要几微秒。在此期间,Operation 可能已经完成并从队列中移除,那么调用 cancel() 将不再有效。

准备就绪

准备状态也是由一个布尔类型的属性进行描述的。这意味着 Operation 已准备就绪并且正在等待队列启动它。在串行队列中,优先进入准备状态的 Operation 优先执行,即使它可能位于队列中的末端。如果有多个 Operation 同时进入准备状态,那它们的执行顺序则由优先级决定。一个 Operation 只有在它的所有依赖都执行完成后才会进入准备状态。

依赖

依赖是 Operation 的最大特征之一。我们可以创建任务,并通过指定依赖规定其他任务需要先执行,然后才能执行创建的任务。同时,对于可以并行的任务,我们也可以通过指定依赖关系来决定执行的先后。这写都是可以通过调用 addDependency() 来完成的。

operation2.addDependency(operation1) //execute operation1 before operation2

任何具有依赖的 Operation 默认只有在其所有依赖执行完成后才会进入准备状态。但是,在取消依赖后,任务如何继续由你决定。

为了便于理解,我们可以通过创建自己的运算符(==>)来添加依赖。如下,也就是说,按照从左到右的顺序执行操作。

precedencegroup OperationChaining {
    associativity: left
}
infix operator ==> : OperationChaining

@discardableResult
func ==><T: Operation>(lhs: T, rhs: T) -> T {
    rhs.addDependency(lhs)
    return rhs
}


operation1 ==> operation2 ==> operation3 // Execute in order 1 to 3

依赖的一大优势是依赖可以跨队列使用。同时,这会产生意外的锁定行为。例如,当UI更新依赖于一个后台的操作,而这个更新行为阻塞了其他操作的执行,因此可能会造成 UI 线程卡顿。此外,还要注意循环依赖。如果操作 A 依赖于操作 B 并且 B 依赖于 A,就是这种情况。这样它们都在等待另一个操作执行,因此会产生死锁。

完成状态

当一个 Operation 执行结束,会进入到完成状态,并回调 completionBlock

let op1 = Operation()

op.completionBlock = {
    print("done")
}

示例

通过对上面基础知识的掌握,让我们来创建一个简单的 Operation 结构。通过打印"Hello world"来演示异步执行、操作依赖以及操作合并。让我们开始吧!

AsyncOperation

首先,我们创建一个Operation对象,以此来创建异步任务。如下,创建一个子类,并且让其可以异步执行任务:

import Foundation

class AsyncOperation: Operation {
    override var isAsynchronous: Bool {
        return true
    }
    
    var _isFinished: Bool = false
    
    override var isFinished: Bool {
        set {
            willChangeValue(forKey: "isFinished")
            _isFinished = newValue
            didChangeValue(forKey: "isFinished")
        }
        
        get {
            return _isFinished
        }
    }

    var _isExecuting: Bool = false
    
    override var isExecuting: Bool {
        set {
            willChangeValue(forKey: "isExecuting")
            _isExecuting = newValue
            didChangeValue(forKey: "isExecuting")
        }
        
        get {
            return _isExecuting
        }
    }
    
    func execute() {
    }
    
    override func start() {
        isExecuting = true
        execute()
        isExecuting = false
        isFinished = true
    }
}

如你所见,我们不得不重写属性 isFinishedisExecuting。参照官方文档,重写时还要遵循KVO(...In your custom implementation, you must generate KVO notifications for the key path...),否则 OperationQueue 将无法观察到操作状态的改变。在 start() 方法中,我们管理了操作从执行到结束的状态。我们创建了一个 execute() 方法,这将由子类来进行实现。

补充: 其实注意看官方文档,在这两个属性的开头和结尾处,官方针对同步和异步两种情况作了分别说明(When implementing a concurrent operation object, you must override the implementation of this property...You do not need to reimplement this property for nonconcurrent operations.)

有关KVO部分,不熟悉的同学需要先理解一下KVO的底层实现原理。

TextOperation

import Foundation

class TextOperation: AsyncOperation {
    let text: String
    
    init(text: String) {
        self.text = text
    }
    
    override func execute() {
        print(text)
    }
}

在这里,我们在初始化方法中传入了 text,并在 execute() 方法中进行打印。

GroupOperation

为了合并操作,我们在这里创建了一个 GroupOperation。

import Foundation

class GroupOperation: AsyncOperation {
    let queue = OperationQueue()
    var operations: [AsyncOperation] = []
    
    override func execute() {
        print("group started")
        queue.addOperations(operations, waitUntilFinished: true)
        print("group done")
    }
}

如你所见,我们创建了一个数组,后面我们的子类将在其中添加它们的操作。执行时,我们只需将 Operation对象 添加到队列中即可。通过这种方式,我们确保这些操作按序执行。调用 addOperations([Operation], waitUntilFinished: true) 会导致队列阻塞,直到添加到队列里的操作执行完成。之后 GroupOperation 会变成已完成状态。

HelloWorldOperation

现在,我们终于到最后一步了。创建 TextOperation对象,设置依赖,并将他们添加到 Operations 数组中。这时,我们只需实例化 HelloWorldOperation,然后执行 start() 方法,就可以看到执行结果了。

import Foundation

class HelloWorldOperation: GroupOperation {
    override init() {
        super.init()
        
        let op = TextOperation(text: "Hello")
        let op2 = TextOperation(text: "World")

        op2.addDependency(op)
        
        operations = [op2, op]
    }
}

创建观察者(Operation Observer)

那么,我们如何知道操作是否已经完成?一种方式是实现 competionBlock,另一种方式就是为操作注册一个观察者。接下来,我们创建一个监听类,使其通过KVO,完成操作状态的监听。

import Foundation

class OperationObserver: NSObject {
    init(operation: AsyncOperation) {
        super.init()
        operation.addObserver(self, forKeyPath: "finished", options: .new, context: nil)
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        guard let key = keyPath else {
            return
        }
        
        switch key {
        case "finished":
            print("done")
        default:
            print("doing")
        }
    }
}

数据传递

上面的Demo,仅仅是打印 "Hello World",并没有传递数据的必要,但我们还是来快速的看一下,如何在操作间进行数据传递。最简单的方式就是使用 BlockOperation。通过它,我们可以为下一个操作设置属性,进行数据传递。不要忘记设置依赖,否则可能造成因操作不会及时执行,导致数据传递不正确。

let op1 = Operation1()
let op2 = Operation2()


let adapter = BlockOperation() { [unowned op1, unowned op2] in
    op2.data = op1.data
}

adapter.addDependency(op1)
op2.addDependency(adapter)

queue.addOperations([op1, op2, adapter], waitUntilFinished: true)

错误处理

另外一件事就是我们还没有添加错误处理的机制。说实话,我还没有找到一个很好的方法来做到这一点。一种选择是添加一个 finished(withErrors:) 方法,并让每一个继承自 AsyncOperation 的对象调用它而不是在 start() 中进行处理。这样,我们可以检查错误并将其添加到一个错误列表中。假设有一个依赖于操作 B 的操作 A。突然,操作 B 以某些错误结束。在这种情况下,操作 A 可以检查这个数组并中止。

class GroupOperation: AsyncOperation {
    let queue = OperationQueue()
    var operations: [AsyncOperation] = []
    var errors: [Error] = []
    
    override func execute() {
        print("group started")
        queue.addOperations(operations, waitUntilFinished: true)
        print("group done")
    }
  
    func finish(withError errors: [Error]) {
        self.errors += errors
    }
}

注意,子操作需要对应处理它们的状态,并且我们需要在 AsyncOperation 类中进行一些修改才能使其工作。

但与往常一样,有很多方法,而这只是其中一种。你还可以通过观察者来监听错误值。

怎么做并不重要。你只要确保操作执行后会自行清理。例如:如果通过 CoreData 上下文对象进行写入 而出现问题,你希望清理此上下文。否则,你可能会得到不一致的状态和结果。

UI Operations

Operation 的使用不仅仅局限于那些你看不到的元素(比如数据请求),你在程序中所作的一切都可以是操作(尽管我不建议你这么做)。有些事情你把它看成是操作会更简单。让我们来看一个使用 Operation 完成一个对话框显示处理的例子:

import Foundation

class UIOperation: AsyncOperation {
    let viewController: UIViewcontroller!
  
    override func execute() {
        let alert = UIAlertController(title: "My Alert", message: @"This is an alert.", preferredStyle: .alert) 
        alert.addAction(UIAlertAction(title: "OK", style: .`default`, handler: { _ in 
              self.handleInput()
        }))
        viewController.present(alert, animated: true, completion: nil) 
    }
  
    func handleInput() {
        //do something and continue operation
    }
  
}

UIOperation 将会在按下OK时停止执行。此后,它会进入完成状态,而所有依赖于UIOperation 的其他操作会继续执行。

互斥

考虑到我们可以对 UI 使用 Operation,这就带来了一个新的问题。当网络不可用时通常会显示提示框,你可能会因此创建一系列操作,这很容易导致所有这些操作都会创建一个显示网络连接问题的弹框。结果,我们会同时弹出多个对话框,并且不知道哪个是第一个,哪个是第二个。所以我们必须让这些操作是互斥的。

尽管这个想法很复杂,但它很容易通过依赖来实现。只需在这些操作之间创建一个依赖关系就完成了。唯一的问题在于如何在任何需要的时候及时访问到这些操作对象。我们可以通过命名操作,然后访问 OperationQueue 并搜索名称来解决。这样你就不必搞一个变量一直保持对 Operation 对象的引用了。

let op1 = Operation()
op1.name = "Operation1"

OperationQueue.main.addOperations([op1], waitUntilFinished:false)
let operations = OperationQueue.main.operations

operations.map { op in
    if op.name == "Operation1" {
        op.cancel()
    }
}

结论

在并发编程中,Operation是一个很不错的控制并发的工具。但我们要合理的使用它,对于切换线程或任务这样很快就能执行完成的小人物,可能并没有必要使用 Operation。相比而言,GCD 才是更好的解决方案。而且,在项目中,想要使用好 Operation 也绝非易事,它可能会产生一些比较麻烦的问题。

补充: 上面的 GroupOperations 部分,代码中还存在一个Bug,我会在后面的文章更新中修复它。