本文译自Grand Central Dispatch Tutorial for Swift: Part 1/2
尽管 Grand Central Dispatch (GCD) 已经存在一段时间了,但并非每个人都知道怎么使用它。这是情有可原的,因为并发很棘手,而且 GCD 本身基于 C 的 API 在 Swift 世界中很刺眼。 在这两篇教程中,你会学到 GCD 的来龙去脉。第一部分解释了 GCD 可以做什么和几个基本功能。第二部分,你会学到一些 GCD 所提供的进阶功能。
libdispatch是 Apple 所提供的在 IOS 和 OS X 上进行并发编程的库,而 GCD 正是它市场化的名字。GCD 有如下优点:
为了理解 GCD,你需要了解一些线程和并发的概念。这些概念可能很含糊并且细微,所以先简要回顾一下。
这两个词用来描述任务的执行顺序。 串行 在同一时间点总是单独执行一个任务,而并发可以同时执行多个任务。
在本教程中,你可以把任务当做一个闭包 (closure)。实际上,你可以将 GCD 和函数指针一起使用,但是一般很少这样使用。闭包更简单!
不记得 Swift 中的闭包?闭包是自含的,可保存传递并被调用的代码块。当调用的时候,他们的用法很像函数,可以有参数和返回值。除此之外,闭包可以“捕获”外部的变量,也就是说,它可以看到并记住它自身被定义时的作用域变量。
Swift 中的闭包和 OC 中的块 (block) 类似甚至于他们几乎就是可交换使用的。唯一的限制在于 OC 中不能使用 Swift 独有的特性,比如元组 (tuple)。但 OC 中的块可以安全的替换成 Swift 中的闭包。
这两个词描述的是函数何时将控制权返回给调用者,以及在返回时任务的完成情况。
同步函数只有在任务完成后才会返回。
异步函数会立即返回,不会等待任务完成。因此异步函数不会阻塞当前线程。
注意 -- 当你读到同步函数阻塞 (block) 当前进程或者函数是阻塞 (blocking) 函数时,不要困惑!动词阻塞(block)描述的是函数对当前线程的影响,和块 (block) 没有关系。同时记住 GCD 文档中有关 OC 的 block 可以跟 Swift 的闭包互换。
这是一段不能并发执行的代码,也就是说两个线程不可以同时执行它。这通常是因为这段代码会修改共享的资源。否则,并发的进程同时修改同一个变量会导致错误。
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。竞态条件可能产生在代码检查时不易被发现的不可预期行为。
两个或更多的线程因等待彼此完成而陷入的困境称为死锁。第一个线程无法完成因为它在等待第二个线程完成。但是第二个线程也无法完成因为它在等待第一个线程完成。
线程安全的代码是可以被多个线程或并发任务安全调用的,他不会造成任何问题(数据错误,崩溃等)。非线程安全的代码在同一时间只能单独执行。一段线程安全的代码如let a = ["thread-safe"]
。由于数组是只读的,它可以被多个线程同时使用而不会引发问题。另一方面,var a = ["thread-unsafe"]
是可变数组。这意味着它不是线程安全的,因为多个线程可以同时获取并修改这个数组,会得到不可预料的结果。非线程安全的变量和可变的数据结构在同一时刻应该只能被一个线程获取。
上下文切换是在进程中切换不同线程时保存和恢复程序执行状态的过程。这一过程在编写多任务 app 时相当常见,但是会造成一些额外开支。
并发和并行经常会被同时提起,所以值得通过简短的解释来区分彼此。
并发代码中的单独部分可以同时执行。然而,这要由系统来决定并发怎样发生或是否发生。
多核设备通过并行来同时执行多个线程;然而,在单核设备中,必须要通过上下文切换来运行另一个线程或进程。这一过程通常发生的很快以至于给人并行的假象。如下图所示
尽管你可能在 GCD 之下编写并发执行的代码,但仍由 GCD 来决定并行的需求有多大。
深层次的观点是并发实际上是关乎结构的。当你编写 GCD 代码时,你组织你的代码来揭示出可以同时运行的工作,以及不可以同时运行的。如果你想深入了解这个主题,猛击Rob Pike。
GCD 提供了 调度队列 (dispatch queues)来处理提交的任务;这些队列管理着你向 GCD 提交的任务并且以先进先出(FIFO)的顺序来执行任务。这保证了第一个加入队列的任务第一个被执行,第二个加入的任务第二个开始执行,以此类推。
所有调度队列都是线程安全的从而让你可以同时在多个线程中使用它们。当你明白了调度队列如何为你的代码提供了线程安全性时,GCD 的优点就很明显了。关键是选择正确的调度队列种类和正确的 调度函数 (dispatching function)来提交你的任务。
顺序队列中的任务同一时间只执行一件任务,每件任务只有在先前的任务完成后才开始。同时,你并不知道一个任务完成到另一个任务开始之间的间隔时间,如下图所示:
任务的执行是在 GCD 掌控之下的;你唯一确定的就是 GCD 在同一时刻只执行一件任务并且按任务加入队列的顺序执行。
因为不会在顺序队列中同时执行两件任务,所以没有多个任务同时进入临界区的危险;这保证了临界区不会出现竞态条件。因此如果进入临界区的唯一途径就是通过向调度队列提交任务,那么可以保证临界区是安全的。
并发队列中的任务可以保证按进入队列的顺序被执行...仅此而已!任务可能以任意顺序完成而且你不知道何时下一个任务会开始,或是任一时刻有多少任务在运行。再一次,这完全取决于 GCD。 下图展示了四个并发任务的例子:
任务 1,2 和 3 都运行的很快,一个接一个。但是任务 1 在任务 0 开始了一段时间后才开始。同时,任务 3 在任务 2 开始后才开始但是却更早完成。
何时开始一个任务完全取决于 GCD。如果一个任务的执行时间和另一个的发生重叠,将由 GCD 来决定是否要将任务运行在另一个可用的核上或是通过上下文切换来运行另一个程序。
有趣的是,GCD 为每种队列类型提供了至少5种特别的队列。
首先,系统提供了一种特殊的顺序队列 main queue。和其他的顺序队列一样,在这个队列里的任务同一时刻只有一个在执行。然而,这个队列保证了所有任务会在主线程中执行,主线程是唯一一个允许更新 UI 的线程。这个队列用来向 UIView 对象发消息或发通知。
系统同时提供了几种并发队列。这些队列和它们自身的 QoS 等级相关。QoS 等级表示了提交任务的意图,使得 GCD 可以决定如何制定优先级。
要清楚 Apple 的 API 同时也使用了全局调度队列(global dispatch queue),所以你添加的任何任务都不是这些队列中的唯一任务。
最后,你可以创建自定义的顺序或并发队列。意味着你至少有5种队列:主队列(main queue),四种通用调度队列,加上任意你自己定制的队列!
以上就是调度队列的主要部分!
GCD 的“艺术”可归结为选择正确的队列调度函数来提交任务。最佳的学习方式就是通过下面的例子。
因为这篇教程的目标是使用 GCD 优化程序以及在不同线程中安全的运行代码,所以你会以一个几近完成的项目 GooglyPuff 来开始。
GooglyPuff 是一个未优化,非线程安全的 app,使用 Core Image 的人脸识别 API 在人脸上叠加金鱼眼。初始图像可以从图片库中选择或是从网络下载一组预定的图片。
一旦下载了工程,提取到合适的地方,打开 Xcode 并运行它。看起来如下:
注意到当你选择 Le Internet 选项来下载图片时,一个UIAlertController
提示框会过早的弹出。你会在教程的第二部分修复这个问题。
这个工程中有 4 个需要关心的类:
PhotoCollectionViewController
:app 启动后的第一个视图控制器。展示所有选择的图片的缩略图。PhotoDetailViewController
:为图片加上金鱼眼并在UIScrollView
中展示。Photo
:描述图片属性的协议。提供图片,缩略图和状态。两个类实现了这个协议:DownloadPhoto
从NSURL
实例化图片,AssetPhoto
从ALAsset
实例化图片。PhotoManager
:管理所有Photo
对象。dispatch_sync
处理后台任务返回 app 并从图片库中添加一些图片或使用 Le Internet 选项下载一些。
留意在轻触PhotoCollectionViewController
中的UICollectionViewCell
后要多久才能完成PhotoDetailViewController
的初始化;此时存在明显的延迟,尤其是在较慢的设备上浏览较大的图片时。
一不小心就会在UIViewController
的viewDidLoad
中填充过多杂乱的方法而造成超负荷;以至于经常要等待很久视图控制器才会出现。如果可能的话,最好将一些工作转移到后台去完成,如果这些工作在加载时不是必需的。
听起来是使用dispatch_async
的时候!
打开PhotoDetailViewController
然后用下面的实现替换viewDidload
:
override func viewDidLoad() {
super.viewDidLoad()
assert(image != nil, "Image not set; required to use view controller")
photoImageView.image = image
// Resize if neccessary to ensure it's not pixelated
if image.size.height <= photoImageView.bounds.size.height &&
image.size.width <= photoImageView.bounds.size.width {
photoImageView.contentMode = .Center
}
dispatch_async(dispatch_get_global_queue(Int(QOS_CLASS_USER_INITIATED.value), 0)) { // 1
let overlayImage = self.faceOverlayImageFromImage(self.image)
dispatch_async(dispatch_get_main_queue()) { // 2
self.fadeInNewImage(overlayImage) // 3
}
}
}
上面代码的工作流程:
dispatch_async
调用,异步提交的闭包意味着调用线程会继续执行下去。这使得viewDidLoad
在主线程上更早的完成从而让加载的过程在感觉上更迅速。同时,人脸识别过程已经开始并会在晚些时候完成。UIImageView
,所以把一个闭包加入主线程中。记住 -- 必须总是在主线程中操作UIKit
!fadeInNewImage
更新 UI。注意到你在使用 Swift 的尾随闭包(trailing closure)语法,将闭包写在参数括号的后面传给dispatch_async
。这种语法看起来更清晰,因为闭包没有内嵌到函数括号中。
运行 app;选择一张图片然后你会明显地发现视图控制器载入更快了,随后金鱼眼会加入进来。这给 app 带来了很好的效果,因为你展示出图片修改前后的变化。同时,如果你试图加载一张极其巨大的图片,app 不会因为加载视图控制器而失去响应,这让 app 有很好的适应性。
正如前面所提到的,dispatch_async
以闭包的形式向队列中追加了一项任务并立即返回了。这项任务会在 GCD 决定的稍后时间执行。当你需要执行网络请求或在后台执行繁重的 CPU 任务时,使用dispatch_async
不会阻塞当前进程。
何时使用何种队列类型快速指南:
dispatch_sync
。dispatch_async
来返回主队列,能保证新的任务会在当前方法完成后再执行。你可能注意到dispatch_get_global_queue
的 QoS 等级参数写起来有些繁琐。这是由于qos_class_t
被定义为一个结构体,它包含有Uint32
型的属性value
,而这个属性需要被转型为Int
。在 Utils.swift 中添加一些全局的计算变量,使获取全局队列更方便一些:
var GlobalMainQueue: dispatch_queue_t {
return dispatch_get_main_queue()
}
var GlobalUserInteractiveQueue: dispatch_queue_t {
return dispatch_get_global_queue(Int(QOS_CLASS_USER_INTERACTIVE.value), 0)
}
var GlobalUserInitiatedQueue: dispatch_queue_t {
return dispatch_get_global_queue(Int(QOS_CLASS_USER_INITIATED.value), 0)
}
var GlobalUtilityQueue: dispatch_queue_t {
return dispatch_get_global_queue(Int(QOS_CLASS_UTILITY.value), 0)
}
var GlobalBackgroundQueue: dispatch_queue_t {
return dispatch_get_global_queue(Int(QOS_CLASS_BACKGROUND.value), 0)
}
回到 PhotoDetailViewController 中的viewDidLoad
中,将dispatch_get_global_queue
和dispatch_get_main_queue
替换为帮助变量:
dispatch_async(GlobalUserInitiatedQueue) {
let overlayImage = self.faceOverlayImageFromImage(self.image)
dispatch_async(GlobalMainQueue) {
self.fadeInNewImage(overlayImage)
}
}
这使得调度调用更易读并且很容易看出在使用哪个队列。
dispatch_after
推迟任务仔细思考你的 app 中的 UX。用户可能在第一次打开 app 的时候不知道该做什么,不是吗?
如果在 PhotoManager 类中没有图片的时候,给用户一个提示是个不错的主意。然而,你同时要考虑用户的视线怎样扫过屏幕:如果提示出现的太快,用户可能还在看其他的地方而忽略了提示。
推迟一秒钟再出现提示,此时便可抓住用户的注意力,因为他们已经对 app 有了第一印象。
将下面的代码加到showOrHideNavPrompt
的实现中,它位于 PhotoCollectionViewController.swift 文件底部。
func showOrHideNavPrompt() {
let delayInSeconds = 1.0
let popTime = dispatch_time(DISPATCH_TIME_NOW,
Int64(delayInSeconds * Double(NSEC_PER_SEC))) // 1
dispatch_after(popTime, GlobalMainQueue) { // 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!"
}
}
}
showOrHideNavPrompt
会在viewDidLoad
以及UICollectionView
重新加载的时候被执行。代码解释如下:
delayInSeconds
所表示的时间,然后将闭包异步地加入主队列中。运行 app。在短暂的延迟后,提示会出现并吸引用户的注意。
dispatch_after
的工作原理就像推迟的dispatch_async
。一旦dispatch_after
返回,你还是无法掌握实际的执行时间抑或是取消任务。
想知道何时使用dispatch_after
?
dispatch_after
。你最好留在主队列中。dispatch_after
是一个好主意;Xcode 对此有自动补全模板。单例。爱也好,恨也罢,它们在 iOS 中就像猫之于互联网一样流行。:]
经常有人因为单例不是线程安全的而忧虑。这种担忧是很有道理的,考虑到他们的用法:单例经常被多个控制器同时使用。 PhotoManager 类是一个单例,所以你要仔细思考这个问题。
思考两种情形,初始化单例的过程和对他进行读写的过程。
先来看初始化。这看起来很简单,因为 Swift 在全局域中初始化变量。在 Swift 中,全局变量在首次使用时被初始化,并且保证初始化是原子操作。也就是说,初始化代码被视为临界区从而保证了初始化在其他线程使用全局变量之前就完成了。Swift 是怎么做到的?其实,Swift 在幕后使用了 GCD 中的dispatch_once
,详见博客。
dispatch_once
以线程安全的方式执行且仅执行一次闭包。如果一个线程正处于临界区中 -- 被提交给dispatch_once
的任务 -- 其他线程会阻塞直到它完成。并且一旦它完成,其他线程不会再执行临界区中的代码。用let
将单例定义为全局常量,我们可以进一步保证变量在初始化后不会发生变化。从某种意义上说,所有 Swift 全局常亮量都天生是单例,并且线程安全地初始化。
但是我们仍需要考虑读和写。尽管 Swift 使用dispatch_once
来确保单例初始化是线程安全的,但不能保证它所表示的数据类型也是线程安全的。例如用一个全局变量来声明一个类实例,但在类中还是会有修改类内部数据的临界区。此时就需要其他方式来达成线程安全,比如通过对数据的同步化使用 (synchronizing access)。
实例化线程安全性不是单例的唯一问题。如果单例的属性表示一个可变对象,比如PhotoManager
中的photos
,那么你就需要考虑那个对象是否线程安全。
在 Swift 中任意用let
声明的常量都是只读并且线程安全的。用var
声明的变量是可变且非线程安全的,除非数据类型本身被设计成线程安全。Swift 中的集合类型比如Array
和Dictionary
,当声明为变量时不是线程安全的。那么像 Foundation 的容器NSArray
呢?是线程安全的吗?答案是--“可能不是”!Apple 维护的一个帮助列表中有许多 Foundation 中非线程安全的类。
尽管很多线程可以同时读取一个Array
的可变实例而不出问题,但如果一个线程在修改数组的同时另一个线程却在读取这个数组,这是不安全的。你的单例目前还不能阻止这种情况发生。
为了弄清楚问题,看看 PhotoManager.swift 中的addPhoto
:
func addPhoto(photo: Photo) {
_photos.append(photo)
dispatch_async(dispatch_get_main_queue()) {
self.postContentAddedNotification()
}
}
这是一个 写 方法,因为它修改了一个可变数组。
再看看photos
属性:
private var _photos: [Photo] = []
var photos: [Photo] {
return _photos
}
这个属性的 getter 方法是一个 读 方法。调用者得到一个数组的拷贝并且保护了原始数组不被改变,但是这不能保证一个线程在调用addPhoto
来写的时候没有另一个线程同时也在调用 getter 方法读photos
属性。
注意 :在上面的代码中,为什么调用者要获取
photo
数组的拷贝?在 Swift 中,参数或函数返回是通过值或引用来传递的。引用传递和 OC 中的传指针一样,这意味着你得到的是原始的对象,对这个对象的修改会影响到其他使用了这个对象引用的代码。值传递拷贝了对象本身,对拷贝的修改不会影响原始的对象。默认情况下,Swift 类实例是引用传递而结构体是值传递。Swift 内置的数据类型,如
Array
和Dictionary
,是用结构体来实现的,看起来传递集合类型会造成代码中出现大量的拷贝。不要因此担心内存使用问题。Swift 的集合类型经过优化,只有在需要的时候才进行拷贝,比如通过值传递的数组在第一次被修改的时候。
这是软件开发中经典的读者写者问题(Readers-Writers Problem)。GCD 使用 调度屏障 (dispatch barriers)提供了一个优雅的解决方案来生成读写锁。
当跟并发队列一起工作时,调度屏障是一族行为像序列化瓶颈的函数。使用 GCD 的 barrier API 确保了提交的闭包是指定队列中在特定时段唯一在执行的一个。也就是说必须在所有先于调度屏障提交的任务已经完成的情况下,闭包才能开始执行。
当轮到闭包时,屏障执行这个闭包并确保队列在此过程不会执行其他任务。一旦闭包完成,队列返回到默认的执行方式。GCD 同时提供了同步和异步两种屏障函数。
下图说明了屏障函数应用于多个异步任务的效果:
注意队列开始就像普通的并发队列一样工作。但当屏障执行的时候,队列变成像顺序队列一样。就是说,屏障是唯一一个在执行的任务。在屏障完成后,队列恢复成普通的并发队列。
下面说明什么时候用 -- 什么时候不应该用 -- 屏障函数:
因为以上唯一合适的选择就是自定义并发队列,你需要生成一个这样的队列来处理屏障函数以隔离读写操作。并发队列允许多个线程同时的读操作。
打开 PhotoManager.swift 并在photos
属性下面添加如下私有属性到类中:
private let concurrentPhotoQueue = dispatch_queue_create(
"com.raywenderlich.GooglyPuff.photoQueue", DISPATCH_QUEUE_CONCURRENT)
使用dispatch_queue_create
初始化一个并发队列concurrentPhotoQueue
。第一个参数遵循反向 DNS 命名习惯;保证描述性以利于调试。第二个参数指出你的队列是顺序的还是并发的。
注意 :当在网上搜索例子时,你经常看到人们传
0
或NULL
作为dispatch_queue_create
的第二个参数。这是一种过时的方法来生成顺序调度队列;最好用参数显示声明。
找到addPhoto
并用如下实现替换之:
func addPhoto(photo: Photo) {
dispatch_barrier_async(concurrentPhotoQueue) { // 1
self._photos.append(photo) // 2
dispatch_async(GlobalMainQueue) { // 3
self.postContentAddedNotification()
}
}
}
来看这段代码如何工作的:
concurrentPhotoQueue
中的其他任务同时执行。这个任务解决了写问题,但是你还需要实现photos
的读方法。
为确保和写操作保持线程安全,你需要在concurrentPhotoQueue
中执行读操作。但是你需要从函数返回读数据,所以不能异步地提交读操作到队列里,因为异步任务不能保证在函数返回前执行。
因此,dispatch_sync
是个极好的候选。
dispatch_sync
同步提交任务并等到任务完成后才返回。使用dispatch_sync
和调度屏障一起来跟踪任务;或是在需要等待返回数据时使用dispatch_sync
。
仍需小心。设想你调用dispatch_sync
到当前队列中。这会造成死锁。因为调用在等待闭包完成,但是闭包无法完成(甚至根本没开始!),直到当前在执行的任务结束,但当前任务没法结束(因为阻塞的闭包还没完成)!这就要求你必须清醒的认识到你从哪个队列调用了闭包,以及你将任务提交到哪个队列。
概述一下何时何地使用dispatch_sync
:
dispatch_sync
调度任务到同一个队列,你显然会制造死锁。photos
如下:
var photos: [Photo] {
var photosCopy: [Photo]!
dispatch_sync(concurrentPhotoQueue) { // 1
photosCopy = self._photos // 2
}
return photosCopy
}
分别来看每个号码注释:concurrentPhotoQueue
队列执行读操作。photoCopy
并返回它。恭喜 —— 你的PhotoManager
单例已经是线程安全的了。不论你读或是写图片数组,你都有信心保证操作会安全的执行。
还是不能 100% 的确定 GCD 的本质?你可以自己创建使用 GCD 函数的简单例子,通过断点和NSLog
来确保你明白发生了什么。
我这里有两张动态 GIF 图片来帮助你理解dispatch_async
和dispatch_sync
。每张 GIF 上面都有代码辅助你理解;注意代码中的断点和相应的队列状态。
override func viewDidLoad() {
super.viewDidLoad()
dispatch_sync(dispatch_get_global_queue(
Int(QOS_CLASS_USER_INTERACTIVE.value), 0)) {
NSLog("First Log")
}
NSLog("Second Log")
}
下面对图片中的几个状态做说明:
viewDidLoad
的UIViewController
类。viewDidLoad
在主线程中执行。dispatch_sync
闭包被加入到全局队列中稍后执行。主线程停下来等待闭包完成。同时,全局队列正在并发执行任务;记住闭包以 FIFO 的顺序从全局队列中取出,但是会并发地执行。全局队列首先处理dispatch_sync
闭包加入前已经存在队列中的任务。dispatch_sync
闭包执行。viewDidLoad
方法完成,主队列接着处理其它任务。dispatch_sync
把任务加入队列并一直等待其完成。dispatch_async
做了差不多的工作,只是它不会等待任务完成,而是转而去继续其他工作。
override func viewDidLoad() {
super.viewDidLoad()
dispatch_async(dispatch_get_global_queue(
Int(QOS_CLASS_USER_INTERACTIVE.value), 0)) {
NSLog("First Log")
}
NSLog("Second Log")
}
viewDidLoad
的UIViewController
类。viewDidLoad
在主线程中执行。dispatch_async
闭包被加入到全局队列中稍后执行。viewDidLoad
在dispatch_async
后继续向下执行,主线程继续其他任务。同时,全局队列正在并发执行任务;记住闭包以 FIFO 的顺序从全局队列中取出,但是会并发地执行。dispatch_async
所添加的闭包。dispatch_async
闭包完成,NSLog
输出到控制台。在这个特别的例子中,第一个NSLog
在第二个NSLog
后执行。事实并非总是如此——这取决于硬件在彼时正在做什么,你无法控制或知晓哪个语句会先执行。“第一个”NSLog
在某种调用情况下可能会先执行。
在本教程中,你已经学到了如何编写线程安全的代码以及如何在保持主线程响应性的前提下执行 CPU 密集型的任务。
可以下载GooglyPuff,里面包含了本教程中所做的所有改进。教程的第二部分会在此基础上继续改进。
如果你打算优化自己的 app,你真的应该使用 Instruments 中的Time Profile 模板来测试。使用方法已经超出本教程范围,可以查看怎样使用 Instruments。
同时确保你在真机上测试,因为在模拟器上测试会得到跟真实体验相差甚远的结果。
在教程的下篇你会更深入 GCD 的 API 中做些更酷的事情。
译者:loveltyoic