单例的多线程安全

来源:互联网 发布:如何远离网络暴力 编辑:程序博客网 时间:2024/05/21 04:39

在GIT上这篇文章讲到单例的线程安全:https://github.com/nixzhu/dev-blog/blob/master/2014-04-19-grand-central-dispatch-in-depth-part-1.md

GCD使用经验,CocoaChina:http://www.cocoachina.com/ios/20150505/11751.html

一个单例如下:

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


当两个或者多个线程同时访问这个单例时,可能获得两个不同的单例,内存地址不同,这个就违反了创建单例的想达到的目的。

    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];
    });

单例方法里面让线程休眠2秒来达到测试的效果,NSLog出来是两个不同的内存地址。


可以使用dispatch_once来进行读取优化,保证只调用API一次,以后就只要直接访问变量:

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

    return sharedPhotoManager;
}


修改后,打印出来都是同一个单例,内存地址相同,这个单例就是线程安全的了。

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

Highlander_dispatch_once

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


还有一些关于可变对象的线程安全,一时消化不完……


处理读者与写者问题

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

如果问题中的这个对象是一个 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 的时机到达,调度障碍执行这个 Block 并确保在那个时间里队列不会执行任何其它 Block 。一旦完成,队列就返回到它默认的实现状态。 GCD 提供了同步和异步两种障碍函数。

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

Dispatch-Barrier

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

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

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

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

打开 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 不能完成(它甚至不会开始!),直到当前已经存在的任务完成,而当前任务无法完成!这将迫使你自觉于你正从哪个队列调用——以及你正在传递进入哪个队列。

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

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

继续在 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.selander.GooglyPuff.photoQueue",                                                    DISPATCH_QUEUE_CONCURRENT);     });    return sharedPhotoManager;}

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

注意:当你在网上搜索例子时,你会经常看人们传递 0 或者 NULLdispatch_queue_create 的第二个参数。这是一个创建串行队列的过时方式;明确你的参数总是更好。

恭喜——你的 PhotoManager 单例现在是线程安全的了。不论你在何处或怎样读或写你的照片,你都有这样的自信,即它将以安全的方式完成,不会出现任何惊吓。


0 0