Swift 3 之 GCD 教程(一)【译】

原文:Grand Central Dispatch Tutorial for Swift 3:Part1/2

GCD 是管理并发操作的底层 API。使用 GCD 把计算昂贵的任务放到后台执行,来改善你的 app 反应灵敏度。它是一个容易使用加锁与多线程的并发模型。

在 Swift 3 中,对 GCD 进行了大幅度的修改,从基于 C API 到 “Swifter”API,包含了新的 Class 与新的数据结构。

在这两篇 GCD 教程中,你将学习 GCD 的来龙去脉。这第一篇教程将介绍 GCD 能做什么以及展示几个基础的 GCD 函数。在第二篇教程中,你将学习一些 GCD 高级函数。

你将在 GooglyPuff 这个已经提供的应用上进行开发。GooglyPuff 是一个没有性能改善,“非线程安全”的app,它使用了 CoreImage 人脸识别API在脸上覆盖一双 Googly Eyes。你可以选择图片库或者从网络下载的图片,来使用这种效果。

在这篇教程中,你的任务是使用 GCD 改善这个 app 并确保你可安全的从不同的线程调用代码。

开始

下载 starter project 并解压。使用 Xcode 打开并运行这个项目,看看它都有些什么功能。

首页初始时为空界面。点击+然后点击Le Internet从网络上下载预备好图片。点击第一张图片,你将会看到 Googly Eyes 添加到脸上。

在这个篇教程中,这里有4个主要的类你将会用到:

  • PhotoCollectionViewController: 初始的 view controller。它显示选择好的图片的缩略图;
  • PhotoDetailViewController:显示从PhotoCollectionViewController中选择的图片以及添加 googly eyes 到图片上。
  • Photo:这是一个协议,描述了图片的属性。它提供图片,缩略图以及它们相关的状态。有两个类实现了这个协议:DownloadPhoto 实例化一张图片从URL实例,AssetPhoto 实例化一张图片从PHAsset实例。
  • PhotoManager:这是一个管理所有Photo对象。

这个 app 还有几个问题。一个总题是你应该注意到的当运行 app 时下载完成的提示过早的显示。你将在该系列教程的第二篇解决此问题。

在这第一篇教程中,你要的事情是改善 googly-fying 处理性能以及让 PhotoManager 变得线程安全。

GCD的概念

要懂得 GCD,你需要理解几个与 concurrency(并发)与 threading(线程)相关的概念。

Concurrency(并发)

iOS中的 process(进程)或应用是由1或多个线程组成。这些线程是由操作系统调度程序独立管理的。所有线程都能并发执行,但这是由系统来决定是否并发执行以及怎样执行。

单核(Single-core)设备可以通过时间分片(time-slicing)来达到并发。它们将运行在一个线程上,执行上下文切换,然后运行其他另一个线程。

而多核(Multi-core)设备则是另一种情况,通过并行(parallelism)在同一时间执行多线程。

GCD 是以线程为基础的。底层的实现是它管理一个共享线程池(shared therad pool)。使用 GCD 你添加代码块或者 work items 到 dispatch queues,并由GCD来决定它们在哪个线程上执行。

比如你构建你的代码,你将会发现有的代码块它们应该同时运行,而有的不应该。这就允许你利用 GCD 的并发执行。

注意,GCD 决定多少并行是必需的是基于系统及闲置系统资源。务必注意的是并行需要并发,但并发不能保证并行。(原文: It’s important to note that parallelism requires concurrency, but concurrency does not guarantee parallelism.)

总之,并发是关于结构,而并行是关于执行。(原文:Basically, concurrency is about structure while parallelism is about execution.)

译者注:stackoverflow上有关于并发与并行的区别的提问:Concurrency vs Parallelism - What is the difference?

Queues(队列)

GCD 提供 DispatchQueue(调度队列)来管理你所提交的任务,并按先进先出的顺序来执行,保证第一个被提交的任务第一个开始。

Dispatch Queue 是线程安全的,这意味着你可以从多个线程同时访问它们。当你理解了怎么用 dispatch queue 为你的部分代码提供线程安全时,GCD 的好处是比较明显的。关键是挑选出合适的 dispatch queue 以及正确的使用调度函数(dispatching function)来提交你的代码(work)到这个队列。

Queues 可以为 serial(串行)或 concurrent(并发)。Serial队列确保任何时刻只有一个任务在运行。GCD控制着执行时间。你不知道当前任务结束与下一任务开始之间的时间为多少:

Concurrent 队列允许多个任务在同一时间运行。这些任务将按照添加时的顺序开始。这些任务可能以任何顺序结束并且你即不知道下一个任务什么时候开始,也不知道什么时候有多少个任务在执行。

下面是任务执行的例子:

注意 Task 1、Task 2 与 Task 3 三者先后较快的开始执行。另一方面,Task 0 结束时Task1才开始,同样我们也注意到 Task 3 比 Task 2 慢执行,但 Task 3 先结束。

什么时候开始一个任务这完全取决于 GCD。如果一个任务的运行时间覆盖到了其他任务,它将由GCD决定否应该运行在不同的上,如果有空闲的话,或者执行一个 context switch(上下文切换)来运行不同的任务。

GCD提供了三个主要的队列类型:

  1. Main queue(主队列): 运行在主线程上,它是 serial queue(串行队列)。

  2. Global queues(全局队列): 整个系统共享的并发队列。它有4种不同优先级的队列:high、default、low 以及 background。background 权限的队列是 I/O 节流(throttled)。

  3. Custom queues(自定义队列):你可以创建串行或并行队列。实际上,归根到底它最后还是由一个全局队列来处理。(These actually trickle down into being handled by one of the global queues.)

    当设置一个全局并发队列,你不能直接指定它的优先级。但你可以指定一个 Quality of Service(Qos) 的类属性。它将表明任务的重要性并给GCD决定这个任务的优先级提供参考。

Qos 类别有:

  • User-interactive:这个表示任务必需立刻完成,从而提供一个比较好的用户体验。使用它来更新 UI,事件处理以及低延迟且较小的工作量。在这个类里,所有的工作在你的 app 执行期间应该比较小。这些应该运行在主线程上。

  • User-initiated:这个表示从 UI 初始化的任务并可以异步执行。它应该被用于用户等待的结果需要立刻返回,或需要持续交互的任务。它将被映射到高优先级的全局队列。

  • Utility:这个表示长时间运行的任务,典型的会有用户可视进度指示器。使用它来计算,I/O,网络,连续的数据提供以及类似的任务。这个类被设计为高能耗。它将被映射到低优先级的全局队列。

  • Background:这个表示用户并不会直接查觉到的任务。使用它来 prefetching(预获取),维护以及其他不需要用户交互与时间不敏感的任务。它将被映射到 background 优先级的全局队列。

Synchronous(同步) vs. Asynchronous(异步)

使用 GCD,你可以 dispatch(调度)一个同步或异步任务。

一个同步函数控制着调用者在任务完成后返回。

一个异步函数立即返回,任务会按顺序开始,但不会等待任务完成。 因此,一个异步函数不会堵塞当前线程继续执行下一函数。

Managing Tasks

关于 tasks(任务)你现在已经知道比较多了。出于这篇教程的目的你应该考虑用 closure 为 task。Closures 是 self-contained(自包含),代码回调 blocks,它可以被存储或做为参数传递。

使用 DispatchWorkItem 来封装 Tasks 提交到 DispatchQueue 中。你可以配置 DispatchWorkItem 的行为,例如 QoS 类或者产生一个新的独立线程。

处理后台任务

知道了这么多 GCD 知识后,是时候用来改善你的 app 了。

回到 app 并通过你的 Photo Library 或者使用 Le Internet选项来下载几张图片。点击图片。注意一下 Photot DetailVC 要多久才能显示出来。当在较慢的设备上展示较大的图片时,延迟是非常显示的。

重载 View Controller 的 viewDidLoad() 方法容易导致在View展示之前有较长的等待。如果一些任务在加载时是非必要的,那么可以将这些任务放在后台执行。

这听上去好像是DispatchQueueasync 的工作!

打开 PhotoDetailViewController.swift。修改 viewDidload() 并替换这两行代码:

1
2
let overlayImage = faceOverlayImageFromImage(image)
fadeInNewImage(overlayImage)

使用下面的代码:

1
2
3
4
5
6
DispatchQueue.global(qos: .userInitiated).async { // 1
let overlayImage = self.faceOverlayImageFromImage(self.image)
DispatchQueue.main.async { // 2
self.fadeInNewImage(overlayImage) // 3
}
}

我们一步一步来解释这些代码做了什么:

  1. 你将work移动到后台全局并在异步闭包中运行 work。这让 viewDidLoad() 在主线程上提前完成并使用加载感觉更简短。此时,人脸识别处理已经开始并将在晚一点的时候完成。
  2. 这个时候,人脸识别处理已经完成并生成新的图片。由于你想用这张新图片来更新你的 UIImageView,你添加一个新的闭包到主线程队列。记住——你访问 UIKit 的类的时候,必须始终在主线程上。
  3. 最后,你调用fadeInNewImage(_:)更新UI,该方法执行渐入效果添加新的googly eyes图片。

Build 并运行 app。通过 Le Internet 选项下载一些图片。选择一张图片,你会注意到 view controller 加载的速度明显并快并且在稍候添加一些 googly eyes 上去。

This lends a nice before and after effect to the app as the googly eyes are added. 甚至,如果你尝试去加载一张更大的图片,app 不会挂起 view controller 同样会加载完成。

通常,当你需要执行一个在后台中的 network-based (基于网络)或CPU intensive 的任务且并不会阻塞当前线程时,你将会想到使用 async

这里有一份怎样使用、什么时候使用 async 的几种队列的快速指南:

  • MainQueue:当在并发队列上的一个任务完成时,通常会选择这个队列去更新UI。为此,你将编写一个 closure 嵌入到其他队列中。定位到主线程队列并调用 async 确保这个新的任务将在当前方法完成后的某个时间执行。
  • GlobalQueue:这个通常使用在非 UI 工作的后台中。
  • Custom Serial Queue:非常好的选择,当你想执行一连串的后台工作或追踪它时。由于同一时间只能执行一个任务,所以它消除了争抢资源的问题。注意,如果你想从一个方法中获取数据,你必须使用内联的 closure 来获取或者考虑使用 sync

Delaying Task Execution(延迟任务执行)

DispatchQueue 允许你延迟任务的执行。Care should be taken not to use this to solve race conditions or other timing bugs through hacks like introducing delays. 当你希望任务在指定时间运行时使用它。

先思考一下你的 app 的用户体验。当用户第一次打开app时,他们有可能会困惑这个 app 是干什么用的,不是吗?

如果在没有图片的情况下,给予用户一些提示会是一个不错的主意。你同样应该考虑到用户的视线是怎样导航首页的。如果提示过早,他们可能没注意到。1秒钟的时间延迟提示信息应该可以足够的引起用户的注意以及引导他们。

打开PhotoCollectionViewController.swift并在showOrHidNavPromp()中实现以下代码:

1
2
3
4
5
6
7
8
9
let delayInSeconds = 1.0 // 1
DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { // 2
let count = PhotoManager.sharedManager.photos.count
if count > 0 {
self.navigationItem.prompt = nil
} else {
self.navigationItem.prompt = "Add photos with faces to Googlyify them!"
}
}

解释一下上面的代码:

  1. 你指定了一个需要延迟多少时间的变量。
  2. 你然后等待指定的时间,然后异步运行更新图片数量和提示信息的 block。

showOrHidNavPrompt() viewDidLoad()中执行并用任意时刻UICollectionView重新加载。

Build 并运行 app。在提示信息显示之前应该有较小的延迟:

那什么情况下使用 asyncAfter 呢?通常情况下,在主队列中使用是比较好的选择。如果在全局后台队列或自定义串行队列中使用的放,你要多加小心。你最好还是坚持在主队列中使用。

为什么不使用 Timer 呢?如果你有重复的任务并容易按照预定时间执行,那么你应该考虑使用它。这里有两点原因支撑使用 asyncAfter

第一是可读性。使用 Timer 你需要定义一个方法然后创建一个带selector或 invocation 的 timer。而 DispatchQueueasyncAfter 你只要简单的添加一个closure。

Timer 是 scheduled 在 runloop 上,所以你必需确保它是 scheduled 在runloop上的,你想要开启的(在正确 runloop modes 的情况下)。就这一点而言,使用asyncAfter就非常简单了。

Managing Singletions(管理单例)

单例。爱并恨着,它们在iOS中很普遍,就像猫的图片在web上一样。:]

单例的一个常见的问题是它们通常不是线程安全的。鉴于使用它们,这种担心也是合理的:单例通常是被多个控制器在同一时间访问单一实例。你的 PhotoManager 类就是一个单例,所以你需要考虑这一问题。

线程安全的代码在多线程或并发任务调用时是安全的,不会出现数据腐化(data corruption)或者导致app崩溃的问题。如果代码不是线程安全的,那么它在同一时间只能在一个上下文中运行。

这里有两种线程安全情况需要考虑,单实例初始化过程中以及实例对象读写过程中。

初始化的情况实现起来比较简单,因为有 Swift 管理初始化全局变量。当全局变量第一次访问后,它们将被实例化,并且保证它们是以 atomic 的方式被初始化。也就是说,代码执行初始化是经过临界区(critical section)处理的并且保证在其他任意线程访问全局变量之前完成。

一个临界区(critical section)是不会并发执行的一段代码,也就是说,两个线程不能在同一时间执行的代码。这是比较常见的,因为一段代码维护一个共享资源,比如一个变量,如果使用并发进程访问的话这个变量将腐化(corrupt)掉。

打开PhotoManager.swift,来看看单例是怎样被初始化的:

1
private let _sharedManager = PhotoManager()

这个私有的全局变量 _sharedManager 被使用到了 PhototManager 懒加载初始化里。正如下所示,它只有在第一次访问时执行:

1
2
3
class var sharedManager: PhototManager {
return _sharedManager
}

这个公开的sharedManager变量返回了私有的 _sharedManager 变量。Swift 会确保这一操作是线程安全的。

当访问单例中共享内部数据代码时,你仍然需要处理线程安全问题。你可以通过使用例如 synchronizing 数据访问的方法来处理它。你将在下一章节中看到一种解决方案。

Handling the Readers-Writers Problem(处理读者-写者问题)

在Swift中,使用 let 关键字定义的任意变量都会被识别为常量并且是只读与线程安全的。然而使用 var 关键字定义的变量,为可变的并且非线程安全的,除非是被设计过的数据类型。当Swift集合类型如 ArrayDictionary 被定义为可变时,它们是非线程安全的。

虽然多个线程同时读一个可变数组时没有问题,但当有一个线程在修改这个线程时而其他线程在读时是非线程安全的。在当前的状态中,你的单例并不会阻挡这种状况的发生。

来看下这个问题,看一下 PhotoManager.swift 中的 addPhoto(),它被复制到下面:

1
2
3
4
5
6
func addPhoto(_ photo: Photo) {
_photos.append(photo)
DispatchQueue.main.async {
self.postContentAddedNotification()
}
}

这是一个write方法,它修改了可变数组对象。

现在看一下photos属性,如下:

1
2
3
4
fileprivate var _photos: [Photo] = []
var photos: [Photo] {
return _photos
}

这个属性的 getter 方法被称为 read 方法,读这个可变数据。调用者获取这个数组的复本并且没有合适的保护原来的数组。当一个线程调用写方法 addPhoto() 同时其他线程在调用 photos 属性的 getter 方法时,这里并没有提供任务保护措施。

Note: 在上面的代码中,为什么说调用者获取是这个数组的复本呢?在 Swift 中,参数与函数的返回类型是传递引用类型或值类型。

传递的复本对象是值类型话,修改复本是不会影响原始对象的。在Swift中,默认情况下class实例是引用类型,结构体是值类型。Swift 内置的数据类型,比如 ArrayDictionary 是采用结构体实现的。

当反复的传递集合类型时,它们看起来会产生大量的复本。不需要担心关于内存使用的问题。Swift 的集合类型是经过性能优化过的,只有在必要的情况下才会复制,例如当经过值传递的数组在通过后第一次被修改。(for instance when an array passed by value is modified for the first time after being passed.)

这就是经典的软件开发读者-写者问题。GCD提供了一个优雅的解决方案,就是使用 dispatch barriers 创建一个read/write lockDispatch barriers 是一个函数组,当运行在并发队列上时,它相当于serial-style的瓶颈。

当你将 DispatchWorkItem 提交到一个 dispatch queue 时,你可以设置 flags 来指示它应该是指定排列的特定时间上的唯一任务。这意味着已经所有提到队列的任务必须在 dispatch barrier 执行DispatchWorkItem之前完成。

DispatchWorkItem 到达时,barrier 会执行它并确保该任务运行期间不会执行队列中的其他任务。一旦执行完毕,该队列将恢复到默认实现状态。

下图描述了 barrier 任务对其他多个异步任务的影响:

注意这个队列的常规操作就像一个正常的并发队列。但是,当 barrier 执行期间它就像一个串行队列。也就是说,barrier 只执行唯一的任务。等 barrier 完成后,该队列将恢复为一个正常的并发队列。

当在全局后台队列中使用 barrier 时要小心,因为这些队列是资源共享的。在串行队列中使用 barrier 是多余的,因为它已经是串队执行。在自定义并发中使用 bairrer是很好的选择,它可以处理临界代码原子性的线程安全。

你将使用一个自定义并发队列来处理你的 barrier 函数与分隔读与写函数。该并发队列允许你进行并发读操作。

打开 PhototManager.swift 并在 _photots 上添加一个私有属性:

1
2
3
4
fileprivate let concurrentPhotoQueue =
DispatchQueue(
label: "com.raywenderlich.GooglyPuff.photoQueue", // 1
attributes: .concurrent) // 2

这里将 concurrentPhotoQueue 初始化为并发队列。

  1. 你设置了 label 描述名称,在 debug 时它非常在用。一般情况下,你会使用颠倒的 DNS 来命名;
  2. 你指定它为并发队列。

下一步,使用以下代码覆盖 addPhoto(_:) 方法:

1
2
3
4
5
6
7
8
func addPhoto(_ photo: Photo) {
concurrentPhotoQueue.async(flags: .barrier) { // 1
self._photos.append(photo) // 2
DispatchQueue.main.async { // 3
self.postContentAddedNotification()
}
}
}

以下是这个函数的解释:

  1. 你 dispatch 这个异步写操作为 barrier。当它执行时,它将成为你的队列中的唯一 item;
  2. 你将 photo 对象添加到数组;
  3. 最后你发送了 photo 被添加的通知。这个通知应该在主线程队列中发送,因为它将会做 UI 更新操作。所以你异步的 dispatch 这个任务到主线程队列来触发这个通知。

这是写方法的实现细节,不过你同样需要实现 photos 的读方法。

为了确保写操作的线程安全,你需要在 concurrentPhotoQueue 上执行读写作。你需要在函数调用时返回数据,而使用异步 dispatch 并不会正常返回。在这种情况下,使用 sync 是个不错的选择。

使用 sync 来追踪你的 dispatch barriers 任务,或者当你使用 closure 进行数据处理之前需要行者之前的任务操作完成。

使用时要尽可能的小心。想像一下,如果你已经运行在当前队列,你又使用 sync 到当前队列。这时将造成 deadlock(死锁)的情况。

两个(或两个以上)items ——通常情况下为线程——如果它们都在等待其他线程完成或执行其他的动作将会产生死锁。第一个不能完成是因为它在等待第二个的运行结束。而第二个不能完成是因为它在等待第一个的结束。

你的情况是,sync 调用将等待直到到 closure 完成,但 closure 不能结束(它甚至都不会开始!)直到当前正在执行的 closure 结束,它结束不了!这使用必须意识到哪些队列应该调用——同样的,哪些队列你可以调入。(原文:This should force you to be conscious of which queue you’re calling from — as well as which queue you’re passing in.)

以下简短的总结了什么情况下使用 sync

  • Main Queue:非常小心!与上面同样的原因;这种情况会隐患的产生死锁;
  • Global Queue:这是好的选择,使用 sync 搭配 dispatch barriers时或当行等待一个任务完成后再更进一步的处理数据;
  • Custom Serial Queue:这种情况时要非常小心!如果你运行在一个队列中并且你调用 sync 到同一队列中时,你明显的创建了一个死锁。

仍然,在 PhotoManager.swift 中修改 photos 属性的 getter:

1
2
3
4
5
6
7
ar photos: [Photo] {
var photosCopy: [Photo]!
concurrentPhotoQueue.sync { // 1
photosCopy = self._photos // 2
}
return photosCopy
}

解释上面的代码:

  1. 使用同步 Dispatch 到 concurrentPhotoQueue执行读操作;
  2. 保存 photo 数据的副本到 photosCopy 并作为返回值返回。

Build 并运行 app。使用 Le Internet下载图片。它应该跟之前一样,但这次你使用一些有趣线程来实现它。

恭喜!现在你的 PhotoManager 是线程安全的了。不管理你怎样去读或写操作图片,你可以自信的确保不会出现任何意外,安全的方式完成。

接下来做什么?

在这篇教程中,你学习了怎样让你的代码线程安全与在 CPU 执行比较密集任务的情况下,如果维护主线程的响应灵敏。

你可以下载完整的代码 completed project,它包含了所有的代码改善。在第二部分教程中你将继续改善这个项目。

如果你计划改善你自己的 app,你应该使用 Instruments 中的 Time Profile 模块进行测试。使用这些工具是本教程的范畴之外,你可以查看 How to Use Instruments 它很好的介绍了 Instruments。

确保你 profile 的是真实设备,由于在模拟器上测试与你的用户所体验的结果有非常大的区别。

你可能想查看 this excellent talk by Rob Pike 的 Concurrency vs Parallelism。

我们的 iOS Concurrency with GCD and Operations 视频教程系列同样覆盖了多数相关的主题。

下一篇教程中,你将更加深入的研究一些更酷的 GCD’s API。