GCD 使用指南

原文链接

在iOS应用程序中,如果不做特殊的操作,程序的代码都是跑在主线程中的。在主线程中有一个RunLoop, iOS会在RunLoop循环的间隙来执行UI的刷新,如果我们在主线程中执行耗时较长的代码,UI刷新不及时,在用户看来就是我们的应用卡住了。为了避免这种情况的发生,我们必须把耗时任务(比如IO操作,网络请求)放在其他线程中执行以保证程序的流畅。幸运的是,apple 提供了非常强大的线程库,使得iOS的多线程开发变得十分简单(相对于直接使用线程)。这个库就叫做GCD (Grand Central Dispatch),提供了C语言接口的并发编程模型。

GCD的简单使用
GCD的使用简单并且直观,先看一下最简单的例子

1
2
3
4
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^(){
// do your time cost work here.
});

在上面注释的地方加入你需要执行的耗时代码,这些代码就会在其他线程中执行。GCD构建出了新的并发模型,这个模型就是操作队列(queue)。你把需要并发执行的代码放入一个block或者一个函数中,然后将这个block或者函数派遣(diapatch)到某一个操作队列(queue)中,然后这个block或者函数就会尽可能快的在其他线程中执行。作为程序员,当你需要做并发任务时,你可以只考虑操作队列(queue)与需要执行的任务,而无需再考虑线程的操作与管理,这毫无疑问极大地简化了编写并发程序的难度

在GCD中,操作队列(queue)的类型是dispatch_queue_t, 上述代码第一行就是通过函数

1
dispatch_queue_t dispatch_get_global_queue( long identifier, unsigned long flags); //获取全局并发操作队列

来获取了一个GCD提供的全局队列并赋值给变量queue。GCD为我们提供了4个全局操作队列(global queue),每个全局操作队列(global queue)都有一个各不相同的优先级,分别是高(high),默认(default),低(low),后台(background)。通过给第一个参数传入不同的值来获取各个全局操作队列:

1
2
3
4
#define DISPATCH_QUEUE_PRIORITY_HIGH 2
#define DISPATCH_QUEUE_PRIORITY_DEFAULT 0
#define DISPATCH_QUEUE_PRIORITY_LOW (-2)
#define DISPATCH_QUEUE_PRIORITY_BACKGROUND INT16_MIN

这个优先级有什么用我们下面会说明。
第二个参数是为将来预留的,目前还没有什么用处,只能设置为0。
在拿到操作队列之后,我们通过函数dispatch_async(dispatch_queue_t queue, dispatch_block_t block)来将任务派遣(diapatch)到队列中。其中第一个参数是背派遣到的队列,第二个参数是需要并发执行的block。然后这个block就会与主线程并发的执行。这个函数名后半段的async是英语单词异步(asynchronous)的前缀,也就是表明该函数所做的是一个异步派遣,函数立即返回,不会等待block执行完成。
这就是GCD最简单的使用方法。很简单直观,不是吗?

GCD与线程池
GCD的内部自己维护了一组线程,我们加入到操作队列(queue)中的任务最重都会在GCD所维护的某一个线程中运行。GCD会根据CPU内核数量、CPU负载情况等多种因素来决定线程的数量。当某一个block被加入到操作队列(queue)中,如果此时有空闲的线程,那么这个block就会被安排在空闲线程上立即执行;如果此时没有空闲线程,那么block会等待,直到有线程空闲下来后再去执行。操作队列(queue)本身是先进先出的,先被加入到操作队列的block先被执行。
我们拿具体的例子来说明。
假设现在GCD维护的线程数量为3。我们向操作队列添加的前3个任务会立即开始执行,如果此时这3个任务都没有执行完的话,接下来添加的第4个以及后续的任务都会排队,等某一个任务完成,有可被使用的线程之后,再从操作队列中取出进入队列最早的任务开始执行。这也是操作队列的意义:按照进入队列的先后顺序尽可能多的并发执行任务。
上文提到GCD默认提供4个全局队列,是有不同的优先级的。每当GCD有空闲线程可以执行任务时,GCD总是从优先级高的队列中选取任务来执行。应用程序中任务的优先级完全取决于应用程序本身自己的逻辑,通常情况下,都使用默认优先级,如果有一个任务需要尽快执行,那就将其添加到高优先级队列;如果有一个任务做了最好,不做也没什么关系的话,可以将其添加到低优先级队列甚至是后台优先级队列.

操作队列(queue)
GCD除了提供4个全局队列之外,还提供了一个名叫mainQueue的队列,获取它的代码如下:

1
dispatch_queue_t mainQueue = dispatch_get_main_queue();

如果一个任务发送到mainQueue中,那么该任务会在主线程的下一次RunLoop中执行。因为添加到mainQueue中任务都是在主线程中执行的,所以后一个任务只能在前一个任务执行完之后才能开始执行,所以是线性队列。相对应的,GCD提供的其它4个队列都是并发队列,无需等待前面的任务执行完,只有有空闲的线程资源就会执行。
mainQueue的典型用法如下:

1
2
3
4
5
6
7
8
9
10
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_queue_t mainQueue = dispatch_get_main_queue();
dispatch_async(queue, ^(){
// do your time cost work here.
[self downloadSometing];
dispatch_async(mainQueue, ^(){
//update UI
});
});

首先我们把下载数据的任务指派到全局并发队列里来做,当下载完成时,需要告诉用户,把UI相关的代码指派到mainQueue中。这种使用模式非常普遍:把耗时操作排遣到全局操作队列中,等任务完成后在mainQueue中更新UI。几行代码就完成了并发编程,GCD的强大可见一斑。

除了GCD提供给我们的5个操作队列之外,我们还可以创建新的队列:

1
dispatch_queue_t queue = dispatch_queue_create("com.mydomain.queue", DISPATCH_QUEUE_SERIAL);

该函数会创建新的操作队列,第一个参数是你给这个队列起的名字,主要作用是方便调试。第二个参数指定了操作队列的属性:是线性队列还是并发队列。第二个参数的可选值:

1
2
DISPATCH_QUEUE_SERIAL //线性队列
DISPATCH_QUEUE_CONCURRENT //并发队列

使用自定义的队列主要有两个目的,第一个是方便调试。在调试运行的时候加一个断点,xcode会在左侧的面板中列出此时全部的线程,如果某一个线程正在执行某个你自己创建的队列中的任务时,那么该线程后面就会显示出该队列的名字,如下图:


也可以在lldb中执行thread list打印出来

可以通过下面这个函数获得队列的名字:

1
const char * dispatch_queue_get_label(dispatch_queue_t queue);

另一个目的是创建与主线程并发执行的线性队列。GCD给我们提供了一个在主线程执行的队列和4个并发执行的队列,却没有在其他线程执行的线性队列,所以当需要使用这种队列时,我们只能创建一个给自己用。需要注意的是,如果我们新建了自己的线性队列,派遣到该队列的任务并不会在GCD所维护的那些线程中执行,GCD会新建一个临时线程专门来做该队列的任务。线程的创建需要额外开销,当线程数目过多的时候,线程调度本身就会消耗一部分资源,所以在开发的是时候应该合理的使用自定义线性队列。如果一个程序中新建了20个线性队列,并且在某一时刻这20个线性队列中都有正在被执行的任务,那么GCD此时就额外维护着20个线程。不过当某一个线性队列中的任务被执行完并且没有其他任务在队列中等待被执行时,分配给该队列的线程会被释放掉,直到该队列又被指派了新的任务,那么一个新的临时线程就又被创建了。
我们也可以创建自定义的并发操作队列。自定义的并发操作队列与GCD提供给我们的4个全局并发队列一样,使用GCD维护的线程池来执行加入到自定义并发队列中的任务,并不会创建新的线程。自定义的并发队列的优先级是默认优先级(DISPATCH_QUEUE_PRIORITY_DEFAULT)。可以通过如下代码来修改自定义并发队列的优先级:

1
2
3
dispatch_queue_t myQueue = dispatch_queue_create("com.mydomain.myQueue", DISPATCH_QUEUE_CONCURRENT);
dispatch_queue_t highPriorityQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_set_target_queue(myQueue, highPriorityQueue);

首先新建一个并发操作队列myQueue,然后获取优先级为高的全局并发队列highPriorityQueue, 最后将这两个队列传入dispatch_set_target_queue(queue1, queue2)这个方法。该方法会将queue1的优先级设置为queue2的优先级。dispatch_set_target_queue(queue1, queue2)的本质是将queue1“连接”到queue2,被加入到queue1的任务实际会被添加到queue2中。GCD会替我们把自定义的队列”连接”到默认优先级的全局并发队列,这也是自定义并发队列的优先级是默认优先级的原因。实际上,任何添加到自定义并发队列中的任务最终会被添加到4个GCD提供的并发队列中的某一个中。
根据队列是线性的还是并发的,此函数共有4种情况:

case queue1 queue2
1 serial serial
2 serial concurrent
3 concurrent serial
4 concurrent concurrent

case 1: 线性队列连接到线性队列之后还是线性执行。多个线性队列之间是并发执行的,然而如果将多个线性队列添加到同一个线性队列,那么这些线性队列就不会并发执行了。
case 2: 将线性队列添加到并发队列并不会使任务从线性执行变为并发执行。
case 3: 将并发队列添加到线性队列会使原本并发执行的任务变成线性执行。
case 4: 将并发队列添加到并发队列后原本并发执行的任务依然会并发执行。
需要注意的是:不要将mainQueue和4个全局并发队列“连接”到其他队列。

向操作队列添加任务
dispatch_async

1
void dispatch_async( dispatch_queue_t queue, dispatch_block_t block);

该函数是最常使用的向操作队列添加任务的函数:将block添加到queue后立即返回。block在被添加到操作队列之后,何时被执行取决于被加入的队列的类型(线性还是并发)、队列中是否已经有任务、是否有空闲线程(对并发队列来说)等因素共同决定。
不仅可以向队列提交block形式的任务,还可以向队列提交函数形式的任务。该函数还有一个对应的提交函数形式任务的版本:

1
dispatch_async_f( dispatch_queue_t queue, void *context, dispatch_function_t work);

第一个参数依然是操作队列。第三个参数的数据类型是函数指针:

1
typedef void (*dispatch_function_t)(void *);

此函数就如同上面的block一样被添加队列中等待执行。
第二个参数是传递给该函数的参数。
基本上GCD中添加任务相关的函数都分block版本与函数版本,使用起来几乎完全一样(函数版本的函数名是block版本的函数名后加_f),所以本文接下来就只介绍block版本的函数,相对应的函数版本可以查阅苹果官方文档。

dispatch_sync

1
void dispatch_sync( dispatch_queue_t queue, dispatch_block_t block);

该函数将block提交到队列中并等待,直到block执行后再返回。该函数使用场景比较少,避免在主线程中调用该函数

dispatch_after

1
void dispatch_after( dispatch_time_t when, dispatch_queue_t queue, dispatch_block_t block);

该函数会在指定的时间到达后将block添加到相应的队列。第一个参数是等待的时间,数据类型是typedef uint64_t dispatch_time_t,参数的单位居然是逆天的纳秒,1秒=1000000000纳秒,所以GCD定义了一些宏来帮助我们更方便的使用此参数:

1
2
3
#define NSEC_PER_SEC 1000000000ull // 秒
#define NSEC_PER_MSEC 1000000ull // 毫秒
#define NSEC_PER_USEC 1000ull // 微秒

使用例子:

1
2
3
4
dispatch_time_t twoSecondsLater = dispatch_time(DISPATCH_TIME_NOW, 2ull * NSEC_PER_SEC);
dispatch_after(twoSecondsLater, dispatch_get_main_queue(), ^{
NSLog(@"two seconds later");
});

dispatch_apply

1
void dispatch_apply( size_t iterations, dispatch_queue_t queue,void (^block)( size_t));

该函数会将参数block向指定的队列添加指定数量个,然后等待全部block执行完成后返回。第一个参数指定向队列添加的block的数量。block有一个参数,在每一个block被执行时依次传入“0”~“iterations - 1”。该函数的典型用法是并发的迭代一个数组的每一个元素,条件是对数组元素的每一次迭代都是独立的,互不影响。这样可以提高代码的运行速度,当然前提是你使用的操作队列是并发的。
举例如下:

1
2
3
4
dispatch_apply(array.count, queue, ^(size_t index){
id object = [array objectAtIndex:index];
//handle with object
});

dispatch_once

1
void dispatch_once( dispatch_once_t *predicate,dispatch_block_t block);

该函数保证所指定的block在程序的整个生命周期中只执行依次。第一个参数用来供GCD来判断block是否执行过,第二个参数就是要保证只执行一次的block。该函数最常见的使用方式是在编写单例的时候来保证在多线程环境中也能且只能生成一个对象:

1
2
3
4
5
6
7
8
+ (instancetype)defaultInstance {
static ClassType *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc] init];
});
return sharedInstance;
}

需要注意的是,sharedInstanceonceToken都需要被static标记为全局变量,否则会出错!

使用组(dispatch group)
在日常开发中往往会遇到这样一种情况,有一些工作可以并发执行,然后要等待这些任务都执行完成之后做一些统一汇总的工作。组(dispatch group)正是用来解决这种问题的:将一系列任务添加到一个抽象的组中,然后当该组中的任务全部执行完成之后,会有机会执行汇总工作。先看下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dispatch_group_t myGroup = dispatch_group_create();
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_async(myGroup, queue, ^(){
//block 1
});
dispatch_group_async(myGroup, queue, ^(){
//block 2
});
dispatch_group_async(myGroup, queue, ^(){
//block 3
});
dispatch_group_async(myGroup, queue, ^(){
//block 4
});
dispatch_group_wait(myGroup, DISPATCH_TIME_FOREVER);
//here all the blocks associate with myGroup finished

首先使用函数dispatch_group_create()创建一个group实例,然后获取全局并发操作队列,然后使用函数dispatch_group_async向全局并发队列依次添加了4个任务,并将这4个任务标记为属于myGroup,最后调用函数dispatch_group_wait(myGroup, DISPATCH_TIME_FOREVER),该函数会等待,直到被标记为属于myGroup的任务全部执行完成,然后代码继续执行。该函数第一个参数就是要等待的组(group);第二个参数是要等待的时间,我们这里使用的是无限等待直到全部的block都完成,也可以传一个有限时间的参数,超过这个时间不论任务是否全部完成都会返回。该函数的返回值是类型是long,返回0表示全部block都执行完成了,返回其它值表示超时了。这里的时间单位也是纳秒,最好使用上文介绍dispatch_after时用到的宏。
dispatch_group_wait函数会阻塞当前线程,GCD还为我们提供了一个异步的方法:

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

该函数异步执行,当全部被标记为属于第一个参数queue的任务都执行完成时,将第三个参数block指派到第二个参数queue中执行。这样就不会阻塞当前线程了

使用屏障(barrier)

GCD允许你在自定义的并发操作队列中设置同步点。同步点本身也是提交到并发操作队列中的block,但这个block不会与之前就已经添加的block并发操作,而是等待之前添加的block全部执行完成后再执行。在同步点之后添加的block,会等待被设置为同步点的block执行完成之后并发执行。
使用屏障(barrier)同样也有异步和同步两个函数。
异步函数:

1
void dispatch_barrier_async( dispatch_queue_t queue, dispatch_block_t block);

该函数会把block提交到queue中然后立即返回,不等待block执行完成。第一个参数必须是自定义的并发操作队列。如果使用系统提供的4个全局并发操作队列中的某一个,该函数不起作用,其行为相当于调用了dispatch_async

同步函数:

1
void dispatch_barrier_sync( dispatch_queue_t queue, dispatch_block_t block);

该函数与异步函数的唯一不同是会阻塞当前线程,等待参数block执行完成之后才返回。第一个参数必须是自定义的并发操作队列。如果使用系统提供的4个全局并发操作队列中的某一个,该函数不起作用,其行为相当于调用了dispatch_sync

挂起(suspend)与恢复(resume)

通过挂起(suspend)可以暂停一个操作队列中的任务被执行,之后可以通过恢复(resume)使得队列中的任务继续被执行。
挂起:

1
dispatch_suspend(myQueue);

执行该函数后,参数myQueue中所有未被执行的任务都会暂缓执行。当然,如果该函数调用的时候恰好该队列中有正在执行的任务,那么这些正在执行的任务不会受到影响,会继续执行。
恢复:

1
dispatch_resume(myQueue);

使用该函数恢复之前被挂起的参数myQueuemyQueue中的任务恢复执行。
需要注意的是,GCD内部会对操作队列维护一个挂起计数,函数dispatch_suspend使挂起计数加1,函数dispatch_resume使挂起计数减1 。当挂起计数为0时才会真正的恢复。所以在使用这两个函数的时候,要保证调用的次数一致,以免出现无法恢复的现象。
还有一点,这两个函数对4个全局并发操作队列无效。

使用信号量(semaphore)

线程同步是并发编程中很重要也很实际的问题。虽然使用了GCD后不在直接使用线程,但是任务之间也是需要同步的,可以利用在本文上述内容中提的的同步方法和屏障(barrier)来实现。除此之外,GCD还提供了信号量(semaphore)来帮我们实现任务同步。
GCD的信号量(semaphore)使用非常简单,一个只有3个函数;

1
2
3
dispatch_semaphore_t dispatch_semaphore_create( long value); //1
long dispatch_semaphore_signal( dispatch_semaphore_t dsema); //2
long dispatch_semaphore_wait( dispatch_semaphore_t dsema, dispatch_time_t timeout); //3

第一个函数是创建一个信号量。每一个信号量都有一个计数,该函数唯一的参数就是该信号量的初始计数值。
第二个函数每调用一次,信号量的计数会增加1。在调用该函数之前信号量的计数小于0,那么该函数会唤醒一个等待第三个函数返回的线程。
第三个函数每调用一次,信号量的计数会减小1.在调用该函数之后,如果信号量的计数值不小于0,那么该函数会立即返回;如果小于0,会等待,直到被第二个函数发出信号。如果有多个函数在等待信号,那么按照先后顺序依次获得信号。
请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dispatch_queue_t queue = dispatch_queue_create("coQueue", DISPATCH_QUEUE_CONCURRENT);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
dispatch_async(queue, ^(){
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"get signal");
});
dispatch_async(queue, ^(){
NSLog(@"before signal");
[NSThread sleepForTimeInterval:1];
dispatch_semaphore_signal(semaphore);
NSLog(@"after signal");
});

首先创建一个信号量,并设置计数为0。接着向并发队列添加2个任务,第一个任务等待信号,第二个任务先sleep 1秒,之后用信号量发送信号。显然是第一任务的wait函数先被执行,信号量计数减1,此时信号量计数从0变到了-1,然后函数等待信号。一秒之后,第二个任务发送信号,在调用之前信号量计数为-1,所以调用之后会使第一个任务的wait函数恢复执行,然后信号量计数变为0。然后第一个任务继续执行。
程序运行结果如下:

1
2
3
2016-04-29 17:13:58.498 ZHZL[29327:427630] before signal
2016-04-29 17:13:59.504 ZHZL[29327:427630] after signal
2016-04-29 17:13:59.504 ZHZL[29327:427629] get signal