×

iOS并行开发:从NSOperation和调度队列开始

96
小热狗
2016.03.03 17:16* 字数 6222

在iOS开发中,并行一直被认为是项目里的怪物。它被认为是一个危险的区域,许多开发者尽力去避免的区域。有谣传说多线程代码应尽可能的避免。我同意并行是危险的,不过那只是因为你没有很好地理解。只是因为未知才变得危险。想想人们在生活中危险的行为活动,有很多吧?但是一旦掌握了,就变得简单了。并行是双刃剑,你应该学习并且掌握如何使用它。它帮助你编写高效、执行快速和响应灵敏的应用,但与此同时,滥用它会无情地毁了你的应用。因此在开始编写任何并行代码之前,先想想你为什么需要并行以及你需要使用哪个API来解决这个问题?在iOS中我们可以使用不同的API。本教程我们将讨论两个最常用的API——NSOperation和调度队列。

我们为什么需要并行?

我知道你是一个优秀的有经验的iOS开发者。然而,无论构建什么样的应用程序,你需要知道并行能使你的应用响应更灵敏、运行更快速。在这里我总结了几点学习或使用并行的优点:

利用iOS设备的硬件:现在所有的iOS设备都有一个多核的处理器,它使开发人员可以并行执行多个任务。你应该利用这个特性,获得硬件带来的好处。

更好的用户体验:你大概已经写过代码来调用web服务,处理一些IO,或执行任何繁重的任务。你知道,在UI线程执行这些操作将卡住应用,使其未响应。一旦用户面临这种情况,第一步,他/她将杀死/关闭应用,没有任何其它想法。有了并行,所有这些任务可以在后台完成,不会阻塞主线程,不会打扰用户。他们仍可以点击按钮、滚动和浏览你的应用,而在后台处理繁重的任务。

NSOperation和调度队列之类的API使得并行容易使用:创建和管理线程并不是容易的任务。这就是为什么大部分开发者听到并行和多线程的代码会感到害怕的原因。在iOS中,我们有很棒的易用的并行API,它将使你的编程变得更容易。你不需要关心创建线程或管理任何底层的东西。API将为你完成所有这些任务。这些API的另一个重要优势是,它可以帮助你轻松实现同步来避免竞态条件。竞态条件发生在多个线程试图访问共享资源时,这会导致意想不到的结果。通过使用同步,能够保护资源免受线程间共享的影响。

关于并行你需要了解什么?

在本教程中,我们将解释所有关于并行你需要的了解的,并减轻你所有关于并行的恐惧。首先我们推荐看看block(在Swift中是闭包),因为大量并行API使用它。接着我们将谈谈调度队列和NSOperationQueues。我们将带你了解一下每个并行的概念,它们的不同点,以及如何实现它们。

第1部分:GCD(伟大的中枢调度)

GCD是最常用的管理并行代码和执行异步操作的Unix系统层的API。GCD构造和管理队列中的任务。首先,让我们看看队列是什么。

队列是什么?

队列是按先进先出(FIFO)管理对象的数据结构。队列类似电影院的售票窗口,票的销售是谁先到谁先服务。在等待线前面的人先去买他们的门票,在其余的后抵达的人之前。队列在计算机科学中是相似的,因为第一个添加到队列的对象也是第一个从队列中删除的对象。

调度队列

调度队列是一种简单的异步和同步任务的方法。它们是队列,任务以block的形式被你的应用提交到其中。有两种调度队列:(1)串行队列(2)并行队列。在谈不同点之前,你需要知道任务分配给这两个队列都是在单独的线程执行的,而不是在创建任务的线程上。换句话说,你创建block再提交到主线程的调度队列。但所有这些任务(block)将运行在单独的线程,而不是主线程。

串行队列

当你选择创建一个串行队列,队列一次只能执行一个任务。同一串行队列的所有任务将相互尊重并连续执行。然而,它们不关心任务是不是在单独的线程,这意味着你仍然可以通过使用多个串行队列来并行地执行任务。例如,你可以创建两个串行队列,每个队列一次只执行一个任务,不过多达两个任务仍可并行执行。

串行队列用于管理共享资源是极好的。它保证了序列化访问共享资源,防止了竞态条件。想象一个售票亭,有一群人想买电影票,这里的展台的工作人员就是一个共享资源。如果员工需要为这些人在同一时间服务,这将是混乱的。为了处理这种情况,人们需要排队(串行队列),这样员工可以一次服务一个顾客。

重申,这并不意味着电影院一次只能处理一个客户。如果多设置两个展位,它可以同时处理三个客户。这就是为什么说你仍然可以通过使用几个串行队列来并行执行多个任务。

使用串行队列的优点是:

1.保证序列化访问共享资源,避免竞态条件。

2.任务的执行顺序是可预测的。当你提交任务到一个串行调度队列,它们将按插入的顺序执行。

3.你可以创建任意数量的串行队列。

并行队列

顾名思义,并行队列可以并行执行多个任务。任务(block)按添加到队列的顺序开始,但它们的执行会同时发生,它们不会相互等待。并行队列保证任务开始的顺序,但你不知道执行的顺序、执行时间或某个时间点的正在执行的任务数。

例如,你提交三个任务(任务#1,#2和#3)到并行队列。任务将并行执行并且按添加到队列的顺序开始。然而,执行时间和完成时间各不相同。即使任务#2和#3的开始需要一些时间,在任务#1之前它们都可以完成。任务的执行是由系统决定的。

使用队列

既然我们已经解释了串行和并行队列,是时候看看我们可以如何使用它们。默认情况下,系统为每个应用提供了一个串行队列和四个并行队列。主调度队列是全局可用的串行队列,它在应用的主线程执行任务。它是用来更新应用的UI和执行所有与UI视图更新有关的任务。同时只有一个任务执行,这就是为什么当你在主队列执行一个繁重的任务UI会被卡住。

除了主队列,系统提供4个并行队列。我们称之为全局调度队列。这些队列对于应用是全局的,区别只在于它们的优先级。使用一个全局并行队列,你必须得到队列的引用,使用函数dispatch_get_global_queue,它的第一个参数是:

DISPATCH_QUEUE_PRIORITY_HIGH

DISPATCH_QUEUE_PRIORITY_DEFAULT

DISPATCH_QUEUE_PRIORITY_LOW

DISPATCH_QUEUE_PRIORITY_BACKGROUND

这些队列类型代表了执行的优先级。HIGH的队列拥有最高的优先级,BACKGROUND拥有最低的优先级。所以你可以根据任务的优先级决定你使用的队列。也请注意,这些队列也被苹果API使用,所以你的任务并不是这些队列里唯一的任务。

最后,你可以创建任意数量的串行或并行队列。关于并行队列,我强烈推荐使用的那四个全局队列,不过你也可以创建自己的。

GCD备忘表

现在,你应该对调度队列有了一个基本的了解。我要给你一个简单备忘表,供你参考。表非常简单,包含了关于GCD你需要知道的所有信息。

很酷,对吧?现在让我们做一个简单的演示,来看看如何使用调度队列。我将向你展示如何利用调度队列来优化应用性能,使其响应更灵敏。

演示项目

我们的开始项目非常简单,我们显示四个image view,每一个从远程站点请求一个特定的形象。图像请求在主线程中完成,来向你们展示这将如何影响界面的响应,我在图片下面添加了一个简单的滑块。现在下载并运行开始项目。单击Start按钮启动图像下载并在图像下载时拖动滑动条。你会发现你一点也拖不动。

一旦你点击Start按钮,图像在主线程开始下载。显然,这种方法非常糟糕,它使UI没有响应。遗憾的是直到今天仍有一些应用在主线程执行繁重的加载任务。现在我们要修复它,使用调度队列。

首先,我们将实现并行队列的解决方案,然后再用串行队列。

使用并行调度队列

现在回到Xcode项目里的ViewController.swift。如果你分析了代码,你应该看到didClickOnStart动作方法。该方法处理图像下载。我们现在是这样执行任务的:

1

2

3

4

5

6

7

8

9

10

11

12

13

14@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

}

每个下载器被认为是一个任务,现在所有的任务都在主队列执行。现在让我们得到一个Default优先级的全局并行队列的引用。

1

2

3

4

5

6

7

8

9

10let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)

dispatch_async(queue) { () -> Voidin

let img1 = Downloader.downloadImageWithURL(imageURLs[0])

dispatch_async(dispatch_get_main_queue(), {

self.imageView1.image = img1

})

}

我们首先使用dispatch_get_global_queue,得到一个默认并行队列的引用,然后在block里我们提交一个任务来下载第一个图像。一旦图像下载完成,我们提交另一个任务到主队列来用下载的图像更新image view。换句话说,我们把图片下载任务放到一个后台线程,但UI相关的任务在主队列执行。

如果你其余图片也这样做,你的代码应该是这样的:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43@IBAction func didClickOnStart(sender: AnyObject) {

let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)

dispatch_async(queue) { () -> Voidin

let img1 = Downloader.downloadImageWithURL(imageURLs[0])

dispatch_async(dispatch_get_main_queue(), {

self.imageView1.image = img1

})

}

dispatch_async(queue) { () -> Voidin

let img2 = Downloader.downloadImageWithURL(imageURLs[1])

dispatch_async(dispatch_get_main_queue(), {

self.imageView2.image = img2

})

}

dispatch_async(queue) { () -> Voidin

let img3 = Downloader.downloadImageWithURL(imageURLs[2])

dispatch_async(dispatch_get_main_queue(), {

self.imageView3.image = img3

})

}

dispatch_async(queue) { () -> Voidin

let img4 = Downloader.downloadImageWithURL(imageURLs[3])

dispatch_async(dispatch_get_main_queue(), {

self.imageView4.image = img4

})

}

}

你刚刚提交了四个图像下载的并行任务到默认队列。现在构建并运行应用,它应该快得多(如果报任何错误,检查你的代码是不是和上面的一样)。注意,你应该能够在下载图片的时候没有任何延迟地拖动滑块。

使用串行调度队列

解决滞后问题的备用方法是使用串行队列。现在回到ViewController.swift文件里的相同的didClickOnStart()方法。这次我们将使用一个串行队列来下载图片。在使用串行队列时,你需要密切关注你正在引用的串行队列是哪一个。每个应用都有一个默认的串行队列,这实际上是用于UI的主队列。所以记住当使用串行队列时,你必须创建一个新队列,否则会在应用试图执行更新UI的任务的时候执行你的任务。这将导致错误和延迟,破坏用户体验。你可以使用函数dispatch_queue_create来创建一个新队列,接着和之前做的一样提交所有任务。修改之后,代码是这样的:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45@IBAction func didClickOnStart(sender: AnyObject) {

let serialQueue = dispatch_queue_create("com.appcoda.imagesQueue", DISPATCH_QUEUE_SERIAL)

dispatch_async(serialQueue) { () -> Voidin

let img1 = Downloader .downloadImageWithURL(imageURLs[0])

dispatch_async(dispatch_get_main_queue(), {

self.imageView1.image = img1

})

}

dispatch_async(serialQueue) { () -> Voidin

let img2 = Downloader.downloadImageWithURL(imageURLs[1])

dispatch_async(dispatch_get_main_queue(), {

self.imageView2.image = img2

})

}

dispatch_async(serialQueue) { () -> Voidin

let img3 = Downloader.downloadImageWithURL(imageURLs[2])

dispatch_async(dispatch_get_main_queue(), {

self.imageView3.image = img3

})

}

dispatch_async(serialQueue) { () -> Voidin

let img4 = Downloader.downloadImageWithURL(imageURLs[3])

dispatch_async(dispatch_get_main_queue(), {

self.imageView4.image = img4

})

}

}

正如我们看到的,与并行队列唯一不同的地方是串行队列的创建。当构建并再次运行应用时,你将再次看到图片在后台下载,所以你可以继续与用户界面交互。

但你会注意到两件事:

1.和并行队列相比,下载图片需要的时间有点长。这是因为我们同时只加载一个图像。每个任务等待前面的任务完成才会被执行。

2.图像按image1,image2,image3,image4的顺序加载。因为队列是一个串行队列,它一次执行一个任务。

第2部分:操作队列

GCD是一个底层的C的API,它使开发人员能够并行地执行任务。操作队列,另一方面,是高度抽象的队列模型,是建立在GCD之上的。这意味着你可以并行执行任务就像GCD一样,但以面向对象的方式。简而言之,队列操作让编程更加简单。

不同于GCD,它们不按先进先出的顺序。下面是操作队列和调度队列的不同点:

1.不遵循先进先出:在操作队列中,你可以设置一个操作的执行优先级,你可以添加操作之间的依赖关系,这意味着你可以定义一些操作完成后才会执行其他操作。这就是为什么它们不遵循先进先出。

2.默认情况下,它们同时操作:然而你不能把它的类型改变成串行队列。通过使用操作之间的依赖关系,在操作队列还存在一个工作区来依次执行任务。

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

NSOperation

任务以NSOperation实例的形式提交到操作队列。我们在GCD讨论了任务是以block提交。同样这里也可以这样做但应捆绑在NSOperation实例里。你可以简单地认为NSOperation是单个的工作单元。

NSOperation是一个抽象类,它不能直接使用,所以你必须使用NSOperation子类。在iOS SDK里,我们提供两个NSOperation的具体子类。这些类可以直接使用,但你也可以继承NSOperation来创建自己的类来执行操作。我们可以直接使用的两个类:

1.NSBlockOperation——使用这个类来用一个或多个block初始化操作。操作本身可以包含多个块。当所有block被执行操作将被视为完成。

2.NSInvocationOperation——使用这个类来初始化一个操作,它包括指定对象的调用selector。

所以NSOperation的优势是什么?

1.首先,它们通过NSOperation类里的方法addDependency(op:NSOperation)支持依赖。当你需要开始一个依赖于其它操作执行的操作,你会需要NSOperation。

2.其次,你可以通过下面这些值设置属性queuePriority来改变执行优先级:

1

2

3

4

5

6

7public enum NSOperationQueuePriority : Int {

caseVeryLow

caseLow

caseNormal

caseHigh

caseVeryHigh

}

优先级高的操作将先被执行。

3.对于任何给定的队列,你可以取消一个特定的或所有的操作。操作可以在被添加到队列后被取消。取消是通过调用NSOperation类里的方法cancel()。当你取消任何操作的时候,我们有三个场景,其中一个会发生:

你的操作已经完成。在这种情况下,取消方法没有效果。

你的操作已经被执行。在这种情况下,系统不会强制操作代码停止,而是属性cancelled被置为true。

你的操作仍在队列中等待。在这种情况下,你的操作将不会被执行。

4.NSOperation有3个有用的布尔属性,finished、 cancelled和ready。一旦操作执行完成,finisher将被置为true。一旦操作被取消,cancelled将被置为true。一旦准备即将被执行,ready将被置为true。

5.任何NSOperation有一个选项来设置回调,一旦任务完成将会被调用。在NSOperation里,一旦属性finished被置为true,这个block将被调用。

现在让我们重写演示项目,但这一次我们将使用NSOperationQueues。首先在ViewController类里声明变量:

1

varqueue = NSOperationQueue()

接下来,用下面的代码替换didClickOnStart方法,看看我们在NSOperationQueue里如何执行操作:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39@IBAction func didClickOnStart(sender: AnyObject) {

queue = NSOperationQueue()

queue.addOperationWithBlock { () -> Voidin

let img1 = Downloader.downloadImageWithURL(imageURLs[0])

NSOperationQueue.mainQueue().addOperationWithBlock({

self.imageView1.image = img1

})

}

queue.addOperationWithBlock { () -> Voidin

let img2 = Downloader.downloadImageWithURL(imageURLs[1])

NSOperationQueue.mainQueue().addOperationWithBlock({

self.imageView2.image = img2

})

}

queue.addOperationWithBlock { () -> Voidin

let img3 = Downloader.downloadImageWithURL(imageURLs[2])

NSOperationQueue.mainQueue().addOperationWithBlock({

self.imageView3.image = img3

})

}

queue.addOperationWithBlock { () -> Voidin

let img4 = Downloader.downloadImageWithURL(imageURLs[3])

NSOperationQueue.mainQueue().addOperationWithBlock({

self.imageView4.image = img4

})

}

}

正如你在上面的代码中所看到的,你使用方法addOperationWithBlock用给定的block(或Swift中的闭包)来创建一个新的操作。这很简单,不是吗?要在主队列执行一项任务,不用调用使用GCD时用的dispatch_async(),我们可以从NSOperationQueue(NSOperationQueue.mainQueue())提交你想要执行的操作到主队列。

你可以运行这个应用来做一个快速测试。如果输入的代码是正确的,应用应该能够在后台下载图片并且不会阻塞用户界面。

在前面的示例中,我们使用的方法addOperationWithBlock在队列中添加操作。让我们看看如何使用NSBlockOperation做相同的事,而且与此同时,它为我们提供更多功能和选项,如设置回调。didClickOnStart方法修改后是这样的:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52@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的实例来将任务封装到一个block。通过使用NSBlockOperation,你得以设置回调。现在当操作完成,回调将被调用。为简单起见,我们只输出一个简单消息来表示操作完成。如果你运行这个演示,你将在控制台看到这样的东西:

1

2

3

4Operation 1 completed

Operation 3 completed

Operation 2 completed

Operation 4 completed

取消操作

如前所述,NSBlockOperation允许你管理操作。现在让我们看看如何取消操作。要做到这一点,首先添加一个按钮到导航栏并把它命名Cancel。为了说明取消操作,我们将添加操作#2和操作#1之间的依赖,以及操作#3和操作#2之间的依赖。这意味着操作#2将在操作#1完成后开始,操作#3将在操作#2完成后开始。操作#4没有依赖,它将并行工作。为了取消操作,你所有需要做的就是调用NSOperationQueue的cancelAllOperations()。在ViewController类中插入下面的方法:

1

2

3

4@IBAction func didClickOnCancel(sender: AnyObject) {

self.queue.cancelAllOperations()

}

记住你需要把你添加到导航栏的Cancel按钮关联到didClickOnCancel方法。为此你可以回到Main.storyboard文件打开Connections Inspector。在那里你会在Received Actions部分看到取消关联didSelectCancel()。单击+拖动从空圆到Cancel工具栏按钮。然后在didClickOnStart方法创建依赖是这样的:

1

2operation2.addDependency(operation1)

operation3.addDependency(operation2)

接着改变操作#1的回调来输出取消状态:

1

2

3operation1.completionBlock = {

print("Operation 1 completed, cancelled:\(operation1.cancelled) ")

}

你可以改变操作#2、#3和#4的日志语句,这样你将更好地理解这个过程。现在让我们构建并运行。点击Start按钮后,按下Cancel按钮。这将在操作#1完成后取消所有操作。发生了以下的事:

由于操作#1已经执行,取消将什么也不做。这就是为什么cancelled值输出为false,而且应用仍然显示图像#1。

如果你点击Cancel按钮的速度足够快,操作#2被取消了。cancelAllOperations()将停止执行,所以图像#2没有被下载。

操作#3已经在队列中,等待操作#2完成。因为它依赖于操作#2的完成然而操作#2被取消,操作#3将不会被执行并立即从队列中被剔除。

没有依赖配置到操作#4。它就并发下载图片#4。

接下来呢?

在本教程中,我带你了解了iOS并发的概念,以及如何在iOS实现它。我给了你一个很好的并发的介绍,解释了GCD,展示了如何创建串行和并发队列。此外,我们还看了NSOperationQueues。你现在应该熟悉GCD和NSOperationQueue之间的区别。

要进一步深入iOS并发,我建议你看看Apple’s Concurrency Guide

为了参考,你可以在Github的iOS并发库找到完整的本文提到的源代码

日记本
Web note ad 1