深入理解 GCD (Grand Central Dispatch) 第一部分

2015年11月23日 · 8 years ago

本文译自:http://www.raywenderlich.com/79149/grand-central-dispatch-tutorial-swift-part-1。Github 上有一篇此文 Objective-C 版本的翻译,看完没有什么印象,所以干脆把 Swift 版本的翻译一遍。

尽管 Grand Central Dispatch(或简称 GCD)已经出来好一阵子了,但还是有些开发者没能完全理解 GCD 的原理。这也是可以理解的,并发本来就是开发中比较难懂的部分,GCD 的 API 看起来就像锋利的尖角扎入 Swift 平滑的世界中。

本文将分为两部分,深入浅出地讲解 GCD。第一部分会讲解 GCD 能用来做什么,包括一些基础的用法。第二部分会讲解更多 GCD 的高级用法。

一、这就开始吧

GCD 是 libdispatch 的别称,是 Apple 提供的用于在多核 iOS/OS X 机器上执行并发代码的库。GCD 将带来几个好处:

  • 通过把需要大量计算的耗时任务放到后台线程运行,让你的应用获得更流畅的体验。
  • GCD 提供了一个比直接用锁和多线程简单的并发模型,可以帮助减少并发代码中的 bug。

在了解 GCD 之前,你需要确保完全理解多线程和并发相关的几个概念。这些概念可能比较模糊或者区别不大,容易搞混,所以请花点时间复习一下这几个概念。

串行(Serial) vs. 并行(Concurrent)

这两个词描述的是任务执行时彼此间的关系。串行执行的任务,每次只有一个任务在执行。并发执行的任务则有可能同时执行多个任务。

任务(Tasks)

在本文中你可以把一个任务当作是一个闭包(Closure)。不过实际上在 GCD 里你是可以用函数指针来执行任务的,不过大多数情况下要麻烦的多。直接用闭包就简单多了!

不清楚 Swift 中的闭包是什么?闭包是是指自给的,可以被调用、存储和传递的代码块(Closures are self-contained, callable blocks of code that can be stored and passed around. )。当一个闭包被调用的时候,它们看上去就是个普通的函数,可以传参数进去,也可以有返回值。另外,闭包作用域外的变量如果被闭包使用到了,就会被闭包“捕获”——就是说在闭包内可以看到外部变量并记住外部变量的值。

Swift 的闭包跟 Objective-C 的 block 很像,并且两者是可以互相转换的。唯一的限制就是,ObjC 代码不能使用 Swift 闭包中暴露的 Swift-Only 的特性,比如元组(Tuple)。但是在 Swift 中使用 ObjC 的 block 是没有任何限制的,所以你在本文中看到 ObjC 的 block 的时候,你可以完全可以直接替换成 Swift 闭包。

同步(Synchronous) vs 异步(Asynchronous)

这两个概念描述的是一个函数何时会把控制权返回(Return)给调用者,当交还的时候,有多少工作已经完成了。

一个同步函数会在它执行的任务完成之后返回。

一个异步函数则不会一直等到任务完成,而是立刻返回,同时执行任务直到结束。因此,异步函数不会阻塞当前线程。

请留意,当你看到同步函数会“阻塞” block 当前线程的时候,请不要和名词 block 混淆。(译者注:中文无此问题,这里省略 100 字…)。

临界区(Critical Section)

临界区值得是不能被并发执行的代码块,也即不能被两个及以上的线程同时访问。这通常是因为这段代码访问了一份共享资源,而且这个资源在被访问的过程中不能中断。

竞争条件(Race Condition)

当软件系统的行为依赖于无法控制的事件的执行顺序时(比如程序并发任务的的执行顺序),我们称这种情况为竞争条件。竞争条件会产生无法预测的结果,通常代码走查也没法发现明显的问题。

死锁(Deadlock)

如果两个(有时候多个)线程都在等着对方完成任务才能执行自己的任务时,这种情况我们成为死锁。第一个线程要等到第二个线程执行完才能执行,而第二个线程则在等着第一个线程,所以两个都永远无法执行。

线程安全(Thread Safe)

线程安全的代码可以被多个线程或者并发任务安全地调用而不会引发任何问题(比如数据损坏,崩溃之类的问题)。没有线程安全的代码同一时间内只能被在一个上下文中运行。举一个线程安全的例子:

let a = ["thread-safe"];

这个数组是只读的,所以你可以在多个线程中同时使用而不会有任何问题。相反的,如果你声明一个可变数组:

var a = ["thread-unsafe"];

则这个数组就不是线程安全的,它可以同时被多个线程访问和修改,造成不可预知的后果。可变的变量和数据结构在同一时间只能被一个线程访问。

上下文切换(Context Switch)

一次上下文切换是指在一个单核处理器中,保存和恢复不同线程的执行状态的过程。在编写多任务应用的时候,这个切换是很常见的,但是这种切换也会带来额外的开销。

二、并发(Concurrency)vs 并行(Parallelism)

并发和并行经常一起被提及,所以简单解释一下这两者的区别还是很有必要的。

独立的并发代码块是可以被“同时”执行的,但是如何实现“同时”却是由系统决定的——甚至根本就不是“同时”执行也是有可能的。

在多核设备上,多线程可以在多个 CPU 上并行执行;单核设备为了实现并发,就得先运行一个线程,然后做上下文切换(Context Switch),从而实现线程和进程切换。这种切换一般进行得非常快,让用户感觉像是同时在运行多个线程一样。详见下图:

Concurrency_vs_Parallelism

所以,虽然你可以使用 GCD 的接口来编写并发代码,但是 GCD 才是真正决定是否使用并行实现的人。并行要求并发,但是并发并不一定能够保证并行。

更深一层地说,并发设计其实是结构的设计。如果你带着 GCD 的思维去编写代码,你就得小心地设计代码的结构,暴露的接口要考虑可以同时执行和不可以同时执行的代码。如果你想更深入地探究这个课题,你可以参考 Vimeo 上 Rob Pike 的这个演讲

三、队列(Queues)

GCD 提供了多个 dispatch queue (分发队列,由于这里跟代码同名,直接使用英文更容易理解,下文遇到此名词都使用英文) 来处理提交的任务;这些队列负责管理使用 GCD 接口提交的任务,并以先入先出(FIFO)的顺序执行。这就保证了最早加入队列的任务会第一个被执行,第二个添加的则第二个被执行,以此类推。

每一个 dispatch queue 都是线程安全的,也就是说你可以同时在多个线程操作同一个队列。当你理解 dispatch queue 是如何给你自己的代码提供线程安全的时候,GCD 的好处就显而易见了。这里线程安全的关键在于选择正确类型的 dispatch queue,以及分发函数(dispatching function)。

四、串行队列(Serial Queues)

串行队列中的任务每次执行一个,只有等上一个任务执行完了才会开始执行下一个。而且,你并不知道两个任务结束与开始之间的间隔时间你是不知道的,如下图所示:

Serial-Queue-Swift

这些任务什么时候开始执行完全是由 GCD 内部控制的,你唯一能知道的事情就是,GCD 保证了这些任务会按照进队列的顺序执行,而且每次只执行一个。

由于顺序队列里面不可能有两个任务并发执行,所以这个队列的任务就不会有同时访问临界区的风险;这样的实现保证了在这些任务中不会有由于竞争条件产生的临界区问题。所以如果临界区只会被队列中的这些任务访问,你就可以说这个临界区是安全的。

五、并发队列(Concurrent Queues)

并发队列保证任务开始执行的顺序跟它进入队列的顺序相同……而且,这就是并发队列唯一能保证的事情!所有的任务可以以任意顺序结束执行,而且你完全不知道开始下一个任务之前的时间间隔是多少,甚至不知道任意时刻到底有多少任务在同时运行。所以,这又是一个完全取决于 GCD 的事情。

下图的例子展示了一个由 GCD 执行的有4 个任务的并发队列:

Concurrent-Queue-Swift

注意到上图中 Task 1 要等到 Task 0 开始执行了好一会儿才开始执行,但是 Task 1, 2 和 3 之间的间隔却很短,一个紧接着另一个。还有,Task 3 在 Task 2 之后才开始执行,但是却比 Task 2 更早结束。

任务什么时候开始执行是完全取决与 GCD 的。当两个任务的执行时间有重叠的时候,GCD 会决定这两个任务是应该分开两个核执行(如果有空闲的 CPU)还是使用上下文切换在一个核中执行两个不同的任务。

有意思的是,GCD 给顺序和并发类型的队列各提供了至少五种特殊的队列供你选择。

六、队列类型

首先,系统提供了一个特殊的顺序队列:main queue。跟所有的顺序队列一样,该队列中同一时间只执行一个任务。不同的是这个队列中所有的任务都保证会在主线程中执行,主线程也是唯一一个可以更新 UI 的线程。这个队列也是用来给 UIView 对象发消息(send message,这里的 message 应该理解为 ObjC 的 message),或者发送通知(notifications,这里的通知应该指 NSNotification)。

系统还提供了几个并发队列。这些队列都有他们自己的 QoS 类(Quality of Service)。QoS 类主要用来描述任务的执行目的以便于 GCD 决定这些任务的优先级。以下是 QoS 类:

  • QOS_CLASS_USER_INTERACTIVEUser Interactive(用户交互)类的任务关乎用户体验,这类任务是需要立刻被执行的。这类任务应该用在更新 UI,处理事件,执行一些需要低延迟的轻量的任务。这种类型的任务应该要压缩到尽可能少。
  • QOS_CLASS_USER_INITIATED: User Initiated(用户发起)类是指由 UI 发起的可以异步执行的任务。当用户在等待任务返回的结果,然后才能执行下一步动作的时候可以使用这种类型。
  • QOS_CLASS_UTILITYUtility(工具)类是指耗时较长的任务,通常会展示给用户一个进度条。这种类型应该用在大量计算,I/O 操作,网络请求,实时数据推送之类的任务。这个类是带有节能设计的。
  • QOS_CLASS_BACKGROUNDbackground(后台)类是指用户并不会直接感受到的任务。这个类应该用在数据预拉取,维护以及其他不需要用户交互,对时间不敏感的任务。

这里要注意苹果的 API 也会使用这些全局的 dispatch queue,所以你提交的任务并不是这些队列的唯一任务。

最后,你也可以创建你自己的顺序队列和并发队列。也就是说,你至少需要处理五个队列:主队列,其他的四个全局队列,以及你自己创建的自定义队列。

以上就是 dispatch queue 基本的样子。

使用 GCD 的艺术在于如何针对你提交的任务选择合适的队列以及分发函数。而学习这项技能最好的办法就是跟着本教程过一遍接下来的示例项目,我们也会在这个过程中提供一些通用的建议。

七、示例项目(Sample Project)

由于本教程的目的主要是如何优化以及如何安全地使用 GCD 编写多线程代码,我们的示例教程就由一个现成的项目开始,这个项目叫 GooglyPuff。

GooglyPuff 是一个还没经过优化而且没实现线程安全的应用,这个应用使用 Core Image 的人脸识别 API 把漫画眼睛贴到照片里眼睛的位置上。图片可以从系统相册选择,或者从预设好的 URL 下载。

整个项目可以从这里下载:GooglyPuff_Swift_Start_1

下载解压后,在 Xcode 中打开并运行,你会看到如下界面:

Workflow1

我们注意到点击“Le Intenet”去下载图片的时候,一个 UIAlertController 立刻就弹出一个警告框,后面我们将一起修复这个问题。

这个项目有四个主要的类:

  • PhotoCollectionViewController:这是应用启动后首先进入的 View Controller,向用户展示了所有选中照片的缩略图。
  • PhotoDetailViewController:这个类里执行了粘贴漫画眼睛的逻辑,使用一个 UIScrollView 来展示贴图结果。
  • Photo:这是一个 protocol,定义了一张照片应用的几个属性。这个 protocol 提供一张图片,一个缩略图以及图片的状态。有两个类实现了这个 protocol:DownloadPhoto类通过 NSURL 实例化图片,而AssetPhoto则通过 ALAsset 实例化图片。
  • PhotoManager:这个类负责管理所有的 Photo 对象。

八、使用 dispatch_async 处理后台任务

现在让我们回到应用,选择从系统相册或者 Le Internet 下载图片。

留意一下从你在 PhotoCollectionViewController 中点击一个 UICollectionViewCell 到弹出一个新的 PhotoDetailViewController 需要多久;这里我们应该可以注意到有明显的延迟,尤其是当你查看一张很大的图片或者使用的是很慢的机器的时候。

UIViewController 的 viewDidLoad 方法是最容易堆积一大堆代码的地方,经常导致 view controller 需要等很久才能出现。所以我们要尽可能把不重要的任务放到后台去完成。

这听起来是 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
    }
  }
}

上面的代码到底干了什么呢:

  1. 首先,我们把部分工作从主线程上搬到了一个全局队列(global queue)中去。这里我们使用了 dispatch_async 方法,所以这个闭包里面的代码会被异步地提交到队列中去,同时 viewDidLoad 函数不等这个 block 执行完就会直接 return,不会卡住主线程。同时,人脸识别的代码也开始在跑。
  2. 这句代码执行的时候,人脸识别的代码已经结束并生成了一张新的图片。由于我们要在主线程用这个新的图片更新 UIImageView,这里我们加了一个新的的闭包,把更新的代码放到主线程去。这里要记得——你永远只能在主线程更新 UIKit 的类。
  3. 最后,我们使用 fadeInNewImage 方法,通过一个淡入动画展示贴上了动画眼睛的图片。

这里要注意我们用了 Swift 的尾随闭包语法(Trailing Closure Syntax),给 dispatch_async 方法传参的时候可以在括号后面直接写闭包的代码,这种写法可以让你的代码看上去更简洁一些。

现在重新编译运行这个工程,选择一张图片,你会发现view controller 展示比之前快多了,过了一会才把动画眼睛加上去。这带来一种很好的体验,你可以感受到照片被修改前后的对比带来的冲击。而且,如果你尝试加载一张特别大的图片,应用也不会在加载 view controller 的时候被卡住,这大大增加了应用的扩展性。

我们上面也提到了,dispatch_async 会把闭包里的任务加到队列里面,然后函数立刻返回。GCD 会决定什么时候执行这个任务。所以对于网络请求或者耗CPU 的任务你应该用 dispatch_async,这样才不会卡住当前线程。

以下是几条关于在使用 dispatch_async 函数时应该选择什么类型队列的建议:

  • 自定义串行队列 Custom Serial Queue:当你想要在后台顺序地执行这些任务并且对其保持跟踪的时候,这种类型是个不错的选择。这种队列避免了资源竞争的情况,因为一次只会有一个任务在进行。注意如果你需要通过一个方法来获取数据的话,你必须通过内联一个闭包来获取,或者你也可以考虑使用 dispatch_sync。
  • 主队列(串行)Main Queue:这个队列一般用在一个任务执行完之后要更新 UI 的时候,这种时候你要在一个闭包里面写多一个闭包用来回调主线程。而且,如果你在主线程调用 dispatch_async 来回调主队列,就可以保证你的这些代码一定会在当前函数结束后的某个时机被调用。
  • 并发队列 Concurrent Queue:通常用于在后台执行非UI 操作的任务。

九、便于获取全局队列的 Helper 变量

你可能会发现每次使用 dispatch_get_global_queue 还得传一个 QoS 类作为参数来获取对应的队列,这种写法有点累赘。这是因为 qos_class_t 被定义成一个结构体,而且 value 属性是 UInt32 类型,传参的时候还得转成 Int 类型。所以通过给 Utils.swift 文件添加几个 helper 变量我们可以更方便地获取这些全局队列:

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 换成我们刚加的 helper 变量:

dispatch_async(GlobalUserInitiatedQueue) {
  let overlayImage = self.faceOverlayImageFromImage(self.image)
  dispatch_async(GlobalMainQueue) {
    self.fadeInNewImage(overlayImage)
  }
}

这样修改后的代码可读性更好,更容易看出是使用了什么类型的队列。

十、使用 dispatch_after 来延迟部分工作

让我们回想一下这个 App 的用户体验,有可能用户刚打开这个 App 的时候并不知道该怎么操作对吧?所以,如果 PhotoManager 类里面还没有照片的时候,我们可以先展示一个提示页面。而且这个提示不能展示得太快。

延迟一秒钟再展示这个提示页面应该可以满足我们的需求。

把以下代码添加到 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!"
    }
  }
}

当 UICollectionView Reload 的时候,viewDidLoad 方法会被触发,从而调用 showOrHideNavPrompt 方法。

  1. 声明延迟调用的时间
  2. delayInSeconds 时间过后,这个闭包将被异步地调用

编译运行一下,我们将会看到延迟一下之后这个提示页面就弹出来了。

dispatch_after 方法就是一个可以设置延迟调用的 dispatch_async 方法,你始终无法精确地控制真正的调用时间,dispatch_after 函数返回后你也无法取消这次调用。

那么什么情况下用 dispatch_after 方法比较合适呢?

  • *自定义串行队列 Custom Serial Queue: 在这种队列中使用 dispatch_after 要格外小心,你最好还是只在主队列中使用这个方法。
  • 串行主队列 Main Queue (Serial): 在主队列中使用 dispatch_after 是一个不错的选择,而且 Xcode 的自动补全默认就是在主队列调用。
  • 并发队列 Concurrent Queue: 要小心在自定义并发队列中使用,一般情况下你是不会在并发队列里面用到的。最好还是使用主队列来使用这个方法。

十一、单例和线程安全 Singletons and Thread Safety

单例,让人又爱又恨的东西,它们就像网上的猫咪一样充斥整个世界 :]

单例最常令人担心的一点就是:它们经常是非线程安全的。而单例又常常是被多个 controller 同时访问的。PhotoManager 就是一个单例,所以我们还得考虑给它加上线程安全的代码。

这里主要有两个时机是需要我们保证线程安全的,一是单例在初始化的时候,另一个就是对单例进行读写操作的时候。

我们先考虑第一个问题。因为 Swift 在全局作用域中初始化变量时的特性,第一点变得很容易解决。在 Swift 中,全局变量是在第一次被访问的时候进行初始化的,而且是保证初始化过程是原子的。就是说,初始化代码是被当做临界区对待的,知道这段代码执行完了,其他的线程才能访问这个变量。Swift 是怎么做到这一点的呢?在我们看不到的代码里面,Swift 是使用 GCD 的 dispath_once 方法来实现的。有兴趣的同学可以看看这篇博客:https://developer.apple.com/swift/blog/?id=7

dispatch_once 会以线程安全的方式执行闭包,而且只会执行一次。如果有一个线程正在执行临界区代码——这个任务在 dispatch_once 方法中——那么其他线程就会暂时被 block,直到临界区执行完毕。一旦这个闭包执行完毕,其他线程将不会再次执行这个代码。再加上用 let 把单例的实例变量声明成一个全局常量,我们就可以保证在以后的代码中这个实例不会被修改。从这个角度上看,所有的 Swift 全局常量实际上都是单例,而且自带线程安全的初始化方法。

但是我们还是要考虑读写操作的线程安全。Swift 虽然可以用 dispatch_once 保证初始化代码是线程安全的,但是这个方法没法保证它的成员变量都是线程安全的。举个例子,如果一个全局变量是一个类的实例,当这个类在内部操作这个变量的时候,你还是有可能遇到临界区问题。所以我们需要用别的方法来保证这些操作是线程安全的,比如说同步数据访问,下面几个章节我们将会讲到。

十二、处理读者和写者问题

实例的线程安全问题并不是只有单例才需要关心。如果一个单例的属性是一个可变的对象,比如说 PhotoManager 的 photos 数组,那么你就得考虑这个对象本身是否线程安全的。

在 Swift 中,所有用 let 关键字声明的变量都是只读的,而且线程安全的。var 关键字声明的则表示可变而且除非这个数据类型的设计本来就是线程安全的,否则都是非线程安全的。Swift 的集合类型如 Array 和 Dictionary,如果声明是可变的就是非线程安全的。那么基础的容器类型呢?比如说 NSArray 这种类型?他们是线程安全的吗?答案是——“恐怕并不是”!Apple 官方维护了一个线程安全与非线程安全类型的列表大家可以瞧瞧。

虽然说多个线程同时读一个 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 方法反悔了一个可变数组,所以是一个读方法。调用这个方法的人会得到一个 _photos 数组的拷贝,以防被别人不小心修改了,但是如果有一个线程正在修改这个数组,另一个线程正在读这个数据就悲剧了。

注意:为啥上面这段代码里调用者会得到一个 _photos 数组的拷贝呢?因为在 Swift 里面,函数的返回类型要嘛是一个引用要嘛就是一个数值。返回引用就跟 ObjC 里面返回指针是一样的,你可以对原来的对象做任何操作。但是返回一个数值则会得到一个原对象的拷贝,对这个返回值的修改不会印象到原对象。默认情况下,Swift 的类实例会以引用方式或者结构体的值的方式传递与返回。

Swift 的内置数据类型,比如 Array 和 Dictionary,都是以 Struct 结构体的形式实现的,所以当你在代码里面传递这些值的时候,你传递的是一大堆结构体的拷贝。不过你不需要担心由此引起的内存消耗,Swift 对集合类型做了优化,只有在必要的时候才会真的做拷贝。例如,只有当一个数组被当做参数会返回值传递的过程中,第一次被修改的时候,才会做一次拷贝。

这是在软件开发中一个经典的读者写者问题,GCD 提供了一个优雅的解决方案——使用 dispatch barriers 创建读写锁

Dispatch barriers 是一组方法,它们的表现就像在并发队列遇到串行队列的性能瓶颈一样。使用 GCD 的 barrier API 可以确保提交进队列的闭包代码在被执行的时候只有一个任务在跑。也就是说,所有比 diaptch barrier 先进队列的任务要优先被执行完,然后再会执行这一个闭包。

轮到这个闭包执行的时候,GCD 会确保在执行过程中有且只有这一个任务在跑,直到它执行结束,才会恢复队列原来的实现。GCD 还提供了同步和异步的 barrier 方法。

下图展示了 barrier 方法在异步队列中的表现:

Dispatch Barrier

注意到通常情况下这个队列跟普通的并发队列没有两样,但是当 barrier 代码被执行的时候,这个队列看上去就像一个串行队列。换句话说,barrier 是当下唯一被执行的代码。当这个 barrier 结束了之后,队列就又会回到一个普通的并发队列的执行方式。

那你啥时候改用 barrier 方法,啥时候不该用呢?

  • *自定义串行队列 Custom Serial Queue: 没有必要在串行队列中使用,barrier 对于串行队列来说毫无用处,因为本来串行队列就是一次只会执行一个任务的。
  • 全局并发队列 Global Concurrent Queue: 要小心使用。在全局队列中使用 barrier 可能不是太好,因为系统也会使用这个队列,一般你不会希望自己的操作垄断了这个队列从而导致系统调用的延迟。
  • 自定义并发队列 Custom Concurrent Queue: 对于需要原子操作和访问临界区的代码,barrier 方法是最佳使用场景。任何你需要线程安全的实例,barrier 都是一个不错的选择。

看起来上面说的唯一正确的选择是自定义并发队列了,所以我们要创建一个自己的并发队列,用来处理 barrier 方法,从而是读写操作分离。这个并发队列将会允许多个读操作同时执行。

打开 PhotoManager.swift 文件,添加以下属性:

    private let concurrentPhotoQueue = dispatch_queue_create(
    "com.raywenderlich.GooglyPuff.photoQueue", DISPATCH_QUEUE_CONCURRENT)

这句代码使用 dispatch_queue_create 把 concurrentPhotoQueue 初始化为一个并发队列。第一个参数是一个反向域名风格的命名,确保这个命名的含义有助于我们以后调试。第二个参数用来表示这个队列是串行的还是并行的。

注意: 如果你在网上搜索 dispatch_queue_create 的示例代码,你经常会看到大家用 0 或者 NULL 作为第二个参数。这实际上是用来创建串行队列的一种过时的方法,更好的做法还是把这个类型写清楚。

找到 addPhoto 方法,替换成以下代码:

func addPhoto(photo: Photo) {
  dispatch_barrier_async(concurrentPhotoQueue) { // 1
    self._photos.append(photo) // 2
    dispatch_async(GlobalMainQueue) { // 3
      self.postContentAddedNotification()
    }
  }
}
  1. 把写操作添加到你的自定义队列中。当临界区代码被执行的时候,这段代码将是队列中唯一正在执行的。
  2. 这句代码是真正修改数组的地方。由于这句代码在 berrier 闭包中,在 concurrentPhotoQueue 这个队列中这个闭包将不会和其他任务同时执行。
  3. 最后,我们发一个通知说已经添加完图片了。这个通知必须在主线程发出才能更新 UI,所以我们在这里用了一个异步队列把任务抛回主线程去。

这个修改解决了写操作问题,接下来我们来看看读操作的问题。

为了确保读写操作都是线程安全的,我们需要把读操作也放进 concurrentPhotoQueue 这个队列里面。但是因为你需要函数的返回值,所以这个地方我们不能用异步调用的形式来写。在这里我们使用 dispatch_sync 方法。

dispatch_sync 会同步地提交当前的任务并且一直等到这个任务执行完毕才把函数返回。当你需要确保这些任务能够与 dispatch barrier 合作时,或者当你需要等待闭包的处理结果,然后才把函数返回时,你可以用 dispatch_sync。

但是使用这个函数要非常小心。试想你把 dispatch_sync 用在一个并发队列上,当这个队列跑起来的时候你就会遇到死锁问题。因为这个同步调用会一直等到前一个闭包执行结束才会返回,但是当前的queue 却在等待 dispatch_sync 的完成才能执行闭包代码,于是造成死锁。这就要求你一定要清楚地知道你是在哪一个队列发起的调用,同时也要搞清楚你是塞进去了哪一个队列。

以下是 dispatch_sync 使用时的概览:

自定义串行队列 Custom Serial Queue: 在这种队列上使用要非常小心。如果你在同一个队列的任务里面使用 dispatch_sync 到同一个队列,那你绝对会遇到死锁问题。
主队列 Main Queue (Serial): 跟上面那个一样要非常小心,也是很有可能造成死锁。
并发队列 Concurrent Queue: 在这种队列中,如果你想要通过 dispatch barrier 来同步任务,dispatch_sync 是一个不错的选择。

下面我们把 PhotoManager.swift 中的 photos 方法修改一下:

    var photos: [Photo] {
      var photosCopy: [Photo]!
      dispatch_sync(concurrentPhotoQueue) { // 1
        photosCopy = self._photos // 2
      }
      return photosCopy
    }
  1. 同步地把读操作加入到 concurrentPhotoQueue 队列中
  2. 把 _photos 数据拷贝一份保存到 photosCopy 变量中

恭喜——你的 PhotoManager 单例现在完全线程安全了。不管你是在何时去读或者写,你都能很有自信地说这些操作都是安全的了。

十三、总结一下 Queueing

可能大家还没有 100% 地清楚 GCD 的牛逼之处?首先确保一下你已经能够自如地使用 GCD 的基础API 来实现简单的多线程逻辑,最好多用断点和 NSLog 来确保你清楚正在发生什么。

下面我们提供了两个 GIF 动画来帮助你理解 dispatch_async 和 dispatch_sync。请留意 GIF 展示的每一步断点与各个队列的关系。

dispatch_sync

override func viewDidLoad() {
  super.viewDidLoad()
 
  dispatch_sync(dispatch_get_global_queue(
      Int(QOS_CLASS_USER_INTERACTIVE.value), 0)) {
 
    NSLog("First Log")
 
  }
 
  NSLog("Second Log")
}

http://cdn.justinbot.com/wp-content/uploads/2015/11/dispatch_sync_in_action_swift.gif

  1. 主队列开始执行任务——下一步是初始化 UIViewController,会掉入 viewDidLoad 方法。
  2. viewDidLoad 在主线程被调用
  3. dispatch_sync 闭包被加入主队列中,会在后面被执行。流程到了这里就会阻塞住主线程,先把闭包里的代码执行完。与此同时,主队列又在并发地执行任务,回想一下我们前面介绍的过的,这个闭包被插入一个先入先出的全局队列,但是会被并发地执行。这个全局队列会先执行在 dispatch_sync 闭包被插入队列之前的那些任务。
  4. 最后, dispatch_sync 开始执行了。
  5. 这个闭包执行结束了,于是主线程可以恢复运行了。
  6. viewDidLoad 方法执行结束,主队列继续执行其他任务。

dispatch_sync 会把任务加入到队列中并且一直等待直到任务执行结束。dispatch_async 也做了一样的事情,唯一的不同时它不会等待任务结束而是立刻就返回。

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")
}

http://cdn.justinbot.com/wp-content/uploads/2015/11/dispatch_async_in_action_swift.gif

  1. 主队列开始执行任务——下一步是初始化 UIViewController,会掉入 viewDidLoad 方法。
  2. viewDidLoad 在主线程被调用。
  3. dispatch_async 被加入到全局队列中。
  4. viewDidLoad 在 dispatch_async 闭包提交到全局队列后继续执行。与此同时,全局队列开始处理他队列中的任务。要记得这里先入先出的全局队列是可以并发地执行任务的。
  5. dispatch_async 提交的闭包开始被执行了。
  6. dispatch_async 执行结束,两句 NSLog 也都打出了日志。但是并不一定每次都是这样的——这实际上取决于硬件的实现,你是完全没法控制也完全不知道这句语句会被怎么执行。

十四、接下来?

在这个教程中,你已经学会了如何让你的代码实现线程安全,也知道怎么把 CPU 密集的任务从主线程挪出来。

你可以在这里下载这个项目的代码,这份代码是包含了以上所有的优化的:GooglyPuff Project 。本教程的第二部分将告诉你如何持续优化这个项目。

如果你有计划要优化你自己的应用,你需要学会怎么使用 Instruments 里的Time Profile 来测试你的代码。不过如何使用这个工具就不在本教程的讲述范围内了,你可以参考这篇文章 如何使用 Instrcments

然后记得要用真实的设备来进行测试,用模拟器测出来的结果与真机相差甚远。

下一篇教程中你将更加深入地了解 GCD 的API,我们将介绍更为强大的工具。

2015.11.22/夜
于自宅

译者注:由于译者太懒,此文花了许多时间来才翻译完,也许内容有些过时,不过 GCD 提供的 API 至今仍然是 iOS/Mac 上使用多线程的首选。