【Objective-C】GCD介绍

整理自raywenderlich。

1.GCD是嘛?

GCD是Grand Central Dispatch的缩写,是苹果对多核硬件上执行并发代码的一种支持。
它有以下优点:

  • GCD通过把计算密集型任务放于后台运行,以此提高APP的响应速度。
  • GCD提供了更简单的并发模型,它优于线程锁,并且帮助你避免并发bug。
  • GCD基于底层、高性能的优化常规类型的代码,例如单例。

2.GCD相关术语

串行和并发(Serial vs. Concurrent)

串行和并发描述了任务之间执行的时机。任务如果是串行的,那么在同一时间只执行一个任务。并发的多个任务被执行的时候,可能是在同一时间。(觉得有点绕?没关系,后面有美图-_-`)。

同步和异步(Synchronous vs. Asynchronous)

同步和异步描述了一个函数相对于另一个函数何时执行完毕。

同步的函数只有当它调用的任务执行完,才会返回。

而异步函数,会立即返回。虽然它也命令任务执行完,但它并不等待任务执行完。如此,异步函数就不会阻塞当前线程。

危险区(Critical Section)
它是指一段代码一定不能被并发执行,也就是说,它不能同时在两个线程里。这是因为代码在并发操作共享资源(例如,NSMutableArray)时,该资源可能会损坏。

竞态条件(Race Condition)

它指这样一种情形:在软件系统中,指定序列的执行行为或者事件的执行时间,没有被有效规范(例如,并发任务中的执行命令)。竞态条件会产生一些不可预测的行为,这些行为通过代码检查是很难发现的。

死锁(Deadlock)

两个或多个线程满足以下情况,就被称为死锁:它们陷入了相互等待对方完成或执行一个动作。第一个无法完成是因为它在等待第二个完成。而第二个无法完成是因为它在等待第一个完成。

线程安全(Thread Safe)

线程安全的代码可以被多线程或者说并发任务安全调用,而不引起任何问题(数据损坏、崩溃等等)。线程不安全的代码在同一时间,只能被同一环境调用。一个线程安全的例子就是NSDictionary。你可以在多线程中调用,没有任何问题。相对来说,NSMutableDictionary就是线程不安全的,它在同一时间只能被一个线程调用。

环境切换(Context Switch)

当你在不同的线程中切换执行的时候,对执行状态的储存和恢复的处理,称为环境切换。当你写多任务APP时,这种处理相当普遍,但是要付出一些额外的成本。

3.并发执行和平行执行(Concurrency vs Parallelism)

并发执行和平行执行经常相提并论,所以值得我们简单的区分一下。

并发执行的每一部分是被"同时执行"的。事实上,这种"同时"取决于并发执行时的系统。

多核设备执行多线程利用的是平行执行,但是,为了单核设备也能实现并发,它需要执行一会儿一个线程(A线程),然后环境切换,再去执行另一个线程或进程(B线程),然后再回来执行前一个线程(A线程)。它切换的很快,以至于给了你平行执行的错觉,就像下图展示的:

ConcurrencyVSParallelism

尽管你在GCD下,写了并发执行的代码,但是,是由GCD来决定平行执行是否是必要的。平行执行要求并发执行,但是并发执行并不能保证是平行执行。

4.队列(Queues)

GCD提供dispatch queues来操作代码块。这些队列管理你提交给GCD的任务,并且按照FIFO(first input first output ,先入先出)顺序执行。这保证了队列中的第一个任务第一个被执行,第二个任务第二个被执行,以此类推。

所有的dispatch queues 自身都是线程安全的,所以你可以在多线程中使用它们。当你理解了dispatch queues为你提供了线程安全的代码后,你就能理解GCD的伟大了。理解的关键在于你要选对dispatch queue,并且提交给queue合适的函数。

串行队列(Serial Queues)

串行队列同一时间只执行一个任务,只有当前一个任务执行完,下一个任务才开始。不过,我们无法知道一个任务结束,到下一个任务开始之间所需的时间,就像下图所示:

SerialQueues

这些任务开始执行的时间是由GCD控制的,你只能肯定GCD在同一时间只执行一个任务,而且执行顺序是当初它们被加到队列中的顺序。

因为在串行队列中,不可能并发的处理两个任务,所以没有进入到危险区(critical section)的风险,从而保证危险区不可能进入竞态条件。

并发队列(Concurrent Queues)

并发队列保证任务是按照加入时的顺序开始的,仅此而已,你再不能从并发队列中得到其他保证了。各个任务执行完的顺序是不可知的,下一个代码块何时开始执行是不可知的,特定时间里,有多少个代码块在运行也是不可知的。这些还是由GCD控制的。

下图展示了在GCD下,四个并发任务执行的例子:

ConcurrentQueues

从图中发现,代码块1,2,3几乎同时开始,但依然是一个跟着一个。代码块0开始了一段时间后,代码块1才开始。代码块3在代码块2之后开始,却先于代码块2结束。

一个代码块何时开始执行,完全取决于GCD。如果一个代码块的执行时间与另一个重叠了,那也是由GCD来决定是否它应该运行在另一个处理器核心,是一个核心就够用了,还是要用环境切换来处理不同的代码块。

队列类型(Queue Types)

仅仅为了让事情更有趣(大牛!这么说话不怕闪着腰吗?……)GCD提供了至少五中队列类型来选择。

首先,系统提供了一个特殊的串行队列——main queue。像其他串行队列一样,该队列在同一时间只能执行一个任务。然而,它是唯一一个允许你更新UI的队列。它就是那个你用来发送信息给UIView或者发送通知的队列。

系统当然也提供了几个并发队列。它们被命名为Global Dispatch Queues。它们因不同的优先级被分为四种:background,low,default,high。要知道苹果的API也在用这些队列,所以,这些队列中不会仅仅有你的任务。

最终,你还可以定制自己的串行或并发队列。这意味着你最少有五种队列可以派遣:main queue(串行),4种global dispatch queues(并发),你定制的队列

dispatch_async

当你需要进行网络操作或者计算密集型任务时,应考虑用dispatch_async,使任务在后台执行,从而不阻塞当前线程。

在各种队列中应该怎么用dispatch_async,以下是一些建议:

  • 定制的串行队列(Custom Serial Queue):当你想在后台执行串行队列时,dispatch_async是个不错的选择。因为串行在同一时间只能执行一个任务,所以它会消除资源之间的联系。要注意,如果你需要从方法中返回的数据,你必须在串行中添加另一个代码块来恢复数据或者考虑用dispatch_sync
  • 主队列(Main Queue):在并发队列中执行完任务,我们通常会在主队列中刷新UI。就像这样:
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ 
        UIImage *overlayImage = [self faceOverlayImageFromImage:_image];
        dispatch_async(dispatch_get_main_queue(), ^{ 
            [self fadeInNewImage:overlayImage]; 
        });
    });
  • 并发队列(Concurrent Queue):在后台执行非UI的操作,dispatch_async是很自然的选择。

dispatch_after

dispatch_after就像一个延迟执行的dispatch_async。

什么样的队列适合使用呢?

  • 定制的串行队列(Custom Serial Queue):不建议,延迟操作没什么意义。
  • 主队列(Main Queue):这是最好的选择。就像这样:
    double delayInSeconds = 1.0;
    dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC)); // 1 
    dispatch_after(popTime, dispatch_get_main_queue(), ^(void){ // 2 
        if (!count) {
            [self.navigationItem setPrompt:@"Add photos with faces to Googlyify them!"];
        } else {
            [self.navigationItem setPrompt:nil];
        }
    });
  • 并发队列(Concurrent Queue):没必要。

单例的线程安全

常见的单例实现如下:

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

这段代码非常简单,我们创建了一个单例,并且实例化了一个私有的可变数组photosArray

然而,if语句里的代码并不是线程安全的。如果连续多次调用此方法,有这样一种可能,线程A进入if语句,在单例创建完成前,发生环境切换( context switch),转到线程B,线程B也会进入if语句并实例化此单例,然后系统再次环境切换到线程A,线程A会继续执行if语句后面的内容,实例化另一个单例。这显然就不是单例了。

if语句里的代码就是危险区(Critical Section),当连续执行:

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

它会创建多个不同的“单例”,这两个并发队列存在竞态条件(Race Condition)。

所以,dispatch_once就登场了!

dispatch_once用一种线程安全的方式,执行且只执行一次代码块。当一个线程已经在执行dispatch_once中的危险区(critical section),那另一个试图进入该代码块的线程将被阻止,直到前一个线程执行完毕危险区中的代码。代码如下:

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

需要指出的是,这仅仅使进入单例变得线程安全,并没有使这个类完全线程安全。如果单例中的属性是一个可变对象(如NSMutableArray),那你要考虑这个对象本身是否线程安全。

如果对象是一个容器类(array,dictionary),那么很可能它是线程不安全的。苹果提供了一个线程是否安全的清单。可以发现,NSMutableArray,正是线程不安全的。

我们可能会这样用_photosArray属性:

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

以上是一个write方法,因为它改变了私有可变数组对象。

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

以上是一个read方法,因为它读取了这个可变数组。它做了一份不可变的拷贝(返回了一个包含同样对象的不可变数组),以防在调用时,不适当的操作改变了这个可变数组。然而当一个线程调用write方法,另一个线程调用read方法时,这里并没有任何的保护。

尽管很多线程同时读取NSMutableArray的实例不会有问题,但是,当一个
线程在改变这个数组,而另外一个读取的时候,就不尽然了。上面的方法并没有防止这种可能。

这是软件开发中典型的读写难题(Readers-Writers Problem)。GCD提供了一种优雅的解决方案——通过dispatch barriers创建读写锁(Readers-writer lock)。

dispatch barriers是一组函数,当它与并发队列一起用时,它就像一个串行瓶颈。用barriers的API能确保提交的代码块是在特定时间指定队列中唯一被执行的。这意味着先前提交到此队列中的代码块,要在dispatch barrier执行前,执行完毕。如下图所示:

DispatchBarriers

注意上图,在barrier执行前,这个并发队列就像一般的并发队列那样执行。但是,在barrier执行后,这个并发队列看起来像个串行队列了(所谓的串行瓶颈)。即,barrier是唯一一个被执行的代码块。在barrier执行完后,这个队列又开始像一般的并发队列一样。

GCD提供了既有同步,也有异步的barrier函数。

在用barrier函数是应注意什么时候能用,而什么时候不能:

  • 定制的串行队列(Custom Serial Queue):相当不建议。本身已经串行了,还用barrier干啥?
  • 系统提供的并发队列(Global Concurrent Queue):要很谨慎的用。因为系统的并发队列中不仅仅运行你指定的代码块,当你用barrier后,你会在barrier执行时,垄断这个线程,这可能会带来不必要的麻烦。
  • 定制的并发队列(Custom Concurrent Queue):这是最好的选择。

因为定制的并发队列是我们最佳的选择,所以,我们最好自己创建一个并发队列,来操作barrier函数,实现分离读写操作。

@interface PhotoManager ()
@property (nonatomic,strong,readonly) NSMutableArray *photosArray;
@property (nonatomic, strong) dispatch_queue_t concurrentPhotoQueue; ///< 一个并发队列属性
@end

+ (instancetype)sharedManager
{
    static PhotoManager *sharedPhotoManager = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedPhotoManager = [[PhotoManager alloc] init];
        sharedPhotoManager->_photosArray = [NSMutableArray array];
 
        // 初始化并发队列:其中我们习惯性的用倒转的DNS命名作为第一个参数,在我们调试的时候这会很有帮助;第二个参数来指定我们创建串行还是并发队列
        sharedPhotoManager->_concurrentPhotoQueue = dispatch_queue_create("com.selander.GooglyPuff.photoQueue",
                                                    DISPATCH_QUEUE_CONCURRENT); 
    });
 
    return sharedPhotoManager;
}

在write中加入barrier:

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

为了write的线程安全,我们需要将read也写在刚才那个并发队列中(真心不知道为什么read方法也要写在这个线程……)。我们需要从函数中得到返回值,所以我们不能用异步。

这一次,我们可以用dispatch_sync

用dispatch_sync一定要小心,否则很有可能造成死锁(deadlock)。如果你把现在正在运行的队列(串行)作为dispatch_sync的目标队列,那就会死锁。dispatch_sync在等待block语句执行完,但是block不可能执行完(它根本就还没开始呢),除非dispatch_sync这条调用的语句执行完,而这是不可能的。

所以,你应该了解什么时候、在哪里,用dispatch_sync:

  • 定制的串行队列(Custom Serial Queue)在这个队列中,你得非常小心。如果你在这个队列中,并且调用dispatch_sync,目标是这个队列,那么你就造成了一个死锁(deadlock)。
  • 主队列(Main Queue):就像在定制的串行队列中所说的那样,也会造成死锁。
  • 并发队列(Concurrent Queue):无论是用dispatch barriers或者dispatch——sync,这是一个好选择。

代码如下:

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

Dispatch Groups

如果你想确保一组任务完成,再执行某些任务,那么Dispatch Groups可以派上用场。Dispatch Groups里的任务可以是异步的,也可以是同步的,并且这些无论同步还是异步的任务,可以来自不同的队列。当Dispatch Groups里的任务执行完后,它可以以异步或者同步的方式通知你。

我们需要创建一个dispatch_group_t的实例来追踪这些在不同队列中的任务。

我们先看其同步的方式——dispatch_group_wait。dispatch_group_wait会阻塞当前的线程,直到组中每个任务都完成或者超时。

我们以下面的代码作为例子来讲解:

- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
  //由于我们用了同步的方式dispatch_group_wait,它会阻塞当前线程,所以我们在整个方法外面套上了dispatch_async,使它在后台执行而不会阻塞主线程。
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ 
 
        __block NSError *error;
        //我们创建了一个dispatch group,这就像一个未完成任务的计数器。
        dispatch_group_t downloadGroup = dispatch_group_create(); 
 
        for (NSInteger i = 0; i < 3; i++) {
            NSURL *url;
            switch (i) {
                case 0:
                    url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
                    break;
                case 1:
                    url = [NSURL URLWithString:kSuccessKidURLString];
                    break;
                case 2:
                    url = [NSURL URLWithString:kLotsOfFacesURLString];
                    break;
                default:
                    break;
            }
            //dispatch_group_enter告知group,一个任务开始了。我们需要将dispatch_group_enter与dispatch_group_leave配对,否则我们会遇到莫名其妙的崩溃。
            dispatch_group_enter(downloadGroup); 
            Photo *photo = [[Photo alloc] initwithURL:url
                                  withCompletionBlock:^(UIImage *image, NSError *_error) {
                                      if (_error) {
                                          error = _error;
                                      }
 //dispatch_group_leave告知group,此任务执行完毕,要注意与dispatch_group_enter配对。
                                   dispatch_group_leave(downloadGroup); 
                                  }];
 
            [[PhotoManager sharedManager] addPhoto:photo];
        }
        //dispatch_group_wait等待所有任务完成或者超时,在此处我们设置等待时间为永远DISPATCH_TIME_FOREVER
        dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER); 
        //当以上所有任务执行完后,我们在主队列调用任务完成的block。
        dispatch_async(dispatch_get_main_queue(), ^{ 
            if (completionBlock) { 
                completionBlock(error);
            }
        });
    });
}

毕竟,用同步阻塞线程,感觉会不爽,我们换异步的方式。

dispatch_group_notify提供了异步完成的block。当group中没有其他任务时,它会执行block。当然,我们需要为block指定完成的队列。在这个例子中,我们把主队列作为参数。代码如下:

   //用dispatch_group_notify,就不需要在方法外套着dispatch_async
    __block NSError *error;
    dispatch_group_t downloadGroup = dispatch_group_create(); 
 
    for (NSInteger i = 0; i < 3; i++) {
        NSURL *url;
        switch (i) {
            case 0:
                url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
                break;
            case 1:
                url = [NSURL URLWithString:kSuccessKidURLString];
                break;
            case 2:
                url = [NSURL URLWithString:kLotsOfFacesURLString];
                break;
            default:
                break;
        }
 
        dispatch_group_enter(downloadGroup);
        Photo *photo = [[Photo alloc] initwithURL:url
                              withCompletionBlock:^(UIImage *image, NSError *_error) {
                                  if (_error) {
                                      error = _error;
                                  }
                                  dispatch_group_leave(downloadGroup); 
                              }];
 
        [[PhotoManager sharedManager] addPhoto:photo];
    }
 //我们异步调用了group,并且在group执行完后,返回主队列,执行完成后的代码。
    dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ 
        if (completionBlock) {
            completionBlock(error);
        }
    });

dispatch_apply

dispatch_apply很像一个for循环,但是它并发的执行每一项。不过,dispatch_apply本身像for循环一样,是串行的。当所有工作结束,dispatch_apply返回。代码如下:

    __block NSError *error;
    dispatch_group_t downloadGroup = dispatch_group_create();
 //第一个参数是循环次数,第二个参数也可以是串行,但是那就没有意义了,还不如用for循环,第三个参数是增量变量
    dispatch_apply(3, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^(size_t i) {
 
        NSURL *url;
        switch (i) {
            case 0:
                url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
                break;
            case 1:
                url = [NSURL URLWithString:kSuccessKidURLString];
                break;
            case 2:
                url = [NSURL URLWithString:kLotsOfFacesURLString];
                break;
            default:
                break;
        }
 
        dispatch_group_enter(downloadGroup);
        Photo *photo = [[Photo alloc] initwithURL:url
                              withCompletionBlock:^(UIImage *image, NSError *_error) {
                                  if (_error) {
                                      error = _error;
                                  }
                                  dispatch_group_leave(downloadGroup);
                              }];
 
        [[PhotoManager sharedManager] addPhoto:photo];
    });
 
    dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{
        if (completionBlock) {
            completionBlock(error);
        }
    });

这样,循环变成并发的了。由于是并发的,循环的每一项的完成时机变成不可知的(for循环的时候是依次执行,所以是顺序是可知的)。

用dispatch_apply替代for是否值得呢?

在以上代码中,用dispatch_apply并没有很大的优势,反而让队列中跑了
很多线程,我们应该在处理耗时较长的循环时,使用dispatch_apply。

啊哈,已经写了很长了,GCD很丰富,还有很多有趣的特性,例如:semaphores、Dispatch Sources等等,这篇算个开篇介绍吧,高阶一点的以后再写,敬请期待。

你可能感兴趣的:(【Objective-C】GCD介绍)