GCD ( 二 ) :为主线程加载提速,创建线程安全的单例
在这篇文章里我们将通过具体的实例,来描述GCD的使用情况,以及需要注意的地方,这或许可以帮助你更好的理解GCD.
一、体会异步带来的好处:
有一个场景:在控制器中需要,我们需要渲染并加载一张大图getImageForSomeTime
,这个方法会耗费一些时间,我们在viewDidLoad中初始化一些UI代码,然后调用getImageForSomeTime获取渲染好的图片,最后加载到页面上.代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
self.photoImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 44, self.view.bounds.size.width, 400)];
self.photoImageView.backgroundColor = [UIColor colorWithWhite:0.5 alpha:1.0];
[self.view addSubview:self.photoImageView];
UIImage *image = [self getImageForSomeTime:4.0f];
self.photoImageView.image = image;
}
当我们启动项目,进入到这个页面的时候花了很长时间,能感到明显的滞后,在重载 UIViewController 的 viewDidLoad 时容易加入太多杂乱的工作,这通常会引起视图控制器出现前更长的等待。如果可能,最好是卸下一些工作放到后台执行,如果它们不是必须要在当前线程中加载。
用下面的实现替换 viewDidLoad :
- (void)viewDidLoad {
[super viewDidLoad];
self.photoImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 44, self.view.bounds.size.width, 400)];
self.photoImageView.backgroundColor = [UIColor colorWithWhite:0.5 alpha:1.0];
[self.view addSubview:self.photoImageView];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // 1
UIImage *image = [self getImageForSomeTime:4.0f];
dispatch_async(dispatch_get_main_queue(), ^{ // 2
self.photoImageView.image = image;//3
});
});
}
下面来说明上面的新代码所做的事:
1.主线程执行到: viewDidLoad
2.实例化了一个UIImageView并加载到self.view上
3.主线程目前在 viewDidLoad 内
,正要到达 dispatch_async
4.dispatch_async 将工作从主线程移到全局线程,Block 会被异步地提交,并在稍后执行。
5.viewDidLoad
在添加dispatch_async
到全局队列后回到主线程继续进行,把注意力转向剩下的任务。同时,全局并发队列正在处理异步提交的Block。记住 Block 在全局队列中将按照FIFO
顺序出列,但可以并发执行。
6.这就使得 viewDidLoad 更早地在主线程完成,主线程中其他任务的执行时间也得以提前,这就让加载过程感觉起来更加快速。
7.接下来,图片渲染过程完成,并生成了一个新的图像。既然你要使用此新图像更新你的 UIImageView ,那么你就添加一个新的 Block 到主线程。记住——你必须总是在主线程访问 UIKit 的类。
8.最后,调用 self.photoImageView.image = image回主线程更新 UI 。
编译并运行应用,发现视图控制器加载明显变快,除图片加载外的任务可以被更早的执行。这给应用带来了不错的效果,和之前的显示差别巨大。
二、让你的单例线程安全:
对于单例,一直有一个担忧,那就是他的线程安全问题,这个担忧十分合理,因为单例常常被多个控制器同时访问。
单例的线程安全问题来自两个方面:
1.单例的初始化过程:
2.属性的读写过程:
单例的实例化代码如下: 这是一个线程不安全的单例
+ (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
if (!sharedPhotoManager) {
sharedPhotoManager = [[PhotoManager alloc] init];
sharedPhotoManager->_photosArray = [NSMutableArray array];
}
return sharedPhotoManager;
}
当前状态下,代码相当简单;你创建了一个单例并初始化一个叫做 photosArray
的 NSMutableArray
属性。
然而,if
条件分支不是线程安全的;如果我们在多个线程同时调用这个方法,有一个可能性是在某个线程(线程1)上进入 if
语句块并且在 sharedPhotoManager
被分配内存前发生一个上下文切换;然后另一个线程(线程2)也进入 if
条件分支,此时(!sharedPhotoManager)
为真,那么系统会为此单例实例分配内存,然后退出。
接着,系统上下文切换回线程1,系统会分配另外一个单例实例的内存,然后退出。在那个时间点,你有了两个单例的实例——很明显这不是你想要的.
当然,发生这样的事情概率很低,因为满足这样的条件必须是上下文切换刚好发生在if分支
语句处,但,这毕竟是一个风险.
在上面的代码中用 NSThread 的 sleepForTimeInterval: 类方法来强制发生一个上下文切换。
+ (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
if (!sharedPhotoManager) {
[NSThread sleepForTimeInterval:2];
sharedPhotoManager = [[PhotoManager alloc] init];
NSLog(@"sharedManager address: %p", sharedPhotoManager);
[NSThread sleepForTimeInterval:2];
sharedPhotoManager->_photosArray = [NSMutableArray array];
}
return sharedPhotoManager;
}
然后在控制器中,创建两个异步并发调用来实例化PhotoManager单例,引发上面描述的竞态条件。
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
[PhotoManager sharedManager];
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
[PhotoManager sharedManager];
});
编译并运行项目;查看控制台输出,你会看到多个单例被实例化,如下所示:
屏幕快照 2019-05-17 下午4.13.01.png
注意到这里有好几行显示着不同地址的单例实例。这明显违背了单例的目的,对吧?
这个输出向你展示了临界区被执行多次,而它只应该执行一次。现在,固然是你自己强制这样的状况发生,但你可以想像一下这个状况会怎样在无意间发生。
要纠正这个状况,实例化代码应该只执行一次,并阻塞其它实例在 if 条件的临界区运行。这刚好就是 dispatch_once 能做的事。
在单例初始化方法中用 dispatch_once 取代 if 条件判断,如下所示:
+ (instancetype)sharedManager
{
static PhotoManager *sharedPhotoManager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[NSThread sleepForTimeInterval:2];
sharedPhotoManager = [[PhotoManager alloc] init];
NSLog(@"sharedManager address: %p", sharedPhotoManager);
[NSThread sleepForTimeInterval:2];
sharedPhotoManager->_photosArray = [NSMutableArray array];
});
return sharedPhotoManager;
}
编译并运行应用;查看控制台输出,你会看到有且仅有一个单例的实例——这就是你对单例的期望!
dispatch_once() 以线程安全的方式执行且仅执行其代码块一次。试图访问临界区(即传递给 dispatch_once 的代码)的不同的线程会在临界区已有一个线程的情况下被阻塞,直到临界区完成为止。
需要记住的是,这只是让单例的实例线程安全。它绝对没有让类本身线程安全。类中可能还有其它竞态条件,例如任何操纵内部数据的情况。这些需要用其它方式来保证线程安全,例如上面提到的信息读写过程:.
三、属性读写过程的线程安全:
实例化时的线程安全不是处理单例时的唯一问题。如果单例的属性表示一个可变对象,那么你就需要考虑是否那个对象自身线程安全。
如果问题中的这个对象是一个 Foundation 容器类,那么答案是——“很可能不安全”!众多的 Foundation 类都不是线程安全的。 NSMutableArray就是其中一个:
要分析这个问题,看看 PhotoManager.m 中的 addPhoto:方法
- (void)addPhoto:(UIImage *)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 在那个特定时间上是指定队列上唯一被执行的条目。这就意味着所有的先于障碍(barrier)提交到队列的条目必能在这个 Block 执行前完成,所有后于障碍(barrier)提交到队列的条目在这个Block后面完成。
当这个 Block 的时机到达,调度障碍执行这个 Block 并确保在那个时间里队列不会执行任何其它 Block 。一旦完成,队列就返回到它默认的实现状态。 GCD 提供了同步和异步两种障碍函数。
下图显示了障碍函数对多个异步队列的影响:
Dispatch barriers.png注意到正常部分的操作就如同一个正常的并发队列。但当障碍执行时,它本质上就如同一个串行队列。也就是,障碍是唯一在执行的事物。在障碍完成后,队列回到一个正常并发队列的样子。
Dispatch barriers的使用指导:
- 自定义串行队列:一个很坏的选择;障碍不会有任何帮助,因为不管怎样,一个串行队列一次都只执行一个操作。
- 全局并发队列:要小心;这可能不是最好的主意,因为其它系统可能在使用队列而且你不能垄断它们只为你自己的目的。
- 自定义并发队列:这对于原子或临界区代码来说是极佳的选择。任何你在设置或实例化的需要线程安全的事物都是使用障碍的最佳候选。
由于上面唯一像样的选择是自定义并发队列,你将创建一个你自己的队列去处理你的障碍函数并分开读和写函数。且这个并发队列将允许多个多操作同时进行。
打开 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) { // 1
dispatch_barrier_async(self.concurrentPhotoQueue, ^{ // 2
[_photosArray addObject:photo]; // 3
dispatch_async(dispatch_get_main_queue(), ^{ // 4
[self postContentAddedNotification];
});
});
}
}
你新写的函数是这样工作的:
1.在执行下面所有的工作前检查是否有合法的相片。
2.添加写操作到你的自定义队列。当临界区在稍后执行时,这将是你队列中唯一执行的条目。
3.这是添加对象到数组的实际代码。由于它是一个障碍 Block ,这个 Block 永远不会同时和其它 Block 一起在 concurrentPhotoQueue 中执行。
4.最后你发送一个通知说明完成了添加图片。这个通知将在主线程被发送因为它将会做一些 UI 工作,所以在此为了通知,你异步地调度另一个任务到主线程。
这就处理了写操作,但你还需要实现 photos 读方法并实例化 concurrentPhotoQueue 。
在写者打扰的情况下,要确保线程安全,你需要在 concurrentPhotoQueue 队列上执行读操作。既然你需要从函数返回,你就不能异步调度到队列,因为那样在读者函数返回之前不一定运行。
在这种情况下,dispatch_sync 就是一个绝好的候选。
dispatch_sync() 同步地提交工作并在返回前等待它完成。使用 dispatch_sync 跟踪你的调度障碍工作,或者当你需要等待操作完成后才能使用 Block 处理过的数据。如果你使用第二种情况做事,你将不时看到一个 __block 变量写在 dispatch_sync 范围之外,以便返回时在 dispatch_sync 使用处理过的对象。
但你需要很小心。想像如果你调用 dispatch_sync 并放在你已运行着的当前队列。这会导致死锁,因为调用会一直等待直到 Block 完成,但 Block 不能完成(它甚至不会开始!),直到当前已经存在的任务完成,而当前任务无法完成!这将迫使你自觉于你正从哪个队列调用——以及你正在传递进入哪个队列。
继续在 PhotoManager.m 上工作,用下面的实现替换 photos :
- (NSArray *)photos
{
__block NSArray *array; // 1
dispatch_sync(self.concurrentPhotoQueue, ^{ // 2
array = [NSArray arrayWithArray:_photosArray]; // 3
});
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.singleton.photoQueue",DISPATCH_QUEUE_CONCURRENT);
});
return sharedPhotoManager;
}
恭喜——你的 PhotoManager
单例现在是线程安全的了。不论你在何处或怎样读或写你的照片,你都有这样的自信,即它将以安全的方式完成,不会出现任何惊吓。
参考@nixzhu在Github上的译文,
https://github.com/nixzhu/dev-blog/blob/master/2014-04-19-grand-central-dispatch-in-depth-part-1.md
原作者:Derek Selander
原文地:[http://www.raywenderlich.com/60749/grand-central-dispatch-in-depth-part-1]