2

什么是 GCD

GCD 存在于一个 名为 libdispatch 的类库中,这个苹果官方的类库提供了在 iOS 和 OS X 多核设备执行并发代码的支持。

GCD 的优势

GCD 可以通过延迟可能需要花费大量时间的任务,并让他们在后台(background)运行,从而提高应用的响应速度。

相对于线程和锁来说,GCD 提供了一个更加易用的模板,从而避免发生并发问题(concurrency bug)。
对于类似单例(singletons)模式,GCD可以用来优化我们的代码。

GCD 相关术语

1. 串行 和 并行 (Serial VS Concurrent)

串行和并行描述的是任务之间如何运行。
串行任务每一次仅执行一个
并行任务可以多个同时执行

2. 同步 和 异步 (Synchronous VS Asynchronous)

同步方法仅会在一个任务完成后返回。
异步方法会立即返回,它会让这个任务执行完成,但不会等待任务完成。因此,异步方法不会阻塞当前线程。

3. Critical Section

指的是不能并发执行的一段代码(不能被两个线程同时访问)。这么做通常是因为一个共享数据(shared resource),例如一个变量,被并发进程访问后,进程之间可能都会对这个变量产生影响,可能会产生数据污染。

4. Race Condition(竞争情形)

当两个(多个)线程同时访问共享的数据时,会发生争用情形。第一个线程读取了一个变量。第二个线程也读取了这个变量的值。然后这两个线程同时操作了该变量,此时他们会发生竞争来看哪个线程会最后写入这个变量。最后被写入的值将会被保留下来。

5. 死锁

两个(多个)线程都要等待对方完成某个操作才能进行下一步,这时会发生死锁。例如,两个线程各自锁定了一个变量,然后他们想要锁定对方锁定的那个变量,这就产生了死锁。

6. 线程安全

一段线程安全的代码(一个线程安全的对象),可以同时被多个线程或并发的任务调用,不会产生问题。非线程安全的只能按次序被访问(调用)。举例来讲,NSDictionary 是线程安全的,NSMutableDictionary 是非线程安全的。

注意:所有的 Mutable 对象都是非线程安全的,所有的 Immutable 对象都是线程安全的。根本原因是,Mutable 对象是可以被修改的,Immutable 对象时不能被修改的。使用 Mutable 对象要注意,一定要用同步锁来同步访问(@synchronized)。

互斥锁的优点:能够防止多线程抢夺造成的数据安全的问题。
缺点:实现互斥锁会消耗大量的资源。

同时,还有原子属性(atomic)也可以实现加锁。

  • atomic:原子属性,为 setter 方法加锁

  • nonatomic:非原子属性,不会为 setter 加锁

注意:所有属性都声明为 nonatomic, 客户端应尽量避免多线程争夺同一资源。

7. context switch

当一个进程(process)中有多个线程(threads)来回切换时,context switch 用来记录执行状态。这样的进程和一般的多线程进程没有太大差别,但会产生一些额外的开销。

8. 并发 VS 并行(Parallelism)

并行是基于多核设备的,并行 一定是并发,但并发不一定是并行。

9. 队列(Queues)

GCD 提供了 dispatch queue 用来处理代码块,这些队列会管理你提供给GCD 的 tasks 然后按先进先出的顺序执行这些 tasks。

所有的 dispatch queue 都是线程安全的,我们可以使用多个线程同时访问一个 queue。当理解了 dispatch queue 如何为我们的代码提供线程安全,GCD 的优点就很容易理解了。最关键的是要选择正确的 dispatch queue 和 dispatching function 来注册我们的代码块。

10. 串行队列(serial queue)

串行队列中的任务(Tasks in serial queue),每次仅执行一个。执行顺序是,每个任务仅会当上一个正在执行的任务结束后才开始执行。我们无法知道一个任务结束时到下一个任务开始的时间。

这些任务的执行时间是由 GCD 控制的,GCD 能够保证的是,每次仅执行一个任务,这些任务的执行顺序是他们被添加进队列 (queue)的顺序。

11. 并发队列(concurrent queue)

并发队列中的任务会按照我们添加任务的顺序来执行,但不保证完成了一个之后才开始下一个任务。这些任务可能以任何顺序结束,我们也无法知道距下个任务(block)开始需要的时间,也不知道在某个时间段有多少个任务在执行。这些全部交由 GCD 控制。

什么时候开始执行一个任务(block)完全由 GCD 控制。如果两个任务的执行时间有重叠,将有 GCD 来决定是否要将一种一个任务交给另一个空闲状态的核心处理,还是使用一个 context switch 来在两个 block 之间切换执行。

GCD 至少提供了5 种不同的 queues 。

12. 队列类型(queue types)

(1) 首先,系统为我们提供了一个特别的串行队列—— main queue。和其他串行队列一样,main queue 中的任务也是每次仅执行一个。main queue 保证了队列中所有的任务都会在主线程中进行。这个队列就是用来给 UIView 发送消息,或者发送一条通知。
(2) 系统也提供了一些并发队列(concurrent queue),我们成为 Global Dispatch Queues。目前有四种全局队列,他们分别有不同的优先级:background, low, default, high。需要注意的时,苹果的API也会使用这些队列,所以这些队列不会被我们添加的任务独占。
(3) 我们也可以创建自己的自定义串行队列或者并发队列。这意味着失少有五个(种)队列可以由我们使用:main queue, 四种 global dispatch queue, 还有我们自定义的队列。

注意:GCD 的重点在于,我们要选择正确的队列调度功能(queue dispatching function),来把我们的任务提交到队列中。

使用 dispatch_async 处理后台任务

原始代码:

UIImage *overlayImage = [self faceOverlayImageFromImage:_image];
[self fadeInNewImage:overlayImage];

修改为:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // 1
    UIImage *overlayImage = [self faceOverlayImageFromImage:_image];
        dispatch_async(dispatch_get_main_queue(), ^{ // 2
            [self fadeInNewImage:overlayImage]; // 3
        });
    });
}
  • 注释1:首先我们将主线程的任务移到了一个全局队列中。因为这里是 dispatch_async,这意味着这个代码块(block)被异步提交(submitted)。这样就会使得 viewDidload 更快执行完成。面部识别的代码就会稍晚被执行完(面部识别的代码因为由GCD控制,我们无得知到底什么时候能够执行完成)。

  • 注释2:此时人脸识别所在的 block 已经执行完成,我们生成了一个新的 UIImage。因为我们需要用得到的 UIImage 来更新UIImageView,所以我们在主线程上提交了一个 block。
    注意:所有UIKit 相关的操作都应该在主线程上执行。

  • 注释3:更新 UI

dispatch_async 中不同队列的使用场景

  1. 自定义串联队列(custom serial queue)
    当你希望在后台按顺序执行一些任务,并且追踪这些任务。这种方式消除了资源争夺现象。

  2. 主队列(Main Queue)
    当我们完成了一个并发队列中的某个工作后,需要更新 UI,我们通常使用 main queue。实现main queue 需要将一个 block 写在 另一个 block 中。还有,如果我们已经在主队列(main queue)中,然后还调用 dispatch_async 指向main queue,我们就会得到保证——新的 task 在当前方法完成后一定会被执行。

  3. 并发队列
    通常非UI展示/更新的代码,都用并发队列。

使用 dispatch_after 实现延迟

下面代码的目的是,在viewDidAppear 后,根据情况为用户显示一个提示(为了引起用户的注意,这个提示延迟 1s出现)

-(void)showOrHideNavPrompt
{
    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){ // 2 
        if (!count) {
            [self.navigationItem setPrompt:@"Add photos with faces to Googlyify them!"];
        } else {
            [self.navigationItem setPrompt:nil];
        }
    });
}
  • 注释1:我们声明了需要被 delay 的时间。

  • 注释2:我们在等待了 delay 的这段时间后,异步的将这个block 添加到主线程中。

dispatchafter 其实相当于一个延时的 dispatchasync。同样我们仍然不能控制 block 中执行的时间,当 dispatch_after 已经返回,我们也无法取消执行 block 中的任务。

dispatch_after 的使用场景:

  1. Custom Serial Queue: 要小心将 dispatch_after 使用在一个 custom serial queue 上。一般还是用在 main queue 上。

  2. Main queue :通常 dispatchafter 都用在 mainqueue 上

  3. Concurrent queue : 也要小心使用!

让我们自己的单例实现线程安全(thread-safe)

关于单例,我们通常都会关心——单例不是线程安全的。基于单例的使用情况,这个关心是很合理的:单例的 instance 经常被多个 ViewController 同时访问。

接下来,我们在一个单例(singleton instance)自己制造一个竞争情况(race condition)。

原本代码如下:

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

上面的代码非常简单,创建并初始化了一个单例。

需要注意的是,上面的 if 语句不是线程安全的。如果我们多次调用这个方法,有可能某一个线(Thread A)程会在 sharedPhotoManager 初始化之前就进入 if 语句中,并且 switch 在此时也未能出现。然后,另一个线程(Thread B)进入 if 语句,初始化这个单例,然后退出。

当系统 context switch 切换到 Thread A ,我们会再次初始化这个单例,然后退出。这样我们就有了两个单例。这是我们不希望发生的。

我们可以改变上面的代码,迫使这种极端情况发生以便我们观察。

PhotoManager.m 中

+(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;
}

上面的代码中通过 NSThread 的 sleep 方法,强制使 context switch 出现。

AppDelegate.m 中

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。

我们可以看到输出结果:

图片描述

可以看到,Log 出三个单例内存地址在这里是不同的。

问题的原因是,初始化PhotoManager 这部分作为一个 critical section,应该仅被执行一次,可实际却被执行了多次。虽然上面的情况是我们强制出现的,但是还是有可能不小心造成这个问题。

注意:刚刚在运行中,三次编译两次都直接 crash,仅仅上面一次log 出上面的结果。这个问题的原因是:系统事件是不受我们控制的。如果出现了线程问题,因为问题难以重现,所以也非常难Debug。

为了纠正这个问题,实例化的代码应该仅被执行一次,因为 if 语句中的代码作为一个 critical section。这里就引出了我们要使用的 dispatch_once。

将上面初始化的代码替换为

+(instancetype)sharedManager
{
    static PhotoManager *sharedPhotoManager = nil;
    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];
    });
    return sharedPhotoManager;
}

可以看到,之前的问题已经没有再出现了。我们把上面的代码调整一下

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

dispatchonce 在一种线程安全的模式下,执行并且只执行一个 block。在有一个线程已经在这个 critical section,如果有其他线程尝试访问 dispatchonce 中的 section,这些线程会一直被 block 直到 critical section 执行完成。

需要注意的是,这个做法实现了实例的线程安全,并没有实现类的线程安全。如果需要,在类中还需要添加其他的 critical block,例如我们需要操作重要的内部数据的时候。这些我们可以使用其他的方法来实现线程安全,例如 同步一个数据的访问(synchronising access to data),我们在后面会提到。

处理读写问题(Reader and Writer Problem)

实例的线程安全并不仅仅是单例要处理的唯一问题。如果单例属性是一个 mutable 对象,我们就需要考虑这个这个对象是否是线程安全的。关于对象是否是线程安全,我们可以参照苹果提供的一份文档:点击链接
举例来说,NSMutableArray 就不是线程安全的。

虽然很多线程都可以从 NSMutableArray 的实例立即访问,但是当它正在某个线程被访问的时候还让其他线程来访问显然是不安全的。我们并没有防止这种情况的发生。

还是 PhotoManager.m 中

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

上面是一个 write 方法,给 photoArray 添加一个对象。

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

这里是一个 read 方法。为了防止调用者改变这个数组,它给调用者生成了一个 photoArray 的 immutable 的copy。但我们仍需要注意,当一个线程调用 read 方法时,要防止另一个线程调用 write 方法。这个问题是软件开发的经典问题 Reader-Writer Problem。GCG通过使用 dispatch barriers ,制造了一个 Reader-Writer lock 提供了一个非常好的解决方式。

Dispatch barrier 的作用是,在并发队列中,提供一种类似串行的瓶颈。使用 dispatch barrier 可以确保被提交的 block 在一个特定时间段内被这个 queue 唯一执行。这意味着,所有之前提交到队列中的 block 必须要在 barrier block 执行前执行完成。

当轮到这个特定的block 的时候,这个 barrier 会执行这个 block,并确保这个 queue 在这个block 执行期间不执行其他的 block。一旦完成,这个queue 就会按原来的设定继续执行。GCD 提供了同步和异步两种 barrier 功能。

图大致说明了 barrier 功能的效果。

Dispatch-Barrier.png

可以看到队列中的一般操作和普通的并发队列并没有太大区别,但是当执行到barrier block 的时候,这个队列就开始向一个 串行队列了。当这个 barrier block 执行完成之后,这个队列又开始恢复到并发队列。

barrier 的使用场景

Custom serial queue:不要在这里使用 barrier,这已经是一个 serial queue,使用 barrier 毫无意义。
Global Concurrent Queue:在这里要小心使用,因为其他系统操作可能会使用这个队列,所以使用 barrier 将 Global Concurrent Queue 独占存在风险。
Custom Concurrent Queue:放心使用;

如上面所说,因为唯一比较好的 barrier 使用方式是在 custom concurrent Queue 中使用,所以我们需要自己创建一个并发队列来处理 barrier ,以便将 read 和 write 方法独立开来。

在 PhotoManager.m 中,添加一个 dispatch 的 property:

@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:检查 photo 是否存在

  • 注释2:用我们的自定义 queue 添加写操作,当 critical section 在稍后执行的时候,这将是队列中唯一被执行的 task。

  • 注释3:将对象添加到数组。因为这是一个 barrier block,不会有其他的 block 同时和这个block 在 concurrentPhotoQueue 中运行。

  • 注释4:最后发送一个通知,广播图片已经添加完成。因为这个通知包含 UI 相关的代码,所以需要从 main thread 中发出,所以在这里我们调度另一个异步任务到主线程,以完成发送通知。

上面的代码处理了写方法,我们还需要实现 photos 的读方法,还有对 concurrentPhotoQueue 实例化。
为了保证写方法那边的线程安全,在读方法这边我们也需要做一些处理。虽然我们需要从 photos 方法获得返回值,但不能异步调度到这个线程,因为它不一定会photo 方法 return之前结束完成。
这种情况,dispatch_sync 就是非常好的选择。

dispatchsync 以同步方式将代码块提交,并且等待 block 完成才返回。我们可以使用 dispatchsync 来追踪 dispatch barrier block,或者当我们需要等待某个操作结束,以得到这个 block 处理后的数据。如果是第二种情况,我们通常会看到一个 _block 变量写在dispatchsync 的外面,以便使用被 dispatch_sync 中的block 处理后的数据。

但是,使用 dispatchsync 的时候还是要相当小心。如果我们吧 dispatchsync 使用在我们当前正在运行的队列上,会导致死锁,因为这个调用会等到 block 结束后执行,但是这个 block 不能结束(因为都不能开始),因为当前的任务也没有结束。所以我们必须要小心我们要从哪个队列调用,也要小心将哪个队列传入。

dispatch_sync 的使用场景

  • Custom Serial Queue:一定要非常小心; 如果我们已经在一个队列中运行,然后调用了 dispatch_sync 指向当前这个队列,会造成死锁。

  • Main Queue:同样要非常小心;和上面原因一样,要注意死锁。

  • Concurrent Queue:一般都放心使用;不管是和 dispatch_barrier 协同使用,还是等待一个任务结束以便下一步处理,都是合理的选择。

还是在 PhotoManager.m 中,修改 photos 读方法

-(NSArray *)photos
{
    __block NSArray *array; // 1
    dispatch_sync(self.concurrentPhotoQueue, ^{ // 2
        array = [NSArray arrayWithArray:_photosArray]; // 3
    });
    return array;
}
  • 注释1:__block 关键字允许 block 中的对象是 mutable 的。如果没有这个关键字, block 中的 array将会变为只读,代码将会无法编译。

  • 注释2:同步调度到 concurrentPhotoQueue 来进行读方法。

  • 注释3:存储 photo 数组并返回。

注意:如果我们希望在一个 block 外面声明一个对象,在 block 内给它赋值,我们就需要在声明的时候加上 __block 关键字,这个关键字允许我们修改 block 内的对象。否则将无法编译。

最后我们需要实例化 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;
}

上面的代码使用 dispatchqueuecreate 将concurrentPhotoQueue 初始化为一个并发队列。这一个参数一般写成反 DNS 形式,最好确保有一定意义,以便 Debug。第二个参数指定我们希望队列是串行的还是并发的。
注意:上面的 dispatchqueuecreate 方法中,我们可以看到经常有人传 0 或 NULL 作为第二个参数。但是这种做法是过时的,最好还是说明我们的参数。

现在,PhotoManager 单例已经是线程安全的了。不管我们怎么读写这些图片,我们都可以确保这些都会以相对安全的方式完成。

Dispatch Groups

我们先来看下面的示例代码:

-(void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
    __block NSError *error;
 
    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;
        }
 
        Photo *photo = [[Photo alloc] initwithURL:url
                              withCompletionBlock:^(UIImage *image, NSError *_error) {
                                  if (_error) {
                                      error = _error;
                                  }
                              }];
 
        [[PhotoManager sharedManager] addPhoto:photo];
    }
 
    if (completionBlock) {
        completionBlock(error);
    }
}

在这个方法的末尾,调用了 completionBlock 。实际这样写是有问题的,并不能保证上面的下载图片方法能够立即完成。因为 initwithURL : withCompletionBlock : 这个方法是异步方法,所以下载还没有完成,这个方法就返回了。但是 downloadPhotosWithCompletionBlock 这个方法却以同步的方式,在方法最后调用了 completion block

正确的做法应该是:downloadPhotosWithCompletionBlock 仅应该在图片下载的异步方法initwithURL : withCompletionBlock :完成并调用了它的 completion blockdownloadPhotosWithCompletionBlock 再去调用自己的 completion block

但是,问题来了——下载图片的方法是异步方法,前文章里我们已经说过,异步任务虽然也是按照提交(submit)的顺序开始的,但是一旦开始执行,我们就无法控制这个任务什么时候被执行完成——这些都由 GCD 来控制。

也许我们会想到使用一些全局的 bool 型变量来记录下载的状态,这种做法不是说不行,但的确是比较笨的办法。
幸运的是 GCD 为我们提供了 dispatch groups,使用 dispatch groups 来监控异步任务的完成情况在合适不过了。

Dispatch groups 会在一组任务完成之后通知我们。这些任务可以是同步的也可以是异步的,甚至这些任务不在同一个队列里也能够被追踪(监控)。当一组任务完成之后,dispatch groups 也可以按同步或异步的方式来通知我们。因为不同队列的任务也可以被追踪,需要有一个 dispatchgroupt 的实例来追踪不同队列中的不同任务。

GCD 提供了两种方式来实现追踪

1. dispatchgroupwait

这个功能用来阻塞当前的线程,一直到这个任务组(group)的所有任务结束,或者直到一个 timeout 出现。dispatch_group_wait是一个同步功能。我们可以将这个功能应用在之前有问题的代码中:

-(void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // 1
 
        __block NSError *error;
        dispatch_group_t downloadGroup = dispatch_group_create(); // 2
 
        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); // 3
            Photo *photo = [[Photo alloc] initwithURL:url
                                  withCompletionBlock:^(UIImage *image, NSError *_error) {
                                      if (_error) {
                                          error = _error;
                                      }
                                      dispatch_group_leave(downloadGroup); // 4
                                  }];
 
            [[PhotoManager sharedManager] addPhoto:photo];
        }
        dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER); // 5
        dispatch_async(dispatch_get_main_queue(), ^{ // 6
            if (completionBlock) { // 7
                completionBlock(error);
            }
        });
    });
}
  • 注释1:因为我们使用了同步功能 dispatchgroupwait,它阻塞了当前的线程。我们将这整段代码都包含在了一个 dispatchasync 当中,就是为了避免这个主线程在这里不被 dispatchgroup_wait 所阻塞。

  • 注释2:创建一个新的 dispatch group,它的作用有点像一个用来记录未完成的任务的计数器。

  • 注释3:dispatchgroupenter 用来通知一组任务开始了。在使用中需要注意的是,我们使用了多少个 dispatchgroupenter ,通常就要使用多少个 dispatchgroupleave , 否则可能会造成一些非常奇怪的 bug。

  • 注释4:通知这一组(group)任务已经完成了。

  • 注释5:dispatchgroupwait ,开始等待,一直到这组任务全部结束,或者知道任务出现了 timeout。如果发生了 timeout,dispatchgroupwait 会返回一个非零结果。我们可以用这个结果来判断任务是不是超时。但是,如果我们向上面的代码写的一样,使用了 DISPATCHTIMEFOREVER ,这个任务就永远不会超时,dispatchgroupwait 就会一直等待直到任务完成。

  • 注释6:此时我们就能知道,所有的图片下载任务要么是下载完成了,或者是超时了。然后我们就能回调到主线程,调用 downloadPhotosWithCompletionBlock 这个方法的 completion block。

  • 注释7:检查 completion block 是否为空,如果不为空,返回。

上面的代码现在看起来效果还不错,但是我们最好还是要避免阻塞线程的操作(同步方式)。接下来我们会重写上面的方法,当下载完成的时候,使用异步通知的方式,使我们能够知道下载结束了。

我们先来了解一下 dispatch group 的使用场景:

  1. 自定义穿行队列(Custom serial queue):比较适合使用通知形式。

  2. 主队列(Main queue):可以使用但一定要小心,有可能我们提交的任务会阻塞主线程。

  3. 并发队列(Concurrent queue):不管是使用通知形式还是 dispatchgroupwait, 都是很好的选择。

接下来我们介绍 dispatch groups 的另一种使用方式

上面的代码的确实现我们需要的效果了,但是我们可以看到,我们必须 dispatch async 到另一个队列然后才能使用 dispatchgroupwait 。我们可以尝试使用另一种方法,修改上面的代码:

-(void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
    // 1
    __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); // 2
        Photo *photo = [[Photo alloc] initwithURL:url
                              withCompletionBlock:^(UIImage *image, NSError *_error) {
                                  if (_error) {
                                      error = _error;
                                  }
                                  dispatch_group_leave(downloadGroup); // 3
                              }];
 
        [[PhotoManager sharedManager] addPhoto:photo];
    }
 
    dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ // 4
        if (completionBlock) {
            completionBlock(error);
        }
    });
}
  • 注释1:在这里我们就不用像使用 dispatchgroupwait 时候那样把整个方法都放在 dispatch async 里,因为我们在这里并没有阻塞主线程。

  • 注释2:和上面一样,表示一个 block 进入了这个group。

  • 注释3:一个 block 离开了 group。

  • 注释4:这段代码会在之前的 dispatch group 中全部执行完时执行。之后我们就需要执行 completion block 了,在这里我们要制定我们之后希望在那个队列来执行 completion block,在这里就是 main queue 了。

关于

void dispatch_group_notify ( dispatch_group_t group, dispatch_queue_t queue, dispatch_block_t block );

作用是:当之前提交到 group 的任务(block)完成时,把一个 block 提交到一个队列。

参数1:我们要追踪的 dispatch group
参数2:当 group 完成之后,我们用来提交 block 的队列。
参数3:要被提交的 block。

大量使用并发的风险

我们来看一下上面的代码,方法中有一个 for 循环,用来下载三张不同的图片。我们可以尝试一下能否对这个 for 循环使用并发,加速下载。

在这里我们就可以尝试使用 dispatch_apply。

dispatchapply 的作用有点像一个用来并发执行不同的迭代(iterations)的for 循环。dispatchapply 这个方法是同步的,所以和一般的 for 循环一样,dispatch_apply 只会在执行完成之后才会返回。

需要注意的是,dispatchapply 中的迭代数量也不宜太多,过多的迭代可能会造成每个迭代的碎片时间、消耗累积,影响使用。在这里 dispatchapply 中的迭代总数需要我们在编码过程中尝试、优化。

在哪里使用 dispatch_apply 比较合适呢?

自定义串行队列(Custom serial queue):不适用,因为 dispatch_apply 是同步方法,还是老老实实使用 for 循环把。

主队列(Main queue):跟上面一样,同样不适用。
并发队列(Concurrent queue):适用 dispatch_apply,特别是当我们需要追踪任务完成情况。

我们再次修改上面的代码:

-(void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
    __block NSError *error;
    dispatch_group_t downloadGroup = dispatch_group_create();
 
    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 时,第一个参数是迭代的数量,第二个参数是真正执行任务的队列,第三个就是要提交的 block。

需要注意的是,我们已经把 add photo 的方法修改为线程安全,但是得到图片的顺序还是不一定的,因为这取决于 dispatch_apply 中哪一个迭代先执行完成。

运行后我们会发现,多次点击从网络加载图片,偶尔下载速度会有一点提升。

但事实上,使用 dispatch_apply 来实现这个效果并不太值得,原因是:

  1. 因为线程并行的原因,多多少少会增加运行开销。

  2. 在这里我们也能体会到,使用 dispatch_apply 替换 for 循环以后,效果并没有太大提升。所以在这里花时间显得有些不太值得。

  3. 通常来说,代码优化会使得代码变得更复杂,也让别人更难读懂我们的代码。所以优化前要考虑到这一点。

GCD 的其他功能

除了上面介绍过的这些常用功能外,GCD 还有一些不是那么常用的功能,但在某些场合使用也许会十分方便。

在 Xcode 中测试是在 XCTestCase 的一个子类上执行。测试方法的方法名通常是原本的方法签名前加一个 test。测试是在主线程上进行的。

一旦一个测试方法运行完成,XCTest 方法会认为这个方法已经完成并且运行下一个 test。这就意味着,如果一个方法中包含异步代码,很有可能在运行下一个方法的时候,当前方法还在继续运行。

通常网络请求相关代码都是异步的,加上 test 通常都会在测试方法执行完成后就结束,这会使得网络请求代码很难测试。但是,我们可以在 test 方法中阻塞主线程一直到网络请求完成。

这里需要注意的是,是否使用这种方式进行测试大家还是有分歧的,有些人认为这种方式没有很好的遵循集成测试的设定。但是如果这种方式对我们有用,不妨一试。

在 test 方法中,原本代码如下:

-(void)downloadImageURLWithString:(NSString *)URLString
{
    NSURL *url = [NSURL URLWithString:URLString];
    __block BOOL isFinishedDownloading = NO;
    __unused Photo *photo = [[Photo alloc]
                             initwithURL:url
                             withCompletionBlock:^(UIImage *image, NSError *error) {
                                 if (error) {
                                     XCTFail(@"%@ failed. %@", URLString, error);
                                 }
                                 isFinishedDownloading = YES;
                             }];
 
    while (!isFinishedDownloading) {}
}

用上面的方法测试并不是很合理。方法末尾的 while 循环等待 isFinishedDownloading 在 completion block 中变为true。运行 test,为了让现象更加明显我们可以开启手机开发者模式里的网络控制,把网络状态调整为 very bad。打开 debug navigation, 在我这里,可以非常明显的看到 CPU 使用率保持在 95% 高居不下,内存使用也从 6点几上涨到了 8 点几。

这种实现方法叫做自旋锁。造成这个现象的原因是,while 循环在网络请求结束之前,不间断的检查 isFinishedDownloading 中的值。

Semaphores(信号灯)

信号灯是一个非常 old-school 的概念。如果想了解更多一些,可以百度“哲学家就餐问题”。
信号灯允许我们控制多个消费者对有限的资源的访问。我们可以把信号灯想象成一个餐厅的服务员,我们的餐厅只有两个位子,那么每次我们最多就只能允许两位客人进去用餐,那么剩下的客人就要排队等位子用餐(First in first out)。

我们来尝试使用 semaphores,将上面的代码替换为:

-(void)downloadImageURLWithString:(NSString *)URLString
{
    // 1
    dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
 
    NSURL *url = [NSURL URLWithString:URLString];
    __unused Photo *photo = [[Photo alloc]
                             initwithURL:url
                             withCompletionBlock:^(UIImage *image, NSError *error) {
                                 if (error) {
                                     XCTFail(@"%@ failed. %@", URLString, error);
                                 }
 
                                 // 2
                                 dispatch_semaphore_signal(semaphore);
                             }];
 
    // 3
    dispatch_time_t timeoutTime = dispatch_time(DISPATCH_TIME_NOW, kDefaultTimeoutLengthInNanoSeconds);
    if (dispatch_semaphore_wait(semaphore, timeoutTime)) {
        XCTFail(@"%@ timed out", URLString);
    }
}

我们来看看这段代码的作用。

  • 注释1:创建 semaphore,参数表示 semaphore 初始的值。这个数字表示可以直接访问信号灯,而不用先让信号灯增长的对象数量。(让一个信号灯增长就是给一个信号灯发信号)

  • 注释2:在这里我们通知这个信号灯,我们不在需要这个资源了。这里让信号灯增长,并且表明,信号灯可以接受其他对象的访问了。

  • 注释3:等待信号灯发信号,并设置一个 timeout 的时间。这里会阻塞当前的线程,知道 semaphore 被通知。如果超时会返回一个非零的结果(non-zero result)。

再次运行 test 可以看到,CPU 使用率一直是 0,在十秒后返回了一个超时错误。

Dispatch sources

Dispatch source 简单说是一个一些 low-level 功能的集合,可以帮助我们监控 Unix 信号、文件描述、VFS 节点等不太常用的东西。上面说的这些东西我自己也没怎么用过,在文中就不做介绍了,我们可以尝试一种特殊的方式来使用以下 dispatch source。
我们来看一下关于 dispatch source 的创建方法:

dispatch_source_t dispatch_source_create(
   dispatch_source_type_t type,
   uintptr_t handle,
   unsigned long mask,
   dispatch_queue_t queue);

关于这个方法的文档

第一个参数 dispatchsourcetype_t,这是最重要的参数。关于这个方法在官方文档的详细介绍:创建一个新的 dispatch source 来监控 low-level 的系统对象,并且自动注册一个 handle block 到 dispatch queue 来对事件作出回应。

参数 type:dispatch source 的类型,必须是这个列表中常量。
参数 handle:用来进行监控。这个参数取决于上面的 type 参数。
参数 mask:一个标志的 mask,用来确定需要哪些事件。同样也受 type 的指挥。
参数 queue:事件操作 block 要被提交到的 queue。

返回值是一个 dispatch source 对象,如果没有创建成功的话返回为空。

这里要监听的是一个 DISPATCHSOURCETYPE_SIGNAL。

dispatch source 会监听当前的进程,等待信号。handle 是一个int 类型的信号数字。Mask 暂时没有用到,先传0。

在这里我们可以看到一个 Unix signal 列表,在这里我们会监听 SIGSTOP 信号。这个信号会在进程收到了一个不可避免的暂停指令后发出。当我们使用 LLDB debugger 来 debug 我们的 APP 的时候,其实也是发送了这个信号。

我们在 PhotoCollectionViewController.m 添加下面的代码:

-(void)viewDidLoad
{
  [super viewDidLoad];
 
  // 1
  #if DEBUG
      // 2
      dispatch_queue_t queue = dispatch_get_main_queue();
 
      // 3
      static dispatch_source_t source = nil;
 
      // 4
      __typeof(self) __weak weakSelf = self;
 
      // 5
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
          // 6
          source = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, SIGSTOP, 0, queue);
 
          // 7
          if (source)
          {
              // 8
              dispatch_source_set_event_handler(source, ^{
                  // 9
                  NSLog(@"Hi, I am: %@", weakSelf);
              });
              dispatch_resume(source); // 10
          }
      });
  #endif
 
  // The other stuff

我们来一步步看一下上面代码的作用:

  • 注释1:这段代码最好是在 Debug 模式下使用。

  • 注释2:声明 queue,注意这里的 queue 就是主队列。

  • 注释3:声明 dispatch_source。

  • 注释4:使用 weakSelf 是为了保证没有 retain 循环。在这里使用 weakSelf 也不是一定需要的,因为 PhotoCollectionViewController 这个类在 App 的整个声明周期都会存在。但是,如果我们在使用过程中有一些类(View Controller)会disappear,weakSelf 任然能保证没有 retain 循环。

  • 注释5:使用 dispatch_once ,让 dispatch source 只执行一次。

  • 注释6:给 source 赋值,参考上面我们对 dispatchsourcecreate 方法的介绍。第二个参数表示,我们要监听 SIGSTOP 信号。

  • 注释7:如果使用了不正确的参数,dispatchsourcecreate 是不能执行成功的。所以我们在这里进行检查。

  • 注释8:dispatchsourceseteventhandler 会在我们收到监听的信号时调用。

  • 注释10:默认情况下,所有的 dispatch sources 是暂停状态的。我们在这里让 source 恢复,以便开始监控其他事件。

    现在运行程序,然后在 Debugger 中暂停,然后再继续运行。

我们能看到在点击继续时候,控制台 log 了上面的文字。现在我们的代码能够检查到进入了 debug 状态。

我们可以用这个方法来 debug 对象,并在 resume 的时候显示数据。还有一个有趣的使用方法:我们可以用它来做堆栈追踪工具,在 debugger 里面找到我们想要操作的对象。

使用这个方法我们可以随时停止 debugger,然后让代码在我们要执行的位置执行。

例如,我们可以在上面代码 NSLog 位置设置一个断点,暂停一下,然后继续,应用就会触发我们刚刚添加的断点。在这里我们就可以访问当前这个类的变量了。

现在在 debugger 中输入

po [[weakSelf navigationItem] setPrompt:@"HAHAH!"]

可以看到我们在没有改变代码的前提下,Navigation Bar 上的文字被改变了。

使用这种方法,我们可以直接更新当前 UI,查询当前类的值,甚至执行一个方法,restart 应用。

图片描述

Bingo!

有需要的话可以点击这里查看完成后的Demo。
参考链接


Richard_Gao
429 声望7 粉丝