GCD深入理解(1)

时间:2021-08-11 15:36:49

写在前面

  1. 本文原文为raywenderlich的《grand-central-dispatch-in-depth-part-1》;顺便提及一下,笔者认为,对于iOS初学者而言,raywenderlich是一个极佳的学习站点。
  2. 本文为转载内容,转自《GCD深入理解(一)》,GCD深入理解(一)和GCD深入理解(二)出自@nixzhu,本文中出现的“译者”即@nixzhu
  3. 笔者在学习@nixzhu的这两篇博文过程中作了一些笔记,近期又正好学习使用markdown来写博客,于是有将这两篇文章搬到自己的博客遂成此文。

虽然GCD已经出现过一段时间了,但不是每个人都明了其主要内容。这是可以理解的;并发一直很棘手,而GCD是基于C的API,它们就像一组尖锐的棱角戳进Objective-C的平滑世界。我们将分两个部分的教程来深入学习GCD 。

在这两部分的系列中,第一个部分的将解释GCD是做什么的,并从许多基本的GCD函数中找出几个来展示。在第二部分,你将学到几个GCD提供的高级函数。

什么是GCD

GCD是libdispatch的市场名称,而libdispatch作为Apple的一个库,为并发代码在多核硬件(跑iOS或OS X)上执行提供有力支持。它具有以下优点:

  1. GCD能通过推迟昂贵计算任务并在后台运行它们来改善你的应用的响应性能。
  2. GCD提供一个易于使用的并发模型而不仅仅只是锁和线程,以帮助我们避开并发陷阱。
  3. GCD具有在常见模式(例如单例)上用更高性能的原语优化你的代码的潜在能力。

本教程假设你对BlockGCD有基础了解。如果你对GCD完全陌生,先看看iOS上的多线程和GCD入门教程学习其要领。

GCD术语

要理解GCD,你要先熟悉与线程和并发相关的几个概念。这两者都可能模糊和微妙,所以在开始GCD之前先简要地回顾一下它们。

串行vs并发

这些术语描述当任务相对于其它任务被执行,任务串行执行就是每次只有一个任务被执行,任务并发执行就是在同一时间可以有多个任务被执行。

虽然这些术语被广泛使用,本教程中你可以将任务设定为一个Objective-C的Block。不明白什么是Block,看看iOS 5教程中的如何使用Block。实际上,你也可以在GCD上使用函数指针,但在大多数场景中,这实际上更难于使用。Block就是更加容易些!

同步vs异步

在GCD中,这些术语描述当一个函数相对于另一个任务完成,此任务是该函数要求GCD执行的。一个同步函数只在完成了它预定的任务后才返回。

一个异步函数,刚好相反,会立即返回,预定的任务会完成但不会等它完成。因此,一个异步函数不会阻塞当前线程去执行下一个函数。

注意——当你读到同步函数“阻塞(Block)”当前线程,或函数是一个“阻塞”函数或阻塞操作时,不要被搞糊涂了!动词“阻塞”描述了函数如何影响它所在的线程而与名词“代码块(Block)”没有关系。代码块描述了用Objective-C编写的一个匿名函数,它能定义一个任务并被提交到GCD。

译者注:中文不会有这个问题,“阻塞”和“代码块”是两个词。

临界区

就是一段代码不能被并发执行,也就是,两个线程不能同时执行这段代码。这很常见,因为代码去操作一个共享资源,例如一个变量若能被并发进程访问,那么它很可能会变质(译者注:它的值不再可信)。

竞态条件

这种状况是指基于特定序列或时机的事件的软件系统以不受控制的方式运行的行为,例如程序的并发任务执行的确切顺序。竞态条件可导致无法预测的行为,而不能通过代码检查立即发现。

死锁

两个(有时更多)东西——在大多数情况下,是线程——所谓的死锁是指它们都卡住了,并等待对方完成或执行其它操作。第一个不能完成是因为它在等待第二个的完成。但第二个也不能完成,因为它在等待第一个的完成。

线程安全

线程安全的代码能在多线程或并发任务中被安全的调用,而不会导致任何问题(数据损坏,崩溃,等)。线程不安全的代码在某个时刻只能在一个上下文中运行。一个线程安全代码的例子是NSDictionary。你可以在同一时间在多个线程中使用它而不会有问题。另一方面,NSMutableDictionary就不是线程安全的,应该保证一次只能有一个线程访问它。

笔者注:笔者以前在接触C系统编程的时候经常看到“该函数是线程安全的”之类的说法,所谓的“线程安全”指的是“该函数能被多个线程同时访问而不出问题”。

上下文切换

一个上下文切换指当你在单个进程里切换执行不同的线程时存储与恢复执行状态的过程。这个过程在编写多任务应用时很普遍,但会带来一些额外的开销。

并发与并行

并发并行通常被一起提到,所以值得花些时间解释它们之间的区别。

并发代码的不同部分可以“同步”执行。然而,该怎样发生或是否发生都取决于系统。多核设备通过并行来同时执行多个线程;然而,为了使单核设备也能实现这一点,它们必须先运行一个线程,执行一个上下文切换,然后运行另一个线程或进程。这通常发生地足够快以致给我们并发执行地错觉,如下图所示:

GCD深入理解(1)

虽然你可以编写代码在GCD下并发执行,但GCD会决定有多少并行的需求。并行要求并发,但并发并不能保证并行。

更深入的观点是并发实际上是关于构造。当你在脑海中用GCD编写代码,你组织你的代码来暴露能同时运行的多个工作片段,以及不能同时运行的那些。

队列

GCD提供有dispatch queues来处理代码块,这些队列管理你提供给GCD的任务并用FIFO顺序执行这些任务。这就保证了第一个被添加到队列里的任务会是队列中第一个开始的任务,而第二个被添加的任务将第二个开始,如此直到队列的终点。

所有的调度队列(dispatch queues)自身都是线程安全的,你能从多个线程并行的访问它们。GCD的优点是显而易见的,即当你了解了调度队列如何为你自己代码的不同部分提供线程安全。关于这一点的关键是选择正确类型的调度队列和正确的调度函数来提交你的工作。

在本节你会看到两种调度队列,都是由GCD提供的,然后看一些描述如何用调度函数添加工作到队列的列子。

串行队列

GCD深入理解(1)

这些任务的执行时机受到GCD的控制;唯一能确保的事情是GCD一次只执行一个任务,并且按照我们添加到队列的顺序来执行。

由于在串行队列中不会有两个任务并发运行,因此不会出现同时访问临界区的风险;相对于这些任务来说,这就从竞态条件下保护了临界区。所以如果访问临界区的唯一方式是通过提交到调度队列的任务,那么你就不需要担心临界区的安全问题了。

并发队列

笔者注:这里翻译成并发队列而非并行队列是非常准确的,
在并发队列中的任务能得到的保证是它们会按照被添加的顺序开始执行,但这就是全部的保证了。任务可能以任意顺序完成,你不会知道何时开始运行下一个任务,或者任意时刻有多少Block在运行。再说一遍,这完全取决于GCD。

下图展示了一个示例任务执行计划,GCD管理着四个并发任务:

GCD深入理解(1)

注意Block 1,2和3都立马开始运行,一个接一个。在Block 0开始后,Block 1等待了好一会儿才开始。同样,Block 3在Block 2之后才开始,但它先于Block 2完成。

何时开始一个Block完全取决于GCD。如果一个Block的执行时间与另一个重叠,也是由GCD来决定是否将其运行在另一个不同的核心上,如果那个核心可用,否则就用上下文切换的方式来执行不同的Block。

有趣的是,GCD提供给你至少五个特定的队列,可根据队列类型选择使用。
笔者注:这5个特定的队列由系统提供,包括:

  1. 主队列(main queue,属于串行队列;
  2. 4个全局调度队列,分别对着不同的优先级:background、low、default、high;

队列类型

首先,系统提供给你一个叫做 主队列(main queue) 的特殊队列。和其它串行队列一样,这个队列中的任务一次只能执行一个。然而,它能保证所有的任务都在主线程执行,而主线程是唯一可用于更新UI的线程。这个队列就是用于发生消息给UIView或发送通知的。

系统同时提供给你好几个并发队列。它们叫做全局调度队列(Global Dispatch Queues)。目前的四个全局队列有着不同的优先级:background、low、default以及high。要知道,Apple的API也会使用这些队列,所以你添加的任何任务都不会是这些队列中唯一的任务

最后,你也可以创建自己的串行队列或并发队列。这就是说,至少有五个队列任你处置:主队列、四个全局调度队列,再加上任何你自己创建的队列。

以上是调度队列的大框架

GCD的使用艺术归结为选择合适的队列来调度你的任务。体验这一点的最好方式是走一遍下边的列子,我们沿途会提供一些一般性的建议。

入门

既然本教程的目标是优化且安全的使用GCD调用来自不同线程的代码,那么你将从一个近乎完成的叫做GooglyPuff的项目入手。

GooglyPuff是一个没有优化,线程不安全的应用,它使用Core Image的人脸检测API来覆盖一对曲棍球眼睛到被检测到的人脸上。对于基本的图像,可以从相机胶卷选择,或用预设好的URL从互联网下载。

点击此处下载项目~

完成项目下载之后,将其解压到某个方便的目录,再用 Xcode 打开它并编译运行。这个应用看起来如下图所示:

GCD深入理解(1)

注意当你选择Le Internet选项下载图片时,一个UIAlertView过早地弹出。你将在本系列教程地第二部分修复这个问题。

这个项目中有四个有趣的类:

  1. PhotoCollectionViewController:它是应用开始的第一个视图控制器。它用缩略图展示所有选定的照片;
  2. PhotoDetailViewController:它执行添加曲棍球眼睛到图像上的逻辑,并用一个UIScrollView来显示结果图片;
  3. Photo:这是一个类簇,它根据一个NSURL的实例或一个ALAsset的实例来实例化照片。这个类提供一个图像、缩略图以及从URL下载的状态;
  4. PhotoManager:它管理所有Photo的实例;

用dispatch_async处理后台任务

回到应用并从你的相机胶卷添加一些照片或使用Le Internet选项下载一些。

注意在按下PhotoCollectionViewController中的一个 UICollectionViewCell 到生成一个新的PhotoDetailViewController之间花了多久时间;你会注意到一个明显的滞后,特别是在比较慢的设备上查看很大的图。

在重载UIViewController的viewDidLoad时容易加入太多杂波(too much clutter),这通常会引起视图控制器出现前更长的等待。如果可能,最好是卸下一些工作放到后台,如果它们不是绝对必须要运行在加载时间里。

这听起来像是dispatch_async能做的事情!

打开PhotoDetailViewController并用下面的实现替换viewDidLoad:

 - (void)viewDidLoad
{
[super viewDidLoad];
NSAssert(_image, @"Image not set; required to use view controller");
self.photoImageView.image = _image; //Resize if neccessary to ensure it's not pixelated
if (_image.size.height <= self.photoImageView.bounds.size.height &&
_image.size.width <= self.photoImageView.bounds.size.width) {
[self.photoImageView setContentMode:UIViewContentModeCenter];
} dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, ), ^{ //
UIImage *overlayImage = [self faceOverlayImageFromImage:_image];
dispatch_async(dispatch_get_main_queue(), ^{ //
[self fadeInNewImage:overlayImage]; //
});
});
}

下面来说明上面的新代码所做的事:

  1. 你首先将工作从主线程(viewDidLoad在主线程中执行)移到全局线程。因为这是一个dispatch_async(),Block会被异步地提交,意味着调用线程地执行将会继续。这就使得viewDidLoad更早地在主线程完成,让加载过程感觉起来更加快速。同时,一个人脸检测过程会启动并将在稍后完成。
  2. 在这里,人脸检测过程完成,并生成了一个新的图像。既然你要使用此新图像更新你的UIImageView,那么你就添加一个新的Block到主线程。记住——你必须总是在主线程访问UIKit的类。
  3. 最后,你用fadeInNewImage: 更新UI,它执行一个淡入过程切换到新的曲棍球眼睛图像。

编译并运行你的应用;选择一个图像然后你会注意到视图控制器加载明显变快,曲棍球眼睛稍微在之后就加上了。这给应用带来了不错的效果,和之前的显示差别巨大。

进一步,如果你试着加载一个超大的图像,应用不会在加载视图控制器上“挂住”,这就使得应用具有很好伸缩性。

正如之前提到的, dispatch_async添加一个Block都队列就立即返回了。任务会在之后由GCD决定执行。当你需要在后台执行一个基于网络或CPU紧张的任务时就使用dispatch_async,这样就不会阻塞当前线程。

下面是一个关于在dispatch_async上如何以及何时使用不同的队列类型的快速指导

  1. 自定义串行队列:当你想串行执行后台任务并追踪它时就是一个好选择。这消除了资源争用,因为你知道一次只有一个任务在执行。注意若你需要来自某个方法的数据,你必须内联另一个Block来找回它或考虑使用 dispatch_sync。
  2. 主队列(串行):这是在一个并发队列上完成任务后更新UI的共同选择。要这样做,你将在一个Block内部编写另一个Block。以及,如果你在主队列调用dispatch_async到主队列,你能确保这个新任务将在当前方法完成后的某个时间执行。
  3. 并发队列:这是在后台执行非UI工作的共同选择。

使用dispatch_after延后工作

稍微考虑一下应用的UX (User eXperience)。是否用户第一次打开应用时会困惑于不知道做什么?你是这样吗?

如果用户的PhotoManager里还没有任何照片,那么显示一个提示会是个好主意!然而,你同样要考虑用户的眼睛会如何在主屏幕上浏览:如果你太快的显示一个提示,他们的眼睛还徘徊在视图的其它部分上,他们很可能会错过它。
笔者注:这段话非常值得玩味,这就是他妈的用户体验啊!

显示提示之前延迟一秒钟就足够捕捉到用户的注意,他们此时已经第一次看过了应用。

添加如下代码到到PhotoCollectionViewController.m中 showOrHideNavPrompt方法中:

 - (void)showOrHideNavPrompt
{
// Implement me!
NSUInteger count = [[PhotoManager sharedManager] photos].count;
double delayInSeconds = 1.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); //
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ //
if (!count) {
[self.navigationItem setPrompt:@"Add photos with faces to Googlyify them!"];
} else {
[self.navigationItem setPrompt:nil];
}
});
}

然后重新运行一下工程,你就瞬间明白了。

showOrHideNavPrompt在viewDidLoad中执行,以及UICollectionView被重新加载的任何时候。按照注释数字顺序看看:

  1. 你声明了一个变量指定要延迟的时长。
  2. 然后等待delayInSeconds给定的时长,再异步地添加一个Block到主线程。

编译并运行应用。应该有一个轻微地延迟,这有助于抓住用户的注意力并展示所要做的事情。

dispatch_after工作起来就像一个延迟版的dispatch_async。你依然不能控制实际的执行时间,且一旦dispatch_after返回也就不能再取消它。

不知道何时适合使用dispatch_after?

  1. 自定义串行队列:在一个自定义串行队列上使用dispatch_after要小心。你最好坚持使用主队列。
  2. 主队列(串行):是使用dispatch_after的好选择;Xcode 提供了一个不错的自动完成模版。
  3. 并发队列:在并发队列上使用dispatch_after也要小心;你会这样做就比较罕见。还是在主队列做这些操作吧。

让你的单例线程安全

单例,不论喜欢还是讨厌,它们在iOS上的流行情况就像网上的猫。

一个常见的担忧是它们常常不是线程安全的。这个担忧十分合理,基于它们的用途:单例常常被多个控制器同时访问。

单例的线程担忧范围从初始化开始,到信息的读和写。PhotoManager类被实现为单例——它在目前的状态下就会被这些问题所困扰。要看看事情如何很快地失去控制,你将在单例实例上创建一个控制好的竞态条件。

导航到PhotoManager.m并找到sharedManager;它看起来如下:

 + (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
if (!sharedPhotoManager) {
sharedPhotoManager = [[PhotoManager alloc] init];
sharedPhotoManager->_photosArray = [NSMutableArray array];
}
return sharedPhotoManager;
}

笔者注:“sharedPhotoManager->_photosArray”这种C语言的写法纯属作者的个人爱好,木有其他的深意。
当前状态下,代码相当简单;你创建了一个单例并初始化一个叫做photosArray的NSMutableArray属性。

然而,if条件分支不是线程安全的;如果你多次调用这个方法,有一个可能性是:

  1. 在某个线程(就叫它线程A)上进入if语句块并可能在 sharedPhotoManager被分配内存前(即在执行“sharedPhotoManager = [[PhotoManager alloc] init];”之前)发生一个上下文切换,即切换到另一个线程(线程B);
  2. 线程B也来掺合,即调用上述方法,显然也会进入if语句并执行其中代码,分配单例实例的内存,然后退出;
  3. 系统上下文切换回线程A,A接着执行上次从if语句中跳出去的代码,即“sharedPhotoManager = [[PhotoManager alloc] init];”,然后退出;

完成这一系列之后,程序就有了两个单例的实例,很明显这不是你想要的(译者注:这还能叫单例吗?)!

要强制这个(竞态条件)发生,即让上述的“可能性”成真,替换PhotoManager.m中的sharedManager为下面的实现:

 + (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
if (!sharedPhotoManager) {
[NSThread sleepForTimeInterval:];
sharedPhotoManager = [[PhotoManager alloc] init];
NSLog(@"Singleton has memory address at: %@", sharedPhotoManager);
[NSThread sleepForTimeInterval:];
sharedPhotoManager->_photosArray = [NSMutableArray array];
}
return sharedPhotoManager;
}

上面的代码中你用NSThread的sleepForTimeInterval:类方法来强制发生一个上下文切换。

打开AppDelegate.m并添加如下代码到application:didFinishLaunchingWithOptions:的最开始处:

 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, ), ^{
[PhotoManager sharedManager];
}); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, ), ^{
[PhotoManager sharedManager];
});

这里创建了多个异步并发调用来实例化单例,然后引发上面描述的竞态条件

编译并运行项目;查看控制台输出,你会看到多个单例被实例化,如下所示:

GCD深入理解(1)

注意到这里有好几行显示着不同地址的单例实例。这明显违背了单例的目的,对吧?

这个输出向你展示了临界区被执行多次,而它只应该执行一次。现在,固然是你自己强制这样的状况发生,但你可以想像一下这个状况会怎样在无意间发生。

注意:基于其它你无法控制的系统事件,NSLog的数量有时会显示多个。线程问题极其难以调试,因为它们往往难以重现。要纠正这个状况,实例化代码应该只执行一次,并阻塞其它实例在 if 条件的临界区运行。这刚好就是dispatch_once能做的事。

在单例初始化方法中用dispatch_once取代if条件判断,如下所示:

 + (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[NSThread sleepForTimeInterval:];
sharedPhotoManager = [[PhotoManager alloc] init];
NSLog(@"Singleton has memory address at: %@", sharedPhotoManager);
[NSThread sleepForTimeInterval:];
sharedPhotoManager->_photosArray = [NSMutableArray array];
});
return sharedPhotoManager;
}

编译并运行你的应用;查看控制台输出,你会看到有且仅有一个单例的实例——这就是你对单例的期望!

现在你已经明白了防止竞态条件的重要性,从AppDelegate.m中移除dispatch_async语句,并用下面的实现替换PhotoManager单例的初始化:

 + (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedPhotoManager = [[PhotoManager alloc] init];
sharedPhotoManager->_photosArray = [NSMutableArray array];
});
return sharedPhotoManager;
}

dispatch_once()以线程安全的方式执行且仅执行其代码块一次。试图访问临界区(即传递给dispatch_once的代码)的不同的线程会在临界区已有一个线程的情况下被阻塞,直到临界区完成为止,总之一句话:dispatch_once能够保护临界区。

GCD深入理解(1)

需要记住的是,这只是让访问共享实例线程安全。它绝对没有让类本身线程安全。类中可能还有其它竞态条件,例如任何操纵内部数据的情况。这些需要用其它方式来保证线程安全,例如同步访问数据,你将在下面几个小节看到。

处理读者与写者问题

线程安全实例不是处理单例时的唯一问题。如果单例属性表示一个可变对象,那么你就需要考虑是否那个对象自身线程安全。

如果问题中的这个对象是一个Foundation容器类,那么答案是:“很可能不安全”!Apple维护一个有用且有些心寒的列表,众多的Foundation类都不是线程安全的。NSMutableArray,已用于你的单例,正在那个列表里休息。

虽然许多线程可以同时读取NSMutableArray的一个实例而不会产生问题,但当一个线程正在读取时让另外一个线程修改数组就是不安全的。你的单例在目前的状况下不能预防这种情况的发生。

要分析这个问题,看看PhotoManager.m中的addPhoto:,转载如下:

 - (void)addPhoto:(Photo *)photo
{
if (photo) {
[_photosArray addObject:photo];
dispatch_async(dispatch_get_main_queue(), ^{
[self postContentAddedNotification];
});
}
}

这是一个写方法,它修改一个私有可变数组对象。

现在看看photos,转载如下:

  - (NSArray *)photos  {
return [NSArray arrayWithArray:_photosArray];
}

这是所谓的读方法,它读取可变数组。它为调用者生成一个不可变的拷贝,防止调用者不当地改变数组,但这不能提供任何保护来对抗当一个线程调用读方法photos的同时另一个线程调用写方法addPhoto:。

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

Dispatch barriers是一组函数,在并发队列上工作时扮演一个串行式的瓶颈。使用GCD的障碍(barrier)API确保提交的Block在那个特定时间上是指定队列上唯一被执行的条目。这就意味着所有的先于调度障碍提交到队列的条目必能在这个Block执行前完成。

当这个Block(如下图中的Barrier Block)的执行时机到达时,barrier执行这个Block(如下图中的Barrier Block)并确保在那个时间里队列不会执行任何其它Block。一旦完成,队列就返回到它默认的实现状态。GCD提供了同步异步两种障碍函数。

下图显示了障碍函数对多个异步队列的影响:

GCD深入理解(1)

注意到正常部分的操作就如同一个正常的并发队列。但当Barrier Block执行时,它本质上就如同一个串行队列。也就是,障碍是唯一在执行的事物。在障碍完成后,队列回到一个正常并发队列的样子。

下面是你何时会——和不会——使用障碍函数的情况:

  1. 自定义串行队列:一个很坏的选择;障碍不会有任何帮助,因为不管怎样,一个串行队列一次都只执行一个操作。
  2. 全局并发队列:要小心;这可能不是最好的主意,因为其它系统可能在使用队列而且你不能垄断它们只为你自己的目的。
  3. 自定义并发队列:这对于原子或临界区代码来说是极佳的选择。任何你在设置或实例化的需要线程安全的事物都是使用障碍的最佳候选。
    笔者注:非常重要!

由于上面唯一像样的选择是自定义并发队列,你将创建一个你自己的队列去处理你的障碍函数并分开读和写函数。且这个并发队列将允许多个多操作同时进行。

打开PhotoManager.m,添加如下私有属性到类扩展中:

 @interface PhotoManager ()
@property (nonatomic,strong,readonly) NSMutableArray *photosArray;
@property (nonatomic, strong) dispatch_queue_t concurrentPhotoQueue; ///< Add this
@end

找到addPhoto:并用下面的实现替换它:

 - (void)addPhoto:(Photo *)photo
{
if (photo) { //
dispatch_barrier_async(self.concurrentPhotoQueue, ^{ //
[_photosArray addObject:photo]; //
dispatch_async(dispatch_get_main_queue(), ^{ //
[self postContentAddedNotification];
});
});
}
}

笔者注:即对“写操作”进行Barrier保护。

你新写的函数是这样工作的:

  1. 在执行下面所有的工作前检查是否有合法的相片。
  2. 添加写操作到你的自定义队列。当临界区在稍后执行时,这将是你队列中唯一执行的条目。
  3. 这是添加对象到数组的实际代码。由于它是一个障碍Block,这个Block永远不会同时和其它Block一起在concurrentPhotoQueue中执行。
  4. 最后你发送一个通知说明完成了添加图片。这个任务将在主线程中被调度,因为它将会做一些UI工作,所以在此为了通知,你异步地调度另一个任务到主线程。

上述工作都是为了照顾“写操作”不被干扰,而我们的目的是让“写操作”不被“读操作”所干扰,现在“写操作”在concurrentPhotoQueue队列中被执行,若把“读操作”放在别个队列中,还能达到目的吗?显然不能,barrier只能保证“写操作”不被同一个队列中的其他任务干扰,但无法保证不被其他队列中的任务干扰,所以为了达到我们的目的,还需要将“读操作”也放在concurrentPhotoQueue中。

既然你需要从函数返回,你就不能异步调度到队列,因为那样在读者函数返回之前不一定运行。

在这种情况下,dispatch_sync就是一个绝好的候选。

dispatch_sync()同步地提交工作并在返回前等待它完成。使用dispatch_sync跟踪你的调度障碍工作,或者当你需要等待操作完成后才能使用Block处理过的数据。如果你使用第二种情况做事,你将不时看到一个__block变量写在dispatch_sync范围之外,以便返回时在dispatch_sync使用处理过的对象。

但你需要很小心。想像如果你调用dispatch_sync并放在你已运行着的当前队列。这会导致死锁,因为调用会一直等待直到Block完成,但Block不能完成(它甚至不会开始!),直到当前已经存在的任务完成,而当前任务无法完成!这将迫使你自觉于你正从哪个队列调用——以及你正在传递进入哪个队列。
笔者注:笔者在刚开始接触GCD时非常不理解dispatch_sync的存在意义,毕竟它不像dispatch_async一样能够借着异步能够带来用户体验上的提升,本段内容很好地消除了我的疑虑。

下面是一个快速总览,关于在何时以及何处使用dispatch_sync

  1. 自定义串行队列:在这个状况下要非常小心!如果你正运行在一个队列并调用dispatch_sync放在同一个队列,那你就百分百地创建了一个死锁。
    笔者注:很容易理解,但是也是非常容易犯的一个错误。
  2. 主队列(串行):同上面的理由一样,必须非常小心!这个状况同样有潜在的导致死锁的情况。
  3. 并发队列:这才是做同步工作的好选择,不论是通过调度障碍,或者需要等待一个任务完成才能执行进一步处理的情况。

继续在PhotoManager.m上工作,用下面的实现替换photos:

 - (NSArray *)photos
{
__block NSArray *array; //
dispatch_sync(self.concurrentPhotoQueue, ^{ //
array = [NSArray arrayWithArray:_photosArray]; //
});
return array;
}

这就是你的读函数。按顺序看看编过号的注释,有这些:

  1. __block关键字允许对象在Block内可变。没有它,array在Block内部就只是只读的,你的代码甚至不能通过编译。
  2. 在concurrentPhotoQueue上同步调度来执行读操作。
  3. 将相片数组存储在array内并返回它。

最后,你需要实例化你的concurrentPhotoQueue属性。修改sharedManager以便像下面这样初始化队列:

 + (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedPhotoManager = [[PhotoManager alloc] init];
sharedPhotoManager->_photosArray = [NSMutableArray array];
// ADD THIS:
sharedPhotoManager->_concurrentPhotoQueue =
dispatch_queue_create("com.selander.GooglyPuff.photoQueue", DISPATCH_QUEUE_CONCURRENT);
});
return sharedPhotoManager;
}

这里使用dispatch_queue_create初始化concurrentPhotoQueue为一个并发队列。第一个参数是反向DNS样式命名惯例;确保它是描述性的,将有助于调试。第二个参数指定你的队列是串行还是并发。

注意:当你在网上搜索例子时,你会经常看人们传递0或者NULL给dispatch_queue_create的第二个参数。这是一个创建串行队列的过时方式;明确你的参数总是更好。
恭喜,你的PhotoManager单例现在是线程安全的了。不论你在何处或怎样读或写你的照片,你都有这样的自信,即它将以安全的方式完成,不会出现任何惊吓。

queue回顾

依然没有100%地掌握GCD的要领?确保你可以使用GCD函数轻松地创建简单的例子,使用断点和NSLog语句保证自己明白当下发生的情况。

我在下面提供了两个GIF动画来帮助你巩固对dispatch_async和dispatch_sync的理解。包含在每个GIF中的代码可以提供视觉辅助;仔细注意GIF左边显示代码断点的每一步,以及右边相关队列的状态。

dispatch_sync回顾

 - (void)viewDidLoad
{
[super viewDidLoad];
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, ), ^{
NSLog(@"First Log");
});
NSLog(@"Second Log");
}

GCD深入理解(1)

下面是图中几个步骤的说明:

  1. 主队列一路按顺序执行任务——接着是一个实例化UIViewController的任务,其中包含了viewDidLoad。
  2. viewDidLoad在主线程执行。
  3. 主线程目前在viewDidLoad内,正要到达dispatch_sync。
  4. dispatch_sync Block被添加到一个全局队列中,将在稍后执行。进程将在主线程挂起直到该Block完成。同时,全局队列并发处理任务;要记得Block在全局队列中将按照FIFO顺序出列,但可以并发执行。
  5. 全局队列处理dispatch_sync Block加入之前已经出现在队列中的任务。
  6. 终于,轮到dispatch_sync Block 。
  7. 这个Block完成,因此主线程上的任务可以恢复。
  8. viewDidLoad方法完成,主队列继续处理其他任务。

dispatch_sync添加任务到一个队列并等待直到任务完成。dispatch_async做类似的事情,但不同之处是它不会等待任务的完成,而是立即继续“调用线程”的其它任务。

dispatch_async回顾

 - (void)viewDidLoad
{
[super viewDidLoad]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, ), ^{
NSLog(@"First Log");
}); NSLog(@"Second Log");
}

GCD深入理解(1)

  1. 主队列一路按顺序执行任务——接着是一个实例化UIViewController的任务,其中包含了viewDidLoad。
  2. viewDidLoad在主线程执行。
  3. 主线程目前在viewDidLoad内,正要到达dispatch_async。
  4. dispatch_async Block被添加到一个全局队列中,将在稍后执行。
  5. viewDidLoad在添加dispatch_async到全局队列后继续进行,主线程把注意力转向剩下的任务。同时,全局队列并发地处理它未完成地任务。记住Block在全局队列中将按照FIFO顺序出列,但可以并发执行。
  6. 添加到dispatch_async的代码块开始执行。
  7. dispatch_async Block完成,两个NSLog语句将它们的输出放在控制台上。

在这个特定的实例中,第二个NSLog语句执行,跟着是第一个NSLog语句。并不总是这样——着取决于给定时刻硬件正在做的事情,而且你无法控制或知晓哪个语句会先执行。“第一个”NSLog在某些调用情况下会第一个执行。

下一步怎么走?

在本教程中,你学习了如何让你的代码线程安全,以及在执行CPU密集型任务时如何保持主线程的响应性。

你可以下载GooglyPuff项目,它包含了目前所有本教程中编写的实现。在本教程的第二部分,你将继续改进这个项目。

如果你计划优化你自己的应用,那你应该用Instruments中的Time Profile模版分析你的工作。对这个工具的使用超出了本教程的范围,你可以看看如何使用Instruments来得到一个很好的概述。

同时请确保在真实设备上分析,而在模拟器上测试会对程序速度产生非常不准确的印象。

在教程的下一部分,你将更加深入到GCD的API中,做一些更Cool的东西。