iOS 并发:NSOperation 与调度队列入门(1)

一直以来,并发都被视为 iOS 开发中的「洪水猛兽」。许多开发者都将其视为危险地带,唯恐避之而不及。更有谣传认为,多线程代码应该尽力避免。笔者同意,如果你对并发的了解不够深入,就容易造成危险。但是,危险往往是因为无知。想想吧,在人们的日常生活中,会经历多少危险的行为或活动?但是,一旦掌握其要领,也就是一碟小菜罢了。

并发就是一柄值得你学习使用并熟练掌握的双刃剑。它能帮助你打造高效、迅捷、响应及时的应用。于此同时,一旦误用,也会毫不留情地毁掉应用。因此,在开始编写并发代码之前,好好想想你为什么需要并发,你需要哪个 API 来解决问题?在 iOS 开发中,可用的 API 有很多。在本教程中,我们将探讨最常用的两个 API——NSOperation 以及调度队列。

ios-concurrency-featured

为什么需要并发?

假设你是有经验的 iOS 开发老手,不论你要创建什么样的应用,你都需要并发来提高应用的响应度与速度。以下是笔者总结的学习或使用并发能够带来的好处:

  • 利用 iOS 设备的硬件:现在,所有的 iOS 设备配备多核处理器,允许开发者并行执行多个任务。你应该通过此功能好好利用这些硬件。

  • 更好的用户体验:你很可能编写了调用 Web 服务,处理 IO,或执行一些繁重任务的代码。你也知道,在 UI 线程执行这些操作会冻结应用,使其无法响应用户的行为。一旦用户遭遇这类情况,他们的第一反应往往是结束应用。有了并发机制,这些任务都可以在背景线程中执行,而无需暂停主线程或烦扰到用户。用户可以点击应用中的按钮,滚动浏览或跳转目录,与此同时,那些繁重的加载任务则放到后台处理。

  • NSOperation 与调度队列这类 API 简化了并发的使用:创建并管理线程并非易事。这也是大多数开发者一听到并发、多线程代码这类术语就大惊失色的原因。iOS 其实提供了许多易于使用的并发 API,能大大简化开发者的工作。你不必担心创建线程或管理底层的部件,这些 API 会帮你搞定一切。使用这些 API 的另一个好处在于:它们能帮你轻易实现同步化,从而避免了竞争状态。当多个线程视图读取共享资源时,就会形成竞争状态,导致意想不到的结果。使用同步机制,就能防止资源在多个线程间的共享。

What do You Need to Know about Concurrency?

关于并发,你需要了解哪些内容?

本文将会解释理解并发所需的全部知识,彻底消除你对它的恐惧。首先,我们建议你了解一下块(blocks)(Swift 中的闭包),因为它们在并发 API 中广泛使用。之后,我们会探讨调度队列与 NSOperations。我们会详细介绍这些并发概念,它们的区别以及实现方法。

第一部分: GCD (Grand Central Dispatch)

GCD 是用于在系统 Unix 层管理并发代码、异步执行操作最为常用的 API。GCD 提供并管理任务队列。首先,了解一下队列是什么。

什么是队列?

队列是以先进先出(FIFO)原则管理对象的数据结构。队列与戏院售票窗口外的队伍很相似。戏票是以先到先得的次序售卖的。排在队伍前面的人会在队伍后面的人之前得到戏票。计算机科学中的队列也遵循似的原理:第一个添加到队列中的对象会第一个从队列中移除。

queue-line-2-1166050-1280x960

Photo credit: FreeImages.com/Sigurd Decroos
图片来源:FreeImages.com/Sigurd Decroos

调度队列

调度队列是在应用中实现异步、并发地执行任务的简单方法。在调度队列中,应用产生的任务会以块(代码块)的形式提交。目前,有两种调度队列:1、串行队列(Serial Queues),2、并发队列(Concurrent Queues)。在进一步了解两种队列的区别之前,你需要知道:分配给这两种队列的任务在执行时所处的线程与创建任务的线程相独立。换句话说,你创建了一些代码块,并将其提交给主线程中的调度队列。但是,所有的任务(也即代码块)会在单独的线程(而非主线程)中执行。

串行队列

如果你选择创建串行队列,该队列每次只能执行一个任务。同一个串行队列中的所有任务都会相互尊重,依次执行。然而,它们不会在意其他独立队列中的任务。这意味着,如果使用了多个串行队列,仍有可能并发地执行任务。例如,你可以创建两个串行队列,每个队列每次都只会执行一个任务,但是仍有可能出现两个任务同时执行的情况。

在管理共享资源时,串行队列的用处极大。它能保证对共享资源的访问是依次进行的,从而防止出现竞争状态。设想,只有一个售票窗口,但是有一群人想买戏票的场景。此处,售票窗口的职员就是共享资源。如果该职员不得不同时服务所有购票者,场面一定非常混乱。为了应对这种场景,人们被要求排成一列(串行队列),职员才能依次服务每位购票者。

不过,需要重申的是,这并不意味着戏院只能一次服务一名顾客。如果戏院开设两个以上的售票窗口,就能同时服务三名顾客。也即,使用多个串行队列,就能并行处理多项任务。

使用串行队列的好处如下:

  1. 保证依次访问共享资源,防止出现竞争状态。
  2. 任务以可预测的次序执行。当你向串行调度队列提交多个任务时,任务的执行次序与其插入次序一致。
  3. 你可以创建任意数量的串行队列。

并发队列

顾名思义,并发队列允许你并行执行多个任务。任务开始执行的次序遵照其加入队列的次序。但是,任务执行的过程都同步进行,不需要等待。并发队列保证任务开始执行的次序是确定的,但是你无法知道执行的次序,执行时长或在任意时间点同步执行的任务个数。

比如,你向某个并发队列提交了三个任务(任务1、2、3号)。这些任务会并发执行,开始执行的次序依照他们加入队列的次序。然而,它们的执行时长与完成时间并不一致。尽管任务2、3开始执行的时间比任务1晚,但它们仍有可能在任务1之前完成执行。最终,由系统决定任务执行的情况。

使用队列

了解了串行队列与并发队列的基本知识之后,现在来看看如何使用它们。默认情况下,系统为每个应用提供了一个串行队列与四个并发队列。主调度队列是全局可用的串行队列,在应用的主线程上执行任务。该队列用于更新应用的 UI,执行与 UIViews 更新相关的所有任务。因此每次只能执行一个任务,所以当你在主队列运行繁重的任务时,UI 就会停止响应。

除了主队列,系统还提供了四个并发队列。我们称之为 Global Dispatch(全局调度)队列。这些队列对应用而言是全局的,差别只在于优先级的不同。为了使用这些队列,你必须用 dispatch_get_global_queue 方法取得你偏好队列的引用。该 dispatch_get_global_queue 方法的首个参数必须为下面四个值中的一个:

这些队列类型代表了执行的优先次序。HIGH 队列的优先级最高,而 BACKGROUND 队列的优先级最低。你可以根据任务的优先级决定使用何种优先级的队列。此外,这些队列也会为苹果的 API 所用,因此,你的任务并不是队列中的所有任务。

最后,你可以创建任意数量的串行队列或并发队列。当用到并发队列时,笔者强烈建议你使用这四个全局队列。当然,你也可以自己创建并发队列。

GCD 备忘录

现在,你应该对调度队列有了基本的理解。接下来,笔者将提供你一份简单的 GCD 备忘录以供参考。该备忘录非常简单,但是包含了有关 GCD 的林林总总,都是你用得上的知识。

gcd-cheatsheet

很赞,对吧?接下来,我们会通过一个简单的演示程序展示如何使用调度队列。笔者会教你如果使用调度队列优化应用性能,提高应用响应度。

演示项目

我们的启动项目非常简单,主要展示四个图片视图,每个视图都需要从一个远程站点获取图片。图片请求会在主线程中完成。为了展示这个过程对 UI 响应性能的影响,笔者在图片下面添加了一个简单的滑动条。现在,下载并运行该启动项目。点击 Start 按钮开始下载图片,在此过程中拖动滑块。你会发现,根本无法拖动它。

concurrency-demo

一旦点击了 Start 按钮,图片就会在主线程中开始下载。显然,这种方法非常糟糕,会导致 UI 停止响应。不幸的是,直到今天,仍有许多应用在主线程中执行这类繁重的任务。下面,我们将使用调度队列解决这一问题。

首先,我们会用并发队列实现解决方案。之后,使用串行队列再此实现解决方案。

使用并发调度队列

现在,回到 Xcode 项目中的 ViewController.swift 文件。如果查看代码,你会发现一名为 didClickOnStart 的动作方法。该方法会处理图片的下载,其实现方式如下:

@IBAction func didClickOnStart(sender: AnyObject) {
    let img1 = Downloader.downloadImageWithURL(imageURLs[0])
    self.imageView1.image = img1
    
    let img2 = Downloader.downloadImageWithURL(imageURLs[1])
    self.imageView2.image = img2
    
    let img3 = Downloader.downloadImageWithURL(imageURLs[2])
    self.imageView3.image = img3
    
    let img4 = Downloader.downloadImageWithURL(imageURLs[3])
    self.imageView4.image = img4
    
}

每个 downloader 都会被视作一个任务,所有的任务都在主队列中执行。现在,换一种实现方式。首先,获取一个默认优先级的全局并发队列的引用。

let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
        dispatch_async(queue) { () -> Void in
            
            let img1 = Downloader.downloadImageWithURL(imageURLs[0])
            dispatch_async(dispatch_get_main_queue(), {
                
                self.imageView1.image = img1
            })
            
        }

此处,我们先用 dispatch_get_global_queue 方法获得默认并发队列的引用。之后,在代码块内部,提交下载第一张图片的任务。图片下载完成之后,向主线程提交另一个任务,用下载好的图片更新图片视图。换句话说,我们将图片下载任务放到后台线程中进行,但是在主线程中执行与 UI 相关的任务。

@IBAction func didClickOnStart(sender: AnyObject) {
    
    let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
    dispatch_async(queue) { () -> Void in
        
        let img1 = Downloader.downloadImageWithURL(imageURLs[0])
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView1.image = img1
        })
        
    }
    dispatch_async(queue) { () -> Void in
        
        let img2 = Downloader.downloadImageWithURL(imageURLs[1])
        
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView2.image = img2
        })
        
    }
    dispatch_async(queue) { () -> Void in
        
        let img3 = Downloader.downloadImageWithURL(imageURLs[2])
        
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView3.image = img3
        })
        
    }
    dispatch_async(queue) { () -> Void in
        
        let img4 = Downloader.downloadImageWithURL(imageURLs[3])
        
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView4.image = img4
        })
    }
    
}

将四张图片的下载作为并发任务提交给默认队列后,构造并运行应用,运行速度应该会明显改善(如果报出代码错误,请仔细对照你的代码与上面的代码)。此外,在下载图片的同时,滑动条应该也可以顺利拖动,没有任何延迟。

使用串行调度队列

解决延迟问题的另一种办法就是使用串行队列。现在,回到 ViewController.swift 文件的 didClickOnStart() 方法。这一次,我们会使用串行队列下载图片。不过,在使用串行队列时,你必须加倍注意自己引用的是哪一个串行队列。每个应用都有一个默认的串行队列,该队列其实是用于 UI 加载的主队列。因此,在使用串行队列时,你必须创建一个新队列,否则,在执行自身任务的同时,应用也会试图执行更新 UI 的任务。这会导致错误与延迟,进而损害用户体验。你可以使用 dispatch_queue_create 方法创建一个新的队列,并将所有任务提交给这个队列,方法与之前介绍的相同。完成这些改动之后,代码如下:

@IBAction func didClickOnStart(sender: AnyObject) {
    
    let serialQueue = dispatch_queue_create("com.appcoda.imagesQueue", DISPATCH_QUEUE_SERIAL)
    
    
    dispatch_async(serialQueue) { () -> Void in
        
        let img1 = Downloader .downloadImageWithURL(imageURLs[0])
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView1.image = img1
        })
        
    }
    dispatch_async(serialQueue) { () -> Void in
        
        let img2 = Downloader.downloadImageWithURL(imageURLs[1])
        
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView2.image = img2
        })
        
    }
    dispatch_async(serialQueue) { () -> Void in
        
        let img3 = Downloader.downloadImageWithURL(imageURLs[2])
        
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView3.image = img3
        })
        
    }
    dispatch_async(serialQueue) { () -> Void in
        
        let img4 = Downloader.downloadImageWithURL(imageURLs[3])
        
        dispatch_async(dispatch_get_main_queue(), {
            
            self.imageView4.image = img4
        })
    }
    
}

如你所见,此方法与并发队列案例的唯一不同是串行队列的创建。当你再次创建并运行应用时,会发现图片下载过程还是在后台运行,因此 UI 交互不受影响。

不过,你会注意到两点:

  1. 与并发队列的案例相比,图片下载时间有所延长。原因是每次只下载一张图片。每个任务只有在前一个任务完成之后,才开始执行。
  2. 图片依次加载,分别为图片1,图片2,图片3,图片4。原因是串行队列每次只执行一个任务。

第二部分:操作队列

我们知道,GCD 是允许开发者并发地执行任务的底级别 C API。然而,操作队列是队列模型的高级抽象,基于 GCD 建立。这意味着,你可以像 GCD 那样并发地执行任务,却是以面向对象的方式。简而言之,操作队列进一步简化了开发者的工作。

与 GCD 不同,操作队列不循序先进先出的次序。以下是操作队列与调度队列的不同之处:

  1. 不遵循 FIFO 次序:在操作队列中,你可以为操作设定执行优先级,并添加操作间的依赖关系。也就是说,你可以定义一些操作只在另一些操作完成之后才能被执行。这也是他们不遵循先进先出原则的原因。

  2. 默认情况下,操作队列并发运行:尽管不能将其类型改为串行队列,你仍能使用操作间的依赖关系指定任务的执行顺序。

  3. 操作队列是 NSOperationQueue 类的实例,其任务则封装在 NSOperation 的实例中。

NSOperation

NSOperation

如前所述,任务以 NSOperation 实例的形式提交给操作队列。而在 GCD 的讨论中,我们说过任务以块为单位进行提交。此处也一样,不过任务必须捆绑为 NSOperation 实例。你可以简单地将 NSOperation 视为一个工作单元。

NSOperation 是抽象类,因此无法直接使用。所以,你只能使用 NSOperation 的子类。在 iOS SDK 中,提供了两个 NSOperation 的具体子类。这些类可以直接使用,不过,你也可以自行创建 NSOperation 的子类来执行操作。我们可以直接使用的两个类为:

  1. NSBlockOperation —— 使用此类可创建带有一个或多个块的操作。操作本身可包含多个块,而且只有当所有块都执行完毕时,该操作才算完成。
  2. NSInvocationOperation —— 使用此类创建的操作能够针对特定对象唤起选择器。

So what’s the advantages of NSOperation?
那么,NSOperation 有什么好处呢?

1.首先,借由 NSOperation 类中的 addDependency(op: NSOperation) 方法,他们支持依赖关系。当你想创建的操作依赖于另一个操作的执行情况时,NSOperation 就能派上用场了。

NSOperation Illustration

2.其次,将 queuePriority 属性的值设置为下列值中的某一个,你可以改变操作执行的优先级。

public enum NSOperationQueuePriority : Int {
    case VeryLow
    case Low
    case Normal
    case High
    case VeryHigh
}

The operations with high priority will be executed first.

优先级高的操作会首先执行。

3.你可以取消任意队列中的某个操作或所有操作。操作在添加到队列之后仍可以取消,调用 NSOperation 类中的 cancel() 方法即可。当你选择取消操作时,可能发生的场景如下:

  • 若操作已经结束,cancel 方法就无法起效。
  • 若操作正在被执行,系统不会强制停止操作代码。但是,cancelled(已取消)属性会设置为真。
  • 若操作还在队列中等待执行,该操作就不会被执行。

4.NSOperation 有三个很有用的布尔值属性,非别为finished(已完成),cancelled(已取消),和 ready(准备就绪)。一旦操作执行完毕,finished 会设置为真。而一旦操作取消,cancelled 会设置为真。若是操作即将被执行,则 ready 会设置为真。

5.一旦任务完成,任何 NSOperation 都可以将完成块设置为 called(已经调用)。一旦 NSOperation 中的 finished 属性设置为真,块就会变为 called。

现在,让我们用 NSOperationQueues 重写演示项目。首先,在 ViewController 类中声明此变量:

var queue = NSOperationQueue()

之后,用下面的代码替代 didClickOnStart 方法。请查看我们是如何在 NSOperationQueue 中执行操作的:

@IBAction func didClickOnStart(sender: AnyObject) {
    queue = NSOperationQueue()

    queue.addOperationWithBlock { () -> Void in
        
        let img1 = Downloader.downloadImageWithURL(imageURLs[0])

        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView1.image = img1
        })
    }
    
    queue.addOperationWithBlock { () -> Void in
        let img2 = Downloader.downloadImageWithURL(imageURLs[1])
        
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView2.image = img2
        })

    }
    
    queue.addOperationWithBlock { () -> Void in
        let img3 = Downloader.downloadImageWithURL(imageURLs[2])
        
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView3.image = img3
        })

    }
    
    queue.addOperationWithBlock { () -> Void in
        let img4 = Downloader.downloadImageWithURL(imageURLs[3])
        
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView4.image = img4
        })

    }
}

如你所见,此处使用了 addOperationWithBlock 方法用给定的块(或者如 Swift 中所说,闭包)创建新的操作。其实非常简单,不是么?在主队列中执行任务,我们可以用 NSOperationQueue (NSOperationQueue.mainQueue())提交想在主队列中执行的任务,而不是像使用 GCD 时那样调用 dispatch_async 方法。

现在,你可以运行应用,简单测试一下。如果代码输入正确,应用应该在后台下载图片,不影响用户交互界面。

在前面的例子里,我们借助 addOperationWithBlock 方法往队列中添加操作。现在,让我们使用 NSBlockOperation 进行同样的操作,与此同时,提供更多的功能与选择,比如设置完成处理程序。这一次,didClickOnStart 方法的改写如下:

@IBAction func didClickOnStart(sender: AnyObject) {
    
    queue = NSOperationQueue()
    let operation1 = NSBlockOperation(block: {
        let img1 = Downloader.downloadImageWithURL(imageURLs[0])
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView1.image = img1
        })
    })
    
    operation1.completionBlock = {
        print("Operation 1 completed")
    }
    queue.addOperation(operation1)
    
    let operation2 = NSBlockOperation(block: {
        let img2 = Downloader.downloadImageWithURL(imageURLs[1])
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView2.image = img2
        })
    })
    
    operation2.completionBlock = {
        print("Operation 2 completed")
    }
    queue.addOperation(operation2)
    
    
    let operation3 = NSBlockOperation(block: {
        let img3 = Downloader.downloadImageWithURL(imageURLs[2])
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView3.image = img3
        })
    })
    
    operation3.completionBlock = {
        print("Operation 3 completed")
    }
    queue.addOperation(operation3)
    
    let operation4 = NSBlockOperation(block: {
        let img4 = Downloader.downloadImageWithURL(imageURLs[3])
        NSOperationQueue.mainQueue().addOperationWithBlock({
            self.imageView4.image = img4
        })
    })
    
    operation4.completionBlock = {
        print("Operation 4 completed")
    }
    queue.addOperation(operation4)
}

针对每一个操作,我们都创建一个新的 NSBlockOperation 实例用于将任务封装为块。借助 NSBlockOperation,你还可以设置完成处理程序。现在,操作执行完成之后,特定的完成处理程序就会被调用。此处,为了简便起见,我们只是在日志中记录一则简单的消息,提示操作已经完成。如果你运行演示项目,会在控制台看到如下信息:

Operation 1 completed
Operation 3 completed
Operation 2 completed
Operation 4 completed

Canceling Operations

取消操作

如前所述,NSBlockOperation 允许你管理操作。现在,让我们来学习如何取消一个操作。为此,首先要在导航栏添加一个名为 Cancel(取消)的按钮。为了演示取消操作,我们将在操作2与操作1,以及操作3与操作2之间分别添加一个依赖关系。也即,操作2会在操作1完成之后开始执行,而操作3会在操作2完成之后开始执行。操作4不存在依赖关系,会并发执行。要想取消这些操作,你只需调用 NSOperationQueue 的 cancelAllOperations() 方法即可。下面,在 ViewController 类中插入下面的方法:

   @IBAction func didClickOnCancel(sender: AnyObject) {
        
        self.queue.cancelAllOperations()
    }

请记住,你需要把添加到导航栏的 Cancel 按钮与 didClickOnCancel 方法相连接。为此,你可以回到 Main.storyboard 文件,打开连接检查器(Connections Inspector)。之后,你会看到 Received Actions 一节下的分开 didSelectCancel() 方法。点击并从空圆拖拽到 Cancel 栏按钮。之后,参照如下代码创建 didClickOnStart 方法中的依赖关系:

operation2.addDependency(operation1)
operation3.addDependency(operation2)

之后,修改操作1的完成块,在日志中记录取消的状态:

operation1.completionBlock = {
            print("Operation 1 completed, cancelled:\(operation1.cancelled) ")
        }

你也可以修改操作2、3、4的日志记录语句,从而更深入地理解此过程。现在,建造并允许应用。点击Start 按钮之后,点击 Cancel 按钮。这样,操作1完成后所有操作都会被取消,以下是运行结果:

  • 由于操作1已经被执行了,取消无法起效。因此,cancelled 的值在日志中记为假,应用仍会展示图片1。
  • 如果点击 Cancel 按钮的速度足够快,操作2会被取消。cancelAllOperations() 的调用会中断操作2的执行,因此图片2下载失败。
  • 操作3已经在队列中,等待操作2执行完成。因为它依赖于操作2的完成才能继续执行。但由于操作2被取消了,操作3也不会得到执行,而是立即从队列中移除。
  • 操作4并未设置任何依赖关系。因此,它会并发执行,成功下载图片4。
ios-concurrency-cancel-demo

Where to go from here?

下一步该做什么?

在本文中,笔者详细介绍了 iOS 并发的概念以及实现方式。首先,笔者简单介绍了并发的概念,阐释了 GCD,以及创建串行与并发队列的方式。进一步地,我们学习了NSOperationQueues。现在,你应该对 GCD 与 NSOperationQueues 的区别有清晰的了解。

若想了解有关 iOS 并发的更多知识,笔者推荐你学习苹果的并发指南

作为参考,你可以在此处下载前文提到的完整源码。

Please feel free to ask any questions. I love to read your comment.
欢迎提问或留下意见及建议。
OneAPM Mobile Insight 以真实用户体验为度量标准进行 Crash 分析,监控网络请求及网络错误,提升用户留存。访问 OneAPM 官方网站感受更多应用性能优化体验,想阅读更多技术文章,请访问 OneAPM 官方技术博客

本文转自 OneAPM 官方博客

推荐阅读更多精彩内容