iOS 并发编程之 Operation Queues - 雷纯锋的技术博客
术语
首先,我们先来了解一下在 iOS 并发编程中非常重要的三个术语,这是我们理解 iOS 并发编程的基础:
进程(process),指的是一个正在运行中的可执行文件。每一个进程都拥有独立的虚拟内存空间和系统资源,包括端口权限等,且至少包含一个主线程和任意数量的辅助线程。另外,当一个进程的主线程退出时,这个进程就结束了;
线程(thread),指的是一个独立的代码执行路径,也就是说线程是代码执行路径的最小分支。在 iOS 中,线程的底层实现是基于POSIX threads API的,也就是我们常说的 pthreads ;
任务(task),指的是我们需要执行的工作,是一个抽象的概念,用通俗的话说,就是一段代码。
串行 vs. 并发
从本质上来说,串行和并发的主要区别在于允许同时执行的任务数量。串行,指的是一次只能执行一个任务,必须等一个任务执行完成后才能执行下一个任务;并发,则指的是允许多个任务同时执行。
同步 vs. 异步
同样的,同步和异步操作的主要区别在于是否等待操作执行完成,亦即是否阻塞当前线程。同步操作会等待操作执行完成后再继续执行接下来的代码,而异步操作则恰好相反,它会在调用后立即返回,不会等待操作的执行结果。
队列 vs. 线程
有一些对 iOS 并发编程模型不太了解的同学可能会对队列和线程产生混淆,不清楚它们之间的区别与联系,因此,我觉得非常有必要在这里简单地介绍一下。在 iOS 中,有两种不同类型的队列,分别是串行队列和并发队列。正如我们上面所说的,串行队列一次只能执行一个任务,而并发队列则可以允许多个任务同时执行。iOS 系统就是使用这些队列来进行任务调度的,它会根据调度任务的需要和系统当前的负载情况动态地创建和销毁线程,而不需要我们手动地管理。
iOS 的并发编程模型
在其他许多语言中,为了提高应用的并发性,我们往往需要自行创建一个或多个额外的线程,并且手动地管理这些线程的生命周期,这本身就已经是一项非常具有挑战性的任务了。此外,对于一个应用来说,最优的线程个数会随着系统当前的负载和低层硬件的情况发生动态变化。因此,一个单独的应用想要实现一套正确的多线程解决方案就变成了一件几乎不可能完成的事情。而更糟糕的是,线程的同步机制大幅度地增加了应用的复杂性,并且还存在着不一定能够提高应用性能的风险。
然而,值得庆幸的是,在 iOS 中,苹果采用了一种比传统的基于线程的系统更加异步的方式来执行并发任务。与直接创建线程的方式不同,我们只需定义好要调度的任务,然后让系统帮我们去执行这些任务就可以了。我们可以完全不需要关心线程的创建与销毁、以及多线程之间的同步等问题,苹果已经在系统层面帮我们处理好了,并且比我们手动地管理这些线程要高效得多。
因此,我们应该要听从苹果的劝告,珍爱生命,远离线程。不过话又说回来,尽管队列是执行并发任务的首先方式,但是毕竟它们也不是什么万能的灵丹妙药。所以,在以下三种场景下,我们还是应该直接使用线程的:
用线程以外的其他任何方式都不能实现我们的特定任务;
必须实时执行一个任务。因为虽然队列会尽可能快地执行我们提交的任务,但是并不能保证实时性;
你需要对在后台执行的任务有更多的可预测行为。
Operation Queues vs. Grand Central Dispatch (GCD)
简单来说,GCD是苹果基于C语言开发的,一个用于多核编程的解决方案,主要用于优化应用程序以支持多核处理器以及其他对称多处理系统。而 Operation Queues 则是一个建立在GCD的基础之上的,面向对象的解决方案。它使用起来比GCD更加灵活,功能也更加强大。下面简单地介绍了 Operation Queues 和GCD各自的使用场景:
Operation Queues :相对GCD来说,使用 Operation Queues 会增加一点点额外的开销,但是我们却换来了非常强大的灵活性和功能,我们可以给 operation 之间添加依赖关系、取消一个正在执行的 operation 、暂停和恢复 operation queue 等;
GCD:则是一种更轻量级的,以FIFO的顺序执行并发任务的方式,使用GCD时我们并不关心任务的调度情况,而让系统帮我们自动处理。但是GCD的短板也是非常明显的,比如我们想要给任务之间添加依赖关系、取消或者暂停一个正在执行的任务时就会变得非常棘手。
关于 Operation 对象
在 iOS 开发中,我们可以使用 NSOperation 类来封装需要执行的任务,而一个 operation 对象(以下正文简称 operation )指的就是 NSOperation 类的一个具体实例。NSOperation 本身是一个抽象类,不能直接实例化,因此,如果我们想要使用它来执行具体任务的话,就必须创建自己的子类或者使用系统预定义的两个子类,NSInvocationOperation 和 NSBlockOperation 。
NSInvocationOperation:我们可以通过一个object和selector非常方便地创建一个 NSInvocationOperation ,这是一种非常动态和灵活的方式。假设我们已经有了一个现成的方法,这个方法中的代码正好就是我们需要执行的任务,那么我们就可以在不修改任何现有代码的情况下,通过方法所在的对象和这个现有方法直接创建一个 NSInvocationOperation 。
NSBlockOperation:我们可以使用 NSBlockOperation 来并发执行一个或多个 block ,只有当一个 NSBlockOperation 所关联的所有 block 都执行完毕时,这个 NSBlockOperation 才算执行完成,有点类似于dispatch_group的概念。
另外,所有的 operation 都支持以下特性:
支持在 operation 之间建立依赖关系,只有当一个 operation 所依赖的所有 operation 都执行完成时,这个 operation 才能开始执行;
支持一个可选的 completion block ,这个 block 将会在 operation 的主任务执行完成时被调用;
支持通过KVO来观察 operation 执行状态的变化;
支持设置执行的优先级,从而影响 operation 之间的相对执行顺序;
支持取消操作,可以允许我们停止正在执行的 operation 。
并发 vs. 非并发 Operation
通常来说,我们都是通过将 operation 添加到一个 operation queue 的方式来执行 operation 的,然而这并不是必须的。我们也可以直接通过调用start方法来执行一个 operation ,但是这种方式并不能保证 operation 是异步执行的。NSOperation 类的isConcurrent方法的返回值标识了一个 operation 相对于调用它的start方法的线程来说是否是异步执行的。在默认情况下,isConcurrent 方法的返回值是NO,也就是说会阻塞调用它的start方法的线程。
如果我们想要自定义一个并发执行的 operation ,那么我们就必须要编写一些额外的代码来让这个 operation 异步执行。比如,为这个 operation 创建新的线程、调用系统的异步方法或者其他任何方式来确保start方法在开始执行任务后立即返回。
在绝大多数情况下,我们都不需要去实现一个并发的 operation 。如果我们一直是通过将 operation 添加到 operation queue 的方式来执行 operation 的话,我们就完全没有必要去实现一个并发的 operation 。因为,当我们将一个非并发的 operation 添加到 operation queue 后,operation queue 会自动为这个 operation 创建一个线程。因此,只有当我们需要手动地执行一个 operation ,又想让它异步执行时,我们才有必要去实现一个并发的 operation 。
创建 NSInvocationOperation 对象
正如上面提到的,NSInvocationOperation 是 NSOperation 类的一个子类,当一个 NSInvocationOperation 开始执行时,它会调用我们指定的object的selector方法。通过使用 NSInvocationOperation 类,我们可以避免为每一个任务都创建一个自定义的子类,特别是当我们在修改一个已经存在的应用,并且这个应用中已经有了我们需要执行的任务所对应的object和selector时非常有用。
下面的示例代码展示了如何通过object和selector创建一个 NSInvocationOperation 对象。说明,本文中的所有示例代码都可以在这里OperationQueues找到,每一个类都有与之对应的测试类,充当client的角色,建议你在看完一个小节的代码时,运行一下相应的测试用例,观察打印的结果,以加深理解。
另外,我们在前面也提到了,NSInvocationOperation 类的使用可以非常的动态和灵活,其中比较显著的一点就是我们可以根据上下文动态地调用object的不同selector。比如说,我们可以根据用户的输入动态地执行不同的selector:
创建 NSBlockOperation 对象
NSBlockOperation 是 NSOperation 类的另外一个系统预定义的子类,我们可以用它来封装一个或多个block。我们知道GCD主要就是用来进行block调度的,那为什么我们还需要 NSBlockOperation 类呢?一般来说,有以下两个场景我们会优先使用 NSBlockOperation 类:
当我们在应用中已经使用了 Operation Queues 且不想创建 Dispatch Queues 时,NSBlockOperation 类可以为我们的应用提供一个面向对象的封装;
我们需要用到 Dispatch Queues 不具备的功能时,比如需要设置 operation 之间的依赖关系、使用KVO观察 operation 的状态变化等。
自定义 Operation 对象
当系统预定义的两个子类 NSInvocationOperation 和 NSBlockOperation 不能很好的满足我们的需求时,我们可以自定义自己的 NSOperation 子类,添加我们想要的功能。目前,我们可以自定义非并发和并发两种不同类型的 NSOperation 子类,而自定义一个前者要比后者简单得多。
对于一个非并发的 operation ,我们需要做的就只是执行main方法中的任务以及能够正常响应取消事件就可以了,其它的复杂工作比如依赖配置、KVO 通知等 NSOperation 类都已经帮我们处理好了。而对于一个并发的 operation ,我们还需要重写 NSOperation 类中的一些现有方法。接下来,我们将会介绍如何自定义这两种不同类型的 NSOperation 子类。
执行主任务
从最低限度上来说,每一个 operation 都应该至少实现以下两个方法:
一个自定义的初始化方法;
main方法。
我们需要用一个自定义的初始化方法来将创建的 operation 置于一个已知的状态,并且重写main方法来执行我们的任务。当然,我们也可以实现一些其他的额外方法,比如实现NSCoding协议来允许我们归档和解档 operation 等。下面的示例代码展示了如何自定义一个简单的 operation :
响应取消事件
当一个 operation 开始执行后,它会一直执行它的任务直到完成或被取消为止。我们可以在任意时间点取消一个 operation ,甚至是在它还未开始执行之前。为了让我们自定义的 operation 能够支持取消事件,我们需要在代码中定期地检查isCancelled方法的返回值,一旦检查到这个方法返回YES,我们就需要立即停止执行接下来的任务。根据苹果官方的说法,isCancelled方法本身是足够轻量的,所以就算是频繁地调用它也不会给系统带来太大的负担。
The isCancelled method itself is very lightweight and can be called frequently without any significant performance penalty.
通常来说,当我们自定义一个 operation 类时,我们需要考虑在以下几个关键点检查isCancelled方法的返回值:
在真正开始执行任务之前;
至少在每次循环中检查一次,而如果一次循环的时间本身就比较长的话,则需要检查得更加频繁;
在任何相对来说比较容易中止 operation 的地方。
看到这里,我想你应该可以意识到一点,那就是尽管 operation 是支持取消操作的,但却并不是立即取消的,而是在你调用了 operation 的cancel方法之后的下一个isCancelled的检查点取消的。
执行 Operation 对象
最终,我们需要执行 operation 来调度与其关联的任务。目前,主要有两种方式来执行一个 operation :
将 operation 添加到一个 operation queue 中,让 operation queue 来帮我们自动执行;
直接调用start方法手动执行 operation 。
添加 Operation 到 Operation Queue 中
就目前来说,将 operation 添加到 operation queue 中是最简单的执行 operation 的方式。另外,这里的 operation queue 指的就是 NSOperationQueue 类的一个具体实例。就技术上而言,我们可以在应用中创建任意数量的 operation queue ,但是 operation queue 的数量越多并不意味着我们就能同时执行越多的 operation 。因为同时并发的 operation 数量是由系统决定的,系统会根据当前可用的核心数以及负载情况动态地调整最大的并发 operation 数量。创建一个 operation queue 非常简单,跟创建其他普通对象没有任何区别:
NSOperationQueue*operationQueue=[[NSOperationQueuealloc]init];
创建好 operation queue 后,我们可以使用下面三个方法添加 operation 到 operation queue 中:
addOperation:,添加一个 operation 到 operation queue 中;
addOperations:waitUntilFinished:,添加一组 operation 到 operation queue 中;
addOperationWithBlock:,直接添加一个 block 到 operation queue 中,而不用创建一个 NSBlockOperation 对象。
在大多数情况下,一个 operation 被添加到 operation queue 后不久就会执行,但是也有很多原因会使 operation queue 延迟执行入队的 operation 。比如,我们前面提到了的,如果一个 operation 所依赖的其他 operation 还没有执行完成时,这个 operation 就不能开始执行;再比如说 operation queue 被暂停执行或者已经达到了它最大可并发的 operation 数。下面的示例代码展示了这三种方法的基本用法:
注意,在将一个 operation 添加到 operation queue 后就不要再修改这个 operation 了。因为 operation 被添加到 operation queue 后随时可能会执行,这个是由系统决定的,所以再修改它的依赖关系或者所包含的数据就很有可能会造成未知的影响。
尽管 NSOperationQueue 类是被设计成用来并发执行 operation 的,但是我们也可以强制一个 operation queue 一次只执行一个 operation 。我们可以通过setMaxConcurrentoperationCount:方法来设置一个 operation queue 最大可并发的 operation 数,因此将这个值设置成 1 就可以实现让 operation queue 一次只执行一个 operation 的目的。但是需要注意的是,虽然这样可以让 operation queue 一次只执行一个 operation ,但是 operation 的执行顺序还是一样会受其他因素影响的,比如 operation 的isReady状态、operation 的队列优先级等。因此,一个串行的 operation queue 与一个串行的 dispatch queue 还是有本质区别的,因为 dispatch queue 的执行顺序一直是FIFO的。如果 operation 的执行顺序对我们来说非常重要,那么我们就应该在将 operation 添加到 operation queue 之前就建立好它的依赖关系。
总结
看到这里,我想你对 iOS 的并发编程模型已经有了一定的了解。正如文中所说的,我们应该尽可能地直接使用队列而不是线程,让系统去与线程打交道,而我们只需定义好要调度的任务就可以了。一般情况下,我们也完全不需要去自定义一个并发的 operation ,因为在与 operation queue 结合使用时,operation queue 会自动为非并发的 operation 创建一个线程。Operation Queues 是对GCD面向对象的封装,它可以高度定制化,对依赖关系、队列优先级和线程优先级等提供了很好的支持,是我们实现复杂任务调度时的不二之选。