在本节中,您将深入了解苹果最流行且易于使用的编写和管理并发任务的机制——Grand Central Dispatch
。您将学习如何利用队列和线程来控制应用程序中任务的执行,以及如何将这些任务分组在一起。您还将了解使用并发性的常见陷阱和危险,以及如何避免它们。
第3章:队列和线程
:这一章教你如何使用一个GCD队列从主线程卸载工作。你还会学到什么是“线程”。
第4章:组和信号量
:在上一章中,您了解了队列是如何工作的。在本章中,您将扩展知识,学习如何向队列提交多个任务,这些任务需要作为一个“组”一起运行,这样当它们全部完成时,您可以得到通知。您还将了解如何包装现有API,以便可以异步调用它。
第5章:并发问题
:现在你已经知道了GCD是如何让你的应用程序更快。如果不小心,本章将向您展示并发的一些危险,以及如何避免它们
第三章 队列和线程
分派队列和线程现在已经提到过几次了,现在您可能想知道它们是什么。在本章中,您将对调度队列和线程有更深入的理解,以及如何最好地将它们合并到您的开发工作流中。
3.1 线程
你可能听说过多线程这个词,对吧?对于执行线程来说,线程确实很短,它是一个正在运行的进程在系统上跨资源分割任务的方式。你的iOS应用程序是一个利用多线程运行多个任务的进程。你可以有许多线程执行一次,因为你有核心在你的设备的CPU。
把应用程序的工作分成多个线程有很多好处:
-
更快的执行
:通过在线程上运行任务,工作可以并发地完成,这将使它比串行地完成所有工作更快。 -
响应性
:如果你只在主UI线程上执行用户可见的工作,那么用户不会注意到应用程序会因为可以在其他线程上执行的工作而周期性地变慢或冻结。 -
优化的资源消耗
:操作系统对线程进行了高度优化。
听起来不错,对吧?更多的核,更多的线程,更快的应用程序。我打赌你准备学习如何创建一个,对吗?太糟糕了!实际上,您永远不会发现自己需要显式地创建线程。操作系统将使用更高的抽象为您处理所有线程创建。
Apple提供了线程管理所需的api,但如果您尝试自己直接管理它们,实际上可能会降低而不是提高性能。操作系统跟踪许多统计信息,以了解何时应该、何时不应该分配或销毁线程。不要欺骗自己,以为它就像你想要一根线的时候把线绕起来那么简单。由于这些原因,本书将不涉及直接线程管理。
3.1.1 Dispatch queues
使用线程的方法是创建一个DispatchQueue
。当您创建一个队列时,OS可能会创建一个或多个线程并将其分配给队列。如果现有线程可用,它们可以被重用;如果没有,那么操作系统将根据需要创建它们。
对于您来说,创建调度队列非常简单,如下面的示例所示:
let label = "com.raywenderlich.mycoolapp.networking"
let queue = DispatchQueue(label: label)
唷,相当简单,是吧?通常,您应该将标签的文本直接放在初始化器中,但是为了简洁起见,它被分解为单独的语句。
label参数只需是用于标识目的的任何唯一值。虽然你可以简单地使用一个UUID来保证唯一性,但是最好使用一个反向dns
风格的名称,如上面所示(例如com.company.app
),因为标签是你在调试时看到的,它有助于为它分配有意义的文本。
The main queue
当应用程序启动时,会自动为您创建一个主分派队列。它是一个负责UI的串行队列。由于它被频繁使用,Apple
将它作为类变量提供,您可以通过DispatchQueue.main
访问它。除非它与实际的UI
工作相关,否则绝不希望对主队列同步执行某些操作。否则,您将锁定UI,这可能会降低应用程序的性能。
如果您还记得上一章,有两种分派队列:串行
或并发
。如上面的代码所示,默认的初始化器将创建一个串行队列,每个任务必须在其中完成,然后才能开始下一个任务。
为了创建一个并发队列,只需传入.concurrent
属性,如下所示:
let label = "com.raywenderlich.mycoolapp.networking"
let queue = DispatchQueue(label: label, attributes: .concurrent)
并发队列非常普遍,苹果公司根据队列应有的服务质量(QoS)提供了6个不同的全局并发队列。
Quality of service
当使用一个并发分派队列时,您需要告诉iOS发送到队列的任务有多重要,以便它能够正确地将需要完成的工作与所有其他需要资源的任务区分优先级。记住,高优先级的工作必须更快地执行,可能会比低优先级的工作需要更多的系统资源和更多的能量。
如果你只是需要一个并发队列,但不想管理自己的,你可以使用DispatchQueue
上的全局类方法来获得一个预定义的全局队列:
let queue = DispatchQueue.global(qos: .userInteractive)
如上所述,苹果提供了六种服务质量等级:
-
.userInteractive
QoS
建议用于用户直接交互的任务。UI更新计算、动画或任何需要保持UI响应和快速的东西。如果工作不迅速进行,事情可能会冻结。提交到这个队列的任务实际上应该立即完成。 -
.userInitiated
当用户从UI启动需要立即执行但可以异步完成的任务时,应该使用. userinitiated
队列。例如,您可能需要打开文档或从本地数据库读取。如果用户单击了一个按钮,这可能就是您想要的队列。在此队列中执行的任务应该需要几秒钟或更短的时间才能完成。 -
.utility
对于通常包含进度指示器的任务,比如长时间运行的计算、I/O、网络或连续的数据提要,您将需要使用.utility
分派队列。该系统试图平衡响应能力和性能与能源效率。任务在这个队列中可能需要几秒钟到几分钟 -
.background
对于用户不能直接意识到的任务,您应该使用.background
队列。它们不需要用户交互,对时间也不敏感。预取、数据库维护、同步远程服务器和执行备份都是很好的示例。OS将关注能源效率而不是速度。您将希望将此队列用于需要大量时间(按分钟或更长的顺序)的工作。 -
.default and .unspecified
还有另外两种可能的选择,但是您不应该显式地使用。在.userinitiated
和.utility
之间有一个.default
选项,它是qos
参数的默认值。它不打算让您直接使用。第二个选项是.未指定的,它的存在是为了支持遗留api
,这些api
可能会选择脱离服务质量的线程。知道它们的存在是件好事,但是如果您正在使用它们,那么几乎可以肯定您正在做一些错误的事情。
注意:全局队列总是并发的,并且先入先出。
推断QoS
如果你创建自己的并发调度队列,你可以通过它的初始化器告诉系统QoS是什么:
let queue = DispatchQueue(label: label,
qos: .userInitiated,
attributes: .concurrent)
然而,这就像你和你的配偶/孩子/狗/宠物石争吵一样:仅仅因为你说了并不会导致结果!操作系统将关注提交到队列的任务类型,并根据需要进行更改。
如果您提交的任务具有比队列更高的服务质量,则队列的级别将会增加。不仅如此,所有加入队列的操作的优先级也将提高。
如果当前上下文是主线程,则推断的QoS为.userinitiated
。您可以自己指定QoS
,但一旦您将添加具有更高QoS
的任务,您的队列的QoS
服务将增加以匹配它。
向队列中添加任务
分派队列提供了同步和异步方法来将任务添加到队列中。请记住,我所说的任务只是指“需要运行的任何代码块”。例如,当应用程序启动时,您可能需要联系服务器来更新应用程序的状态。这不是用户发起的,不需要立即发生,依赖于网络I/O,所以你应该把它发送到全局效用队列:
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
// Perform your work here
// ...
// Switch back to the main queue to
// update your UI
DispatchQueue.main.async {
self.textLabel.text = "New articles available!"
}
}
您应该从上面的代码示例中了解到两个关键点。首先,取消闭包规则的DispatchQueue
没有什么特殊之处。如果您计划使用闭包捕获的变量(如self),您仍然需要确保正确地处理它们。
在GCD异步闭包中强捕获self不会导致一个引用循环(例如一个保留循环),因为一旦它完成,整个闭包将被释放,但它会延长self的生命周期。例如,如果你从一个视图控制器发出一个网络请求,而这个请求已经被驳回,那么闭包仍然会被调用。如果你弱捕获视图控制器,它将是nil。然而,如果你强捕获它,视图控制器将保持活着,直到闭包完成它的工作。记住这一点,并根据自己的需要,或强或弱地获取信息。
其次,注意UI的更新是如何被分派到后台队列分派中的主队列的。在他人内部嵌套异步类型调用不仅可以,而且很常见
注意:除了主队列之外,永远不要在任何队列上执行UI更新。如果没有记录API回调使用的队列,就将其分派到主队列!
在同步地向分派队列提交任务时要格外小心。如果您发现自己调用的是sync方法,而不是async方法,请考虑一两次是否真的应该这样做。如果你同步提交一个任务到当前队列,它阻塞了当前队列,并且你的任务试图访问当前队列中的资源,那么你的应用程序将会死锁,这将在第5章“并发问题”中详细解释。类似地,如果你从主队列调用同步,你会阻塞更新UI的线程,你的应用会冻结。
注意:永远不要从主线程调用同步,因为这会阻塞主线程,甚至可能导致死锁
3.2 图像加载示例
此时,您已经被大量的理论概念淹没了。现在来看一个实际的例子!
在本书可下载的资料中,您将找到本章的入门项目。打开Concurrency.xcodeproj
项目。你会看到一些图片慢慢地从网络加载到UICollectionView
。如果你试图在图片加载时滚动屏幕,要么什么也不会发生,要么滚动会非常缓慢和不平滑,这取决于你使用的设备的速度。
打开CollectionViewController.swift
看看发生了什么。当视图加载时,它只抓取要显示的图像url
的静态列表。当然,在生产应用程序中,您可能会在此时进行网络调用来生成要显示的项列表,但是对于本例来说,硬编码图像列表更容易。
方法collectionView(_:cellForItemAt:)
是问题发生的地方。可以看到,当单元格准备显示时,会通过Data
的构造函数之一调用来下载图像,然后将图像分配给单元格。代码看起来非常简单,这也是大多数iOS开发人员下载图像时所做的事情,但是您看到了结果:不稳定、性能差的UI体验!
除非您在前面几页的解释中睡着了,否则您现在已经知道下载映像的工作(这是一个网络调用)需要在与UI分开的线程上完成。
小挑战:您认为哪个队列应该处理映像下载?回顾几页,做出你的决定
你选的是 userinteractive
还是。userinitiated ?这样做很有诱惑力,因为最终结果对用户是直接可见的,但实际上,如果使用了这种逻辑,就永远不会使用任何其他队列。这里正确的选择是使用.utility
队列。你无法控制一个网络通话需要多长时间来完成,你想要操作系统在速度和设备的电池寿命之间取得适当的平衡。
3.2.1 Using a global queue
在CollectionViewController
中创建一个新方法,开始如下:
private func downloadWithGlobalQueue(at indexPath: IndexPath) {
DispatchQueue.global(qos: .utility).async { [weak self] in
}
}
你最终会从collectionView(_:cellForItemAt:)
调用它来执行实际的图像处理。首先确定应该加载哪个URL。由于url列表是self的一部分,因此需要处理正常的闭包捕获语义。在async
闭包中添加以下代码:
guard let self = self else {
return
}
let url = self.urls[indexPath.item]
一旦知道了要加载的URL,就可以使用前面使用的相同数据初始化器。尽管它是一个正在执行的同步操作,但它是在单独的线程上运行的,因此UI不会受到影响。在闭包的末尾添加以下内容:
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else {
return
}
现在你已经成功下载了URL的内容并将其转换为UIImage
,是时候将其应用到集合视图的单元格了。记住,对UI的更新只能在主线程上发生!将这个异步调用添加到闭包的末尾:
DispatchQueue.main.async {
if let cell = self.collectionView.cellForItem(at: indexPath) as? PhotoCell {
cell.display(image: image)
}
}
注意,最少量的代码被发送回主线程。在分派到主队列之前,尽可能完成所有工作,以便UI保持尽可能的响应性。单元分配是否让你感到困惑?为什么不直接将实际的PhotoCell
传递给这个方法而不是IndexPath
呢?
考虑一下你在这里所做的事情的性质。您已经将cell的配置转移到异步进程。当网络下载发生时,用户很可能会对你的应用做一些事情,对于UITableView
或UICollectionView
,这可能意味着他们在滚动。在网络调用结束时,该单元可能已经被另一个映像重用,或者它可能已经被完全丢弃。通过调用cellForItem(at:)
,您在准备更新单元格时获取它。如果它仍然存在,并且仍然在屏幕上,那么您将更新显示。如果不是,则返回nil
如果你只是简单地传递一个PhotoCell
并直接与那个对象交互,你会发现随机的图像被放置在随机的单元中,当你滚动的时候你会看到相同的图像重复多次。
现在你已经有了一个正确的图像下载和单元格配置方法,update collectionView(_:cellForItemAt:)
来调用它。用以下两行代码替换创建和返回单元格之间的所有内容:
cell.display(image: nil)
downloadWithGlobalQueue(at: indexPath)
再次构建并运行你的应用。一旦你的应用程序开始加载图片,滚动表格视图。注意,滚动是多么光滑!当然,你可能会注意到一些问题:图片弹出和消失,加载非常缓慢,当你滚动时不断重新加载。您需要一种方法来启动和取消这些请求,并缓存它们的结果,以使体验完美。这些都是使用操作比使用中央调度要容易得多的事情,您将在后面的章节中介绍。所以继续阅读!:]
使用内置的方法
你可以看到,上面的改变是多么简单,极大地提高了你的应用程序的性能。然而,并不是总是需要自己抓取调度队列。许多标准的iOS库可以为你处理这个问题。向CollectionViewController
添加以下方法
private func downloadWithUrlSession(at indexPath: IndexPath) {
URLSession.shared.dataTask(with: urls[indexPath.item]) {
[weak self] data, response, error in
guard let self = self,
let data = data,
let image = UIImage(data: data) else {
return
}
DispatchQueue.main.async {
if let cell = self.collectionView
.cellForItem(at: indexPath) as? PhotoCell {
cell.display(image: image)
}
}
}.resume()
}
注意,这一次,您直接使用URLSession上的dataTask方法,而不是获取调度队列。代码几乎是相同的,但是它为您处理数据的下载,因此您不必自己完成,也不需要获取分派队列。当系统提供的方法可用时,一定要使用系统提供的方法,因为这样不仅可以使您的代码更经得起时间的检验,而且更容易为其他开发人员阅读。初级程序员可能不理解调度队列是什么,但他们理解进行网络调用。
如果你在collectionView(_:cellForItemAt:)
中调用downloadWithUrlSession(at:)
而不是downloadWithGlobalQueue(at:)
,你应该在再次构建和运行你的应用之后看到完全相同的结果。
3.3 DispatchWorkItem
除了传递匿名闭包之外,还有另一种向DispatchQueue
提交工作的方法。DispatchWorkItem
是一个类,它提供一个实际对象来保存希望提交到队列的代码。
例如,以下代码:
let queue = DispatchQueue(label: "xyz")
queue.async {
print("The block of code ran!")
}
就像这段代码一样:
let queue = DispatchQueue(label: "xyz")
let workItem = DispatchWorkItem {
print("The block of code ran!")
}
queue.async(execute: workItem)
3.4 Canceling a work item
您可能希望使用显式DispatchWorkItem
的一个原因是,您需要在执行之前或执行期间取消任务。如果您在工作项上调用cancel()
,将执行两个操作中的一个:
- 1: 如果任务还没有在队列上启动,它将被删除。
- 2: 如果任务当前正在执行,则
isCancelled
属性将被设置为true
。
您需要定期检查代码中的isCancelled
属性,并采取适当的操作来取消任务(如果可能的话)。
Poor man's dependencies
DispatchWorkItem
类还提供了一个notify(queue:execute:)
方法,该方法可用于识别在当前工作项完成后应该执行的另一个DispatchWorkItem
let queue = DispatchQueue(label: "xyz")
let backgroundWorkItem = DispatchWorkItem { }
let updateUIWorkItem = DispatchWorkItem { }
backgroundWorkItem.notify(queue: DispatchQueue.main,
execute: updateUIWorkItem)
queue.async(execute: backgroundWorkItem)
请注意,当指定要执行的后续工作项时,您必须显式地指定工作项应该针对哪个队列执行。
如果您发现自己需要取消任务或指定依赖项的能力,我强烈建议您参阅第9章“操作依赖项”和第10章“操作取消操作和第三章 Operations
3.6 Where to go from here?
至此,您应该对调度队列是什么、它们用于什么以及如何使用它们有了很好的理解。试试上面的代码示例,确保您理解它们是如何工作的。
考虑将PhotoCell
传递到下载方法中,而不是仅仅传递IndexPath
来查看实际中常见的错误类型
当然,这个示例应用程序有些人为设计,以便轻松地展示DispatchQueue
是如何工作的。示例应用程序还有许多其他性能改进,但这些都需要等到第7章“操作队列”的时候。
现在您已经看到了并发的好处,下一章将向您介绍在应用程序中实现并发的危险。