9/54
Feb20-Feb26
iOS
Swift
GCD
并发
译文
Grand Central Dispatch(GCD)是一种用于管理并发操作的低级API。 GCD可以提高应用程序的响应能力,通过将计算成本高的任务放到后台。这是比锁和线程更容易的并发模型。
在Swift 3中,GCD进行了重大改进,从基于C的API转移到包含新类和新数据结构的Swiftier
API。
第一部分将解释GCD的功能,并展示几个基本的GCD functions。第二部分中,你将了解一些高级功能。
你将构建一个GooglyPuff App。 GooglyPuff是一个非优化的“线程不安全”的应用程序,使用Core Image的面部检测API覆盖检测到的面部上的眼镜。您可以从照片库中选择要应用此效果的图像,或选择从互联网下载的图像。
你在本教程中的任务是使用GCD优化应用程序,并确保你可以安全地从不同的线程调用代码。
开始
下载初始项目,Run。
主屏幕初始为空。点击+
,然后选择Le Internet
下载预定义的图像。点击第一个图像,你会看到 googly eyes 添加到脸上。
主要使用四个类:
- PhotoCollectionViewController:Initial view controller, 显示缩略图。
-
PhotoDetailViewController:显示从
PhotoCollectionViewController
选择的照片,并添加眼球。 -
Photo:这是描述照片属性的
Protocol
。它提供了一个图像,缩略图及其相应的状态。实现协议的两个类:DownloadPhoto
,其从URL的实例实例化照片,以及AssetPhoto
,实例化来自PHAsset
的实例的照片。 -
PhotoManager:这管理所有的
Photo
对象。
这个App有几个问题。运行应用程序时,下载完成提醒太早了。你会在系列的第二部分解决这个问题。
在这个第一部分中,你将进行一些改进,包括优化 googly-fying 进程和使PhotoManager
线程安全。
GCD概念
要理解GCD,你需要理解并发和线程相关的几个概念。
并发
在iOS中,进程或应用程序由一个或多个线程组成。线程由操作系统调度程序独立管理。每个线程可以并发执行,但由系统决定是否并发、怎样并发。
单核设备可以通过时间分片实现并发。他们将运行一个线程,执行上下文切换,然后运行另一个线程。
多核设备通过并行,同时执行多个线程。
GCD建立在线程之上。在底层它管理一个共享线程池。使用GCD,您可以向调度队列添加代码块或工作项,由GCD决定执行哪些线程。
在写代码时,你发现一些代码可以同时运行,另外一些不能。这样就允许您使用GCD来并行。
GCD基于系统和可用系统资源决定并行性。需要注意的是,两个线程并行一定是并发的,但反之不然。
大致来说,并发是关于结构,而并行是关于执行。
队列
GCD提供由 DispatchQueue 队列以管理您提交的任务,并以FIFO顺序执行它们,以确保提交的第一个任务是第一个开始的任务。
DispatchQueue是线程安全的,这意味着你可以同时从多个线程访问它们。当你理解调度队列如何为代码的某些部分提供线程安全时,GCD的好处是显而易见的。这样做的关键是选择正确的DispatchQueue和正确的dispatching功能将您的工作提交到队列。
队列可以是串行或并发的。串行队列保证在任何给定时间只运行一个任务。 GCD控制执行时序。你不会知道一个任务结束和下一个开始之间的时间。
并发队列允许多个任务同时运行。保证任务按照添加的顺序启动。任务可以以任何顺序完成,你不知道下一个任务要开始的时间,也不知道在任何时间运行的任务数。
参见下面的示例:
注意Task 1,Task 2和Task 3一个接一个地快速启动。而任务1 等了一段时间在任务0之后开始。另外,虽然任务3在任务2之后开始,但它更快完成。
何时开始任务的决定完全由GCD决定。如果一个任务的执行时间与另一个任务重叠,那么由GCD决定是否应该在不同的核上运行(如果一个任务可用),或者改为执行上下文切换以运行不同的任务。
GCD提供三种主要类型队列:
- 主队列 ( Main Queue) :在主线程上运行,是一个串行队列。
- 全局队列 ( Global Queues) :由整个系统共享的并发队列。有四个这样的队列具有不同的优先级:高,默认,低和后台。后台是I/O限制。
- **自定义队列 ( Custom queues) **:创建的队列,可以是串行或并发的。这些实际上会被一个全局队列处理。
当设置全局并发队列时,不直接指定优先级。而是指定Quality of Serive(QoS)类属性。这将指示任务的重要性,并指导GCD确定给予任务的优先级。
QoS类别:
- 用户交互式:这表示需要立即完成以提供良好的用户体验的任务。用于UI更新,事件处理和需要低延迟的小型工作负载。在执行应用程序期间,在此类中完成的工作总量应该很小。这应该在主线程上运行。
- 用户启动:表示从UI启动并可以异步执行的任务。它应该在用户正在等待即时结果时使用,并且用于需要继续用户交互的任务。这将被映射到高优先级全局队列。
- 实用程序:这表示长时间运行的任务,通常是进度指示条。用于计算,I / O,网络,连续数据和类似任务。这将被映射到低优先级全局队列。
- 后台:这表示用户不直接感知的任务。用于提取,维护和其他不需要用户交互并且不对时间敏感的任务。这将被映射到后台优先级全局队列。
同步和异步
同GCD,你可以同步或异步分派任务。
同步函数在任务完成后将控制返回给调用者。
异步函数立即返回,程序并不等待它执行完成。异步函数不会阻止当前执行线程,而会继续执行下一个函数。
管理任务
现在你已经听说过任务了。为了本教程的目的,您可以将任务视为闭包 (closure)。闭包是自包含的,可调用的代码块,可以存储和传递。(在Objective-C中叫做 Block)
提交到DispatchQueue的任务由DispatchWorkItem封装。你可以配置一个DispatchWorkItem的行为,如它的QoS类或是否产生一个新的分离的线程。
处理后台任务
来改进App吧!
添加一些照片从您的照片库或使用Le Internet
选项下载几张。点击照片。注意照片详细视图显示需要多长时间。当在较慢的设备上查看大图像时,滞后会更明显。
在viewDidLoad()
中太多的工作很容易导致view出现前长时间等待。如果不是必要的加载,可以把它放在后台执行。
这就是一个DispatchQueueAsync 操作。
打开PhotoDetailViewController.swift
。修改viewDidLoad()
替换这两行:
let overlayImage = faceOverlayImageFromImage(image)
fadeInNewImage(overlayImage)
替换代码:
DispatchQueue.global(qos:.userInitiated).async { // 1
let overlayImage = self.faceOverlayImageFromImage(self.image)
DispatchQueue.main.async { // 2
self.fadeInNewImage(overlayImage) // 3
}
}
解释:
- 将工作移到后台全局队列,并以异步方式在闭包中运行工作。这让
viewDidLoad()
在主线程完成,并使加载感觉更清晰。同时,面部检测处理开始并且将在稍后的时间完成。 - 面部检测处理完成,已生成新图像。因为你想使用这个新的图像来更新你的UIImageView,你设置image在主队列。你必须总是在主线程上访问UIKit类!
- 最后,用
fadeInNewImage(_:)
更新UI,执行新的 googly 眼睛图像的淡入转换。
运行应用程序。通过Le Internet
选项下载照片。选择一个照片,你会注意到视图控制器加载明显更快,并添加了googly眼睛一个短暂的延迟:
现在,即使你试图加载一个巨大的图像,你的应用程序也不会卡顿。
一般来说。你用async
, 当你需要执行基于网络或CPU密集型任务在后台,而不是阻塞当前线程。
这里是如何使用各种队列与async
:
- 主队列:这是一个常见的选择,以在并行队列中的任务完成工作后更新UI。调用async,保证这个新任务将在当前方法结束后的某个时间在主队列执行。
- 全局队列:这是在后台执行非UI工作的常见选择。
- 自定义串行队列:当你想要连续执行后台工作并跟踪它。这消除了资源竞争,因为您知道一次只有一个任务正在执行。请注意,如果您需要来自方法的数据,则必须内联另一个闭包以检索它或者考虑使用sync。
延迟任务执行
DispatchQueue
允许你延迟任务执行。注意不要使用这个来解决竞争条件或其他时序的bug,例如引入延迟。当您希望任务在特定时间运行时使用此选项。
对你的应用程序的用户体验来说。用户可能会对他们第一次打开应用程序时做什么感到困惑是吗?
如果没有任何照片,最好向用户显示提示。你还应该考虑用户如何使用新App。如果你显示一个提示太快,他们可能会错过它,因为他们的眼睛徘徊在视图的其他部分。显示提示之有一秒钟的延迟足以吸引用户的注意力并指导他们。
打开PhotoCollectionViewController.swift
并在showOrHideNavPrompt()
的实现以下代码:
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!"
}
}
解释:
- 为延迟时间量指定一个变量。
- 等待指定的时间,然后异步运行更新照片计数,并更新prompt。
Build & Run。在显示提示之前应该有一点延迟:
想知道什么时候适合使用asyncAfter
?一般来说,在主队列中使用它是一个不错的选择。在其他队列(如全局后台队列或自定义串行队列)上使用asyncAfter时,您需要小心。
为什么不使用Timer?你可以考虑使用它,如果你有重复的任务。这里有两个原因用asyncAfter
。
- 一个是可读性。要使用Timer,您必须定义一个方法,然后使用选择器或调用定义的方法创建计时器。使用DispatchQueue和async只需添加一个闭包。
- Timer 被安排在Run Loop上,所以你还必须确保它被安排在正确的Run Loop上。在这方面,使用调度队列更容易。
管理 Singletons
Singletons,在iOS中非常流行。
Singletons 的一个常见问题是,它们通常不是线程安全的。因为它们通常被多个Controller 同时访问。PhotoManager
是一个单例,所以你需要考虑这个问题。
线程安全的代码可以安全地从多个线程或并发任务调用,而不会导致任何问题。非线程安全的代码只能一次在一个上下文中运行。
在单例实例的初始化期间以及在对实例的读取和写入期间,需要考虑两种线程安全性情况。
初始化是容易的情况,这是由于Swift初始化全局变量的方式。全局变量在首次访问时被初始化,并且它们被保证以原子方式初始化。也就是说,执行初始化的代码被视为原子操作,保证在任何其他线程访问全局变量之前完成。
打开PhotoManager.swift
查看如何初始化单例:
private let _sharedManager = PhotoManager()
私有全局 _sharedManager
变量用于惰性初始化 PhotoManager
。 这仅发生在第一次访问:_
class var sharedManager: PhotoManager {
return _sharedManager
}
公共函数sharedManager
返回私有_sharedManager
变量。 Swift确保此操作是线程安全的_
在访问操作共享内部数据的单例中的代码时,仍然需要处理线程安全。可以通过同步数据访问等方法来处理此问题。在下一节中,您将看到一种方法。
处理 Readers-Writers 问题
在Swift中,用let
关键字声明的任何变量都被认为是一个常量,并且是只读的和线程安全的。然而,使用var关键字声明变量,它是可变的,并且不是线程安全的,除非数据类型被设计为这样。 Swift集合类型(如Array
和Dictionary
)在声明为可变时不是线程安全的。
虽然许多线程可以同时读取Array
的可变实例,这没有问题,但是让一个线程读,让一个线程修改数组是不安全的。你的单例不能防止这种情况发生。
让我们看下 addPhoto(_:)
在PhotoManager.swift
func addPhoto(_ photo: Photo) {
_photos.append(photo)
DispatchQueue.main.async {
self.postContentAddedNotification()
}
}
这是一个写方法,因为它修改了一个可变数组对象。
现在来看看photos
:
fileprivate var _photos: [Photo] = [ ]
var photos: [Photo] {
return _photos
}
此属性的getter被称为读取方法,因为它正在读取mutable数组。 调用者获得数组的副本,并且防止不适当地改变原始数组。 这不提供任何保护,以防止一个线程调用addPhoto(_:)
,而另一个线程调用getter的photos
属性。_
注意:在上面的代码中,为什么调用者得到的照片数组的副本? 在Swift中,参数和返回类型的函数通过引用或值传递。
按值传递将导致对象的副本,对副本的更改不会影响原始副本。 默认情况下,在Swift类中,实例通过引用传递,而struct通过值传递。 Swift的内置数据类型,如数组和字典,被实现为结构体。
看起来当你来回传递集合时,代码中有很多复制。 不要担心这种内存使用的影响。 Swift集合类型被优化以仅在必要时进行复制,例如当通过值的数组在传递之后第一次被修改时。
这是经典的Readers-Writers
问题。 GCD提供了一个优雅解决方案创建读写锁,通过使用 dispatch barriers
。dispatch barriers
是在使用并发队列时作为串行式瓶颈的一组函数。
当您向调度队列提交DispatchWorkItem
时,您可以设置标志以指示它应该是在特定时间在该指定队列上执行的唯一项目。 这意味着提交到队列之前的所有项目必须在DispatchWorkItem
执行之前完成。
当轮到DispatchWorkItem
时,GCD确保队列在那段时间内不执行任何其他任务。 一旦完成,队列返回到其默认状态。
下图说明了barriers
对各种异步任务的影响:
注意在正常操作中队列的行为就像一个普通的并发队列。 但是当barriers
执行时,它本质上像一个串行队列。 也就是说,barriers
是唯一执行的任务。 在barriers
完成后,队列返回到正常的并发队列。
在全局后台并行队列中使用barriers
时请谨慎,因为这些队列是共享资源。 在自定义串行队列中使用障碍是多余的,因为它已经连续执行。 在自定义并发队列中使用barriers
是最好的选择。
你将使用自定义并发队列来处理您的barrier
功能并隔离读取和写入功能。 并发队列将允许同时进行多个读取操作。
打开PhotoManager.swift
并在_photos
声明之上添加一个私有属性:
fileprivate let concurrentPhotoQueue =
DispatchQueue(
label: "com.raywenderlich.GooglyPuff.photoQueue", // 1
attributes: .concurrent) // 2
这会将concurrentPhotoQueue
初始化为并发队列。
- 您在调试期间使用描述性名称设置标签,这是有帮助的。 通常,您将使用颠倒的DNS样式命名约定。
- 指定并发队列。
接下来,使用以下代码替换addPhoto(_:)
func addPhoto(_ photo: Photo) {
concurrentPhotoQueue.async(flags: .barrier) { // 1
self._photos.append(photo) // 2
DispatchQueue.main.async { // 3
self.postContentAddedNotification()
}
}
}
这里是工作原理:
-
barrier
异步分派写操作。当它执行时,它将是队列中的唯一项目。 - 将对象添加到数组。
- 最后,您发布了您添加了照片的通知。这个通知应该发布在主线程上,因为它会做UI工作。因此,您将另一个任务异步分派到主队列以触发通知。
还需要实现照片读取方法。
为了确保线程安全,您需要在concurrentPhotoQueue
队列上执行读取。您需要从函数调用返回数据,因此异步调度不会打断它。在这种情况下,syc
将是一个很好的候选。
使用sync
来跟踪您的工作与调度障碍,或当您需要等待操作完成,然后才能使用由闭包处理的数据。
你需要小心。如果你调用sync
并且当前队列已经运行。这将导致死锁情况。
两个(或有时更多)项目 - 在大多数情况下,线程 - 被称为死锁,如果他们都卡住等待对方完成或执行另一个操作。第一个不能完成,因为它在等待第二个完成。但第二个不能完成,因为它在等待第一个完成。
以下是使用同步功能的时间和位置:
- 主队列:非常小心,这种情况也有潜在的死锁状态。
- 全局队列:这是一个很好的候选,通过调度障碍或等待任务完成同步工作,所以你可以执行进一步处理。
-
自定义序列队列:非常小心,如果您在队列中运行并调用
sync
锁定同一个队列,那么肯定会导致死锁。
仍然在PhotoManager.swift
中修改photos
属性getter:
var photos: [Photo] {
var photosCopy: [Photo]!
concurrentPhotoQueue.sync { // 1
photosCopy = self._photos // 2
}
return photosCopy
}
以下是逐步进行的操作:
-
sync
分派到concurrentPhotoQueue
上以执行读取。 - 将照片阵列的副本存储在
photosCopy
中并返回。
构建并运行应用程序。 通过Le Internet
选项下载照片。 它应该像以前一样,但在底下,你有一些健康的线程。
恭喜 - PhotoManager
Singleton 现在是线程安全的。无论在哪里或如何读或写入照片,你都可以相信它会以安全的方式完成。
下一步
在这个Grand Central Dispatch
教程中,您学习了如何使代码线程安全,以及如何在执行CPU密集型任务时保持主线程的响应。
您可以下载已完成的项目,其中包含所有改进。在本教程的第二部分中,你将继续改进此项目。
如果您打算优化自己的应用程序,您应该使用Instruments中的Time Profile
文件模板来分析您的工作。
你也许还想看看Rob Pike对并发与并行性的这个精彩演讲。
我们的iOS并发与GCD和操作视频教程系列也涵盖了很多我们在本教程中涵盖的相同的主题。
在本教程的下一部分,您将深入了解GCD的API,以做更酷的东西。
网友评论