Grand Central Dispatch Tutorial for Swift 4(Part 1/2)

Grand Central Dispatch (GCD) 是一个用来管理并发操作的底层API,它可以通过将计算成本高昂的任务推迟到后台来提高你的应用的响应速度,与锁和线程相比它是一套更易用的并发模型。

Getting Started

下载 初始项目 ,用 Xcode 打开它看看。

首页最开始是空白的。点击 + ,选择 Le Internet 来从网络下载预设的图片,选择第一张图,然后你将看到一个卡通的眼球添加在了脸上。

starter_app_flow

在本教程中你将主要使用以下四个 Class:

  • PhotoCollectionViewController : 初始控制器,用来一块一块的显示选择的图片。
  • PhotoDetailViewController:显示一张来自 PhotoCollectionViewController 的图片,并加上卡通的眼球上图片上。
  • Photo :用来描述 Photo 的属性。它提供一张图、一张缩略图图和相应的状态。这个项目提供了两个实现它的类:DownloadPhoto 从一个URL实例实例化照片;AssetPhoto 从一个PHAsset实例实例化照片
  • PhotoManager 管理所有的 Photo 实例。

该应用程序存在一些问题。在运行应用程序时您可能已经注意到的一个是下载完成警报为时过早。你将在后面解决这个问题。

下面,你将进行一些改进,包括优化googly-fying过程和使 PhotoManager 线程安全。

GCD Concepts

要理解GCD,您需要熟悉与并发和线程相关的几个概念。

Concurrency

在 iOS 中,进程或应用程序由一个或多个线程组成。操作系统调度程序彼此独立地管理线程。每个线程可以并发操作,但由系统来决定是否并发,何时并发以及如何并发。

单核设备通过称为时间切片(time-slicing)的方法实现并发,它们运行一个线程,执行上下文切换,然后运行另一个线程。

Concurrency_vs_Parallelism

另一方面,多核设备通过并行(parallelism)同时执行多个线程。

GCD构建在线程上,在引擎盖下它管理着共享线程池。通过GCD,你可以添加代码块到 dispatch queues ,由GCD决定哪个线程执行它们。

构建代码时,你将发现代码块有时可以同时运行但是有时候不可以。这时你可以使用GCD 取得并发执行的优势。

注意,GCD需要根据系统和系统可用资源决定它可以并行(parallelism)多少,要重点注意的是并行(parallelism)需要并发(concurrency),但并发并不能保证并行。

根本上说,并发是围绕结构(structure),而并行是围绕执行(execution)。

Queues

如前所述,GCD通过一个名为 DispatchQueue的类来操作调度队列(dispatch queues)。你提交的到队列的工作单元,GCD将通过先进先出(FIFO) 的规则执行它们,以保证最早提交的队列将最早开始执行。

调度队列是线程安全(thread-safe)的,这意味着您可以同时从多个线程访问它们。当您了解调度队列如何为你写的代码的某些部分提供线程安全时,GCD的好处是显而易见的。这个问题的关键在于选择正确的 dispatch queues 类型和正确的 dispatching function 来提交你的操作到队列。

队列 (Queues) 可以是串行(serial)或者是并行(concurrent)的。串行队列保证在任何给定时间只运行一个任务。GCD控制执行时间。你将不知道一个任务结束和下一个任务开始之间的间隔时间。

Serial-Queue-Swift

并发队列允许在同一时间多个任务同时执行。任务可以按任何顺序完成,你将不知道下一个任务启动所需的时间,也不知道在任何给定时间运行的任务数。

这是设计使然:您的代码不应该依赖于这些实现细节。

看看下面这个任务执行的例子:

Concurrent-Queue-Swift

请注意Task1,Task2和Task3是一个接一个地快速启动。另一方面,Task0之后,Task1过了一会儿才启动。同时注意到Task3 在Task2 后面开始但是优先结束。

决定什么时候开始任务完全取决于GCD 。如果一个任务的执行时间与另一个任务重叠,GCD决定它是否应该运行在不同的核心上。如果只有一个核心可用,可能通过执行上下文切换以运行其他任务。

GCD 提供三种主要是队列:

  1. Main queue: 运行在主线程,并发队列。
  2. Global queues: 整个系统共享的并发队列。这有四种优先级:high、default、 low 和 background。background 级队列优先级最低,并在任何 I / O 活动中受到限制,以最大限度地减少系统负担。
  3. Custom queues:你创建的队列,可以是串行或并发。这些队列中的请求实际上最终在一个全局队列中。

将任务发送到全局并发队列时,不用直接指定优先级。而是使用 Quality of Service (QoS) 类的属性,它决定了任务的权重,指导 GCD 去确定给予任务的优先级。

Qos 类包括:

  • User-interactive 它代表了任务必须立即完成来提供良好的用户体验。可以使用它来更新UI、响应事件和一些需要低延迟的小工作。这些操作的总耗时应该很小。它们在主线程执行。
  • User-initiated: 用户从UI启动的异步任务。当用户需要一个直接的返回值然后任务需要继续执行用户交互。它们在高优先级的全局主队列中执行。
  • Utility 表示长时间运行的任务,通常会伴随一个用户可见的进度指示器。使用它来计算、I/O、网络请求、连接数据流等类似的操作。这个类致力于高效节能。将被映射到低优先级全局队列。
  • Background: 表示用户意识不到的任务。用来预获取,维护和其他不需要用户交互的时间不敏感的任务。它们在 background 优先级的全局主队列中执行。

Synchronous vs. Asynchronous

使用 GCD 你可以同步或者异步调度任务。

一个同步函数(synchronous function)会在任务完成后将控制权返回给调用者。调用 DispatchQueue.sync(execute:)你可以安排工作单元同步工作。

一个异步函数 (asynchronous function )会立即返回,按顺序开始任务但是不等待它完成。因此,异步函数不会阻止当前执行线程继续执行下一个函数。调用 DispatchQueue.async(execute:)你可以安排工作单元异步工作。

Managing Tasks

前面已经多次提到任务了。在这个教程中,你可以认为每个任务是一个闭包。闭包是独立的,可以引用和传递的可调用的代码块。

你提交给DispatchQueue每个任务都是一个 DispatchWorkItem。你可以配置 DispatchWorkItem 的行为例如它的 QoS 类或者是否产生一个新的分离的线程。

Handling Background Tasks

是时候通过这些压抑的 GCD 知识来改善你的一个应用了。

回到应用程序并从你的照片库或使用 Le Internet 选项下载来添加一些照片。点击一张图片。注意照片详情显示出来所需要的时长。在较慢的设备上查看大图像时,滞后更明显。

重载视图控制器的viewDidLoad()很容易,导致在视图出现之前等待很长时间。可以加载时的一些不必要的操作移到后台。

这听起来是DispatchQueueasync的工作!

打来 PhotoDetailViewController.swift 修改 viewDidLoad() 替换这两行代码:

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

使用下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else {
return
}
let overlayImage = self.faceOverlayImageFrom(self.image)

// 2
DispatchQueue.main.async { [weak self] in
// 3
self?.fadeInNewImage(overlayImage)
}
}

这是代码一步一步做的事情:

  1. 你把工作移动到 background global queue ,在闭包中异步运行它。这使得 viewDidLoad() 在主线程中提早的完成,使加载看起来更利索。与此同时,面部检测处理开始了并在稍后完成。
  2. 在此刻,面部检测处理完成,你生成了一个张新图片。然后你想使用新的图片更新你的 UIImageView ,你在主线程添加了新的闭包。记住:所有修改UI 的操作必须在主线程运行。
  3. 最后你使用 fadeInNewImage(_:) 更新UI,该方法执行新的卡通眼睛图像的淡入过渡。

在两个地方,你添加了 [weak self] 来在每个闭包中获得 self 的弱引用。如果您不熟悉捕获列表,参考: 这个内存管理的教程.

构建运行应用。通过 Le Internet 选项下载一些图片。选择一张图片,你将注意到视图控制器载入明显更快,然后在短暂的延迟后添加了卡通眼球。

Simulator-Screen-Shot-iPhone-8-2018-05-29-at-17.43.43

当卡通眼睛出现时,为应用程序提供了一个很好的前后效果。即使你尝试加载一个非常庞大的图片,你的应用程序也不会在视图控制器加载时挂起。

通常,当你需要在后台执行基于网络或CPU密集型的任务而不阻止当前线程时,你要使用 async

以下是如何使用以及何时使用async的各种队列的快速指南:

  • Main Queue: 是在完成并发队列上的任务之后更新UI的常见选择。你将编写一个闭包在另一个闭包的内部。瞄准主线程调用 async保证在当前方法完成后的某个时间执行此新任务.
  • Global Queue: 这是在后台执行非UI工作的常见选择。
  • Custom Serial Queue 当你想执行连续的后台任务并且追踪他。这消除了资源争用和竞争条件,因为您知道一次只执行一个任务。注意,如果需要方法中的数据,则必须声明另一个闭包来检索它或考虑使用
    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

    ## Delaying Task Execution

    ``DispatchQueue`` 允许你延迟任务的执行。不要使用它通过像引入延迟的hack解决**竞争条件**或者其他时间的掌控上的bug,而是当你想在特定的时间运行一个任务是使用它。

    暂时考虑一下您的应用的用户体验,用户可能会对第一次打开应用时该怎么做感到困惑 - 是吗? :]

    如果没有任何照片,最好向用户显示提示。你还应该考虑用户的眼睛如何在主屏幕上巡视,如果你太快的展示一个提示,当他们的眼睛徘徊在视图的其他部分时,他们可能会错过它。一个2秒的延迟足够用来捕捉用户的注意力并引导他们。

    打开 *PhotoCollectionViewController.swift* ,在``showOrHideNavPrompt()``的实现中填入:

    ```swift
    // 1
    let delayInSeconds = 2.0

    // 2
    DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { [weak self] in
    guard let self = self else {
    return
    }

    if PhotoManager.shared.photos.count > 0 {
    self.navigationItem.prompt = nil
    } else {
    self.navigationItem.prompt = "Add photos with faces to Googlyify them!"
    }

    // 3
    self.navigationController?.viewIfLoaded?.setNeedsLayout()
    }

上面的代码做了以下事情:

  1. 指定一个延迟总时长。
  2. 然后等待指定的时间,然后异步运行更新照片计数的Block并更新提示。
  3. 设置提示后强制导航栏进行布局,以确保它符合要求。

showOrHideNavPrompt()viewDidLoad() 和你的 UICollectionView 刷新的任何时候执行。

构建并运行应用,在你看到一个提示显示之前将有一个轻微的延迟。

Simulator-Screen-Shot-iPhone-8-2018-05-29-at-17.46.41

Note: 你可以忽略Xcode 控制台中Auto Layout 的信息,它们都来自iOS,并不表示你的错误。

为什么不使用定时器(Timer)? 如果你有重复的任务更容易使用Timer安排可以考虑使用它。这有两个理由坚持使用 dispatch queue 的 asyncAfter()

一个是可读性。使用定时器你需要定义一个方法,然后使用方法选择器和定义好的方法创建一个定时器。而使用 DispatchQueueasyncAfter(),你仅仅加了一个闭包。

计时器是在运行池上排布的,因此你还必须确保在正确的运行池(或正确的运行池模式)上排布它。因此,使用 dispatch queue 更简单。

Managing Singletons

##

单例。 有人爱他或者有人恨他,它在iOS上的常见程度想网络上的猫的照片一样。

常常涉及到的一点是他们通常不是线程安全的。考虑到单例的使用,这个考虑是合理的:单例通常用于同时访问单例实例的多个控制器。你的 PhotoManager 是一个单例,所以你需要考虑这个问题。

线程安全的代码可以从多个线程或并发任务安全地调用,而不会导致任何问题,例如数据损坏或应用程序崩溃。非线程安全的代码一次只能在一个上下文中运行。

线程安全需要考虑的有两个点:在单例实例的初始化期间以及对实例的读写期间。

单例的初始化很简单,这是由于Swift初始化静态变量的方式。它在首次访问时初始化静态变量,并保证初始化是原子的。也就是说,Swift将执行初始化的代码视为关键部分,并保证在任何其他线程访问静态变量之前完成。

另一个关键点是一段不能同时执行的代码,即一次从两个线程执行。这通常是因为代码操纵共享资源(如变量),如果它由并发进程访问,则可能会损坏。

打开 PhotoManager.swift 来看看你是如何初始化单例的:

1
2
3
4
class PhotoManager {
private init() {}
static let shared = PhotoManager()
}

私有构造器确保唯一的PhotoManager是通过访问 shared得到的。这样你就不必担心会在不同管理器之间同步更改照片库。

在访问单例中操作共享的内部数据的代码时你还是必须处理线程安全性。你可以通过同步数据访问等方法来处理此问题。你将在下一节中看到一种方法。

Handling the Readers-Writers Problem

在Swift 中,任何 let关键词声明的变量都是常量,因此,他们是只读且线程安全的。然而,使用var关键字声明变量,它会变得可变并且不是线程安全的,除非数据类型被设计为如此。Swift的集合类型、向Array

Dictionary 定义为可变时不是线程安全的。

尽管许多线程可以同时读取可变的 Array实例而没有问题,但当一个线程读取它时让另一个线程修改它是不安全的。你的单例当前的状态不会阻止这种情况发生。

要查看问题,查看 PhotoManager.swift中的addPhoto(_ :),如下所示:

1
2
3
4
5
6
func addPhoto(_ photo: Photo) {
unsafePhotos.append(photo)
DispatchQueue.main.async { [weak self] in
self?.postContentAddedNotification()
}
}

这是一个修改可变数组实例的 方法。

然后来看一下 photos属性:

1
2
3
4
5
private var unsafePhotos: [Photo] = []

var photos: [Photo] {
return unsafePhotos
}

这个属性的getter方法读取可变数组,这被称为 操作。调用者获取数组的副本,并防止不适当地改变原始数组。然而它不会在一个线程调用写方法addPhoto(_:)另一个线程同时调用photos属性的getter方法时提供任何保护。

这就是为什么返回的变量被命名为unsafePhotos - 如果它在错误的线程上访问,你可能会得到一些古怪的行为!

这是经典的软件开发 读写问题。通过 dispatch barriers , GCD提供一个优雅的创建读写锁解决方案。Dispatch barriers 是一组在使用并发队列时充当串行式瓶颈的函数。

当你向一个 dispatch queue 提交 DispatchWorkItem 时,你可以设置标志以指示它应该是在特定时间内在指定队列上执行的唯一项目。这意味着在 dispatch barrier 之前提交到队列的所有项必须在 这个DispatchWorkItem执行之前完成。

当运行到 DispatchWorkItem时,barrier executes 执行它并确保队列在此期间不执行任何其他任务。完成后,队列将返回其默认实现。

下图说明了 barrier 对各异步任务的影响:

Dispatch-Barrier-Swift

请注意,在正常操作中,队列的行为与普通并发队列的作用相同。但是当屏障执行时,它基本上就像一个串行队列。也就是说,barrier 是唯一执行的事情。barrier 完成后,队列将返回到正常的并发队列。

在全局后台并发队列(global background concurrent queues )中使用 barriers 时要慎重,因为这些队列是共享资源。在自定义串行队列中使用 barriers 是多余的,因为它已经是串行行执行。在自定义并发队列中使用 barriers 是处理原子或关键代码区域中的线程安全的绝佳选择。

你将使用自定义并发队列来处理屏障功能并分离读写功能。并发队列将允许同时进行多个读取操作。

打开 PhotoManager.swift 并在 unsafePhotos声明上方添加一个私有属性:

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

上述代码将 concurrentPhotoQueue初始化为并发队列。你可以设置 一个描述性名称给的label属性,这样便于调试。通常,按照命名约定使用反向DNS样式。

然后使用下列代码替换 addPhoto(_:)方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func addPhoto(_ photo: Photo) {
concurrentPhotoQueue.async(flags: .barrier) { [weak self] in
// 1
guard let self = self else {
return
}

// 2
self.unsafePhotos.append(photo)

// 3
DispatchQueue.main.async { [weak self] in
self?.postContentAddedNotification()
}
}
}

刚刚写的新方法将这样工作:

  1. 使用 barrier 异步调度写入操作。执行时,它将是队列中唯一的项目。
  2. 添加对象到数组。
  3. 最后,发送了添加照片的通知。发布通知的操作必须在主线程上,因为它将执行UI工作。因此,异步的派发另一个任务到主队列以触发通知。

这个任务考虑了写入,但你还需要实现photos读取方法.

要确保写入的线程安全,你需要在concurrentPhotoQueue队列中执行读取操作。你需要从函数调用返回数据,因此异步调度不符合要求。在这种情况下,同步将是一个很好的选项。

使用 sync可以通过 dispatch barriers 跟踪工作,或者在需要等待操作完成时,在这之前你可以使用闭包处理数据。

但是你需要小心,如果你瞄准你当前运行的队列调用sync。这将导致死锁情况。

两个操作(或有时更多)- 在大多数情况下线程死锁的原因 : 如果它们都被卡住等待彼此完成或执行另一个操作则会死锁。第一个无法完成,因为它正在等待第二个完成。但第二个无法完成,因为它正在等待第一个完成

在你的案例中,sync 调用将等到闭包完成,但闭包无法完成(或启动!),直到当前正在执行的不能完成的闭包完成!这应该会强制你了解你正在调用的队列 - 以及你传入的队列。

以下是使用sync的时机和位置的简单概述:

Main Queue: :出于与上述相同的原因,要非常小心;这种情况也有可能造成锁死。这在主队列上尤其糟糕,因为整个应用程序将无法响应。

Global Queue: 这是使用 dispatch barriers 或者 在等待任务完成时执行下一步处理的极佳选择。

Custom Serial Queue: 需要非常小心;如果你正在队列中运行并瞄准当前队列调用 sync,那么肯定会造成死锁。

停留在 PhotoManager.swift中,修改photos属性的getter:

1
2
3
4
5
6
7
8
9
10
11
var photos: [Photo] {
var photosCopy: [Photo]!

// 1
concurrentPhotoQueue.sync {

// 2
photosCopy = self.unsafePhotos
}
return photosCopy
}

这是每一步的进度:

  1. 同步调度到concurrentPhotoQueue 执行读取。
  2. 将照片数组的副本存储在 photosCopy中并将其返回。

构建并运行应用程序。通过Le Internet选项下载照片。它看起来应该像以前一样,但在引擎盖下,你有一些非常幸福的线程。

Simulator-Screen-Shot-iPhone-8-2018-05-29-at-17.48.27

恭喜 - 你的PhotoManager单例现在是线程安全的!无论你在何处或如何读取或写入照片,你都可以确信它会以安全的方式发生而不会出现任何意外。