iOS多线程:细谈GCD

2,558 阅读22分钟

GCD : 线程,队列,任务

说到GCD,就会想到队列和任务,我就从线程,队列,任务这三者来说说它们的关系。

线程

首先,要知道线程,线程是执行任务的,也就是执行我们写的代码的,一个线程只能同时执行一个任务,也就是说假设现在一个线程正在执行一个任务A,如果要执行其他任务,例如说一个任务B,那么该线程必须将任务A执行完毕后才能执行这个任务B,一般来讲,线程执行任务的速度是非常快的,从程序执行代码上我们就看出来了,基本上是一瞬间就执行完毕的,除非是一些耗时任务。

队列和任务

队列和任务,队列是用来存放任务的,每次将任务追加进队列都是存放在此时该队列的最后面,这个也好理解,就跟我们现实生活中的排队一样,每次排队都是排在当前这个队伍的最后一个。线程则是从队列上拿取任务并执行,可以说,队列是任务管理者,线程是任务执行者,线程执行哪个任务需要听从队列的安排,不是说线程想执行哪个任务就执行哪个任务,即便此时线程有执行任务的能力,也要听从队列的安排分配,当任务追加进队列时,由队列安排分配一个对应的线程来执行该任务。队列分为两种,一种是串行队列(主队列也是串行队列),另一种是并发队列(包含全局并发队列)。

串行队列

先说下串行队列,串行队列的特性是存放在该队列上的任务必须按先后顺序一个一个来被线程执行,前面的任务执行完毕,后面的任务才能开始执行,这其实也说明了为什么串行队列只有一个线程的这种设计,前面介绍线程时说了,一个线程只能同时执行一个任务,串行队列只有一个线程的话,那么存放在串行队列上的任务只能按先后顺序来一个一个执行,后面的任务必须等待前面的任务执行完毕后才能开始执行,这刚好符合串行队列的特性。

并发队列

然后是并发队列,存放在并发队列上的任务可以并发执行(多个任务可以同时执行),前面的任务正在执行,后面的任务也可以开始执行,无需等待前面的任务执行完毕,所以说并发队列有多个线程,多个线程则可以同时执行存放在并发队列上的多个任务,这也符合并发队列的特性。

好了,现在基本上对线程,队列,任务这三者以及它们之间的关系都很清楚了。那接下来就来讲解下GCD的常用API dispatch_sync函数和dispatch_async函数。

dispatch_sync

先来讲解下dispatch_sync函数,这是个同步函数,这个函数有2个传参,第一个参数是队列类型,第二个参数是block类型。这里说下阻塞这个概念,阻塞可以理解为处于等待的状态,解除阻塞状态需要满足一定的条件。先说重点,网上很多技术博客讲dispatch_sync函数是阻塞当前线程,其实并不是这样的,我后面会贴上代码例子来推翻这一结论,那既然不是阻塞当前线程... 到底是什么呢,我这里就先说了,dispatch_sync函数是阻塞当前队列,为什么是这样我后面会来讲解。 dispatch_sync的运行机制是阻塞当前队列,将block里的任务追加进目标任务队列(第一个参数队列,此时任务是存放在该队列的最后面),由该队列分配一个对应的线程来执行block任务,因为是dispatch_sync,所以该队列分配的线程就是执行dispatch_sync函数所在的当前线程,注意:如果该队列为主队列,那么分配的就一定是主线程来执行,dispatch_sync需要等待block任务执行完毕才解除阻塞,然后当前线程继续往下执行当前队列上的任务。

dispatch_async

dispatch_async是一个异步函数,该函数的运行机制是不会阻塞当前队列,不管block任务有没有执行完毕甚至有没有执行,dispatch_async本身这个任务都算执行完毕,当前线程会继续往下执行当前队列上的任务。将block任务追加进目标任务队列,此时该队列会分配一个对应的线程来执行block任务,是该队列对应的线程(一般来讲是新的线程,注意,如果是主队列,那么分配的线程一定是主线程),但是如果当前队列和目标任务队列为同一个串行队列,那么分配的线程自然还是执行dispatch_async函数所在的当前线程了。

关于组合

关于组合,就是同步/异步+串行/并发队列这些组合问题,这些得具体情况具体分析,因为当前环境不同(也就是当前队列/线程),组合的执行流程和其产生的结果也是不同的,不能单看组合而忽视当前队列/线程问题。看待问题要抓其本质,将同步/异步和串行/并发队列的机制理解透彻了,再根据当前环境队列,那怎么组合都可以一步步分析它的执行流程出来,就跟做数学题和物理题一样,万变不离其宗。

死锁

死锁在开发中也很常见,下面给大家看一段经典的代码:

只打印了第一个日志,然后就崩溃报错了。有了前面的知识底子,分析起这个问题就很简单了。

首先我们要知道iOS程序启动默认的代码任务是存放在主队列上的,由主队列分配一个对应的线程来执行这些任务,这个线程就是主线程,所以viewDidLoad这系统调用的方法,当前环境队列默认就是主队列,这样首先把当前队列是主队列分析出来,因为dispatch_sync阻塞当前队列(主队列),它需要等待block任务执行完毕才能解除阻塞。而block任务是追加进主队列,存放在此时主队列最后面,这样因为主队列是串行队列,串行队列上的任务需要按先后顺序一个一个来执行,前面的任务没执行完毕,后面的任务就没办法开始执行,所以block任务需要等待dispatch_sync执行完毕才能执行,这样就造成了这两个任务的互相等待。注意此时的当前线程(主线程)是没有在执行任务的,阻塞是发生在主队列上的阻塞,这也是我前面讲的,线程即便拥有执行任务的能力,也要听从队列的安排。因为队列上的任务互相等待,从而导致线程不知道该执行哪个任务,无从下手,毕竟得罪哪个先执行哪个都不行,这样自然就造成死锁了。

为什么dispatch_sync是阻塞当前队列,而不是阻塞当前线程

好了,到了这里,就可以解释下前面遗留的问题,为什么dispatch_sync是阻塞当前队列呢,下面我贴个代码来帮助分析:

如果按照dispatch_sync阻塞当前线程的说法的话,那么逻辑流程就是这样的,当前线程(主线程)已经被dispatch_sync阻塞了,需要等待block任务执行完毕才解除阻塞,block里的任务追加进自定义串行队列,因为是dispatch_sync,所以由当前线程(主线程)来执行block任务,但是此时主线程刚好是被dispatch_sync阻塞了,前面讲线程知识也说了,一个线程只能同时执行一个任务,所以block任务需要等待dispatch_sync任务执行完毕才能执行,那这样就是任务的互相等待了,就会造成死锁了。但是,从上图贴的代码也看出,这块代码实际上运行起来是没有任何问题的,不会造成死锁。所以说,dispatch_sync并不是阻塞当前线程,准确点来讲,可以理解为阻塞当前队列!

正确的逻辑流程是这样的:dispatch_sync阻塞当前队列(主队列),需要等待block任务执行完毕才能解除阻塞,block任务追加进自定义串行队列,此时自定义串行队列上并没有其他任务,所以可以执行block任务,因为是dispatch_sync同步,该自定义串行队列分配当前线程(主线程)来执行block任务,block任务执行完毕后,dispatch_sync解除阻塞,当前线程继续往下执行当前队列的任务,所以说打印日志顺序是1,2,3

探究:dispatch_sync死锁触发的条件

先来看下面的代码:

从图中看出发生了死锁,分析下执行流程:viewDidLoad方法里默认当前队列为主队列,当前线程为主线程,dispatch_async异步函数,不阻塞当前队列,当前线程继续往下执行当前队列上的任务,block任务1追加进自定义串行队列上(任务存放在此时队列的最后面),就称该队列为队列1吧,由队列1安排分配一个对应的子线程来执行block任务1,称该子线程为线程3吧,所以说执行handleSomething这个方法的当前线程则为线程3,当前队列则为队列1,然后dispatch_sync阻塞队列1,等待block任务2执行完毕才解除阻塞,block任务2还是追加进队列1,存放在此时队列1的最后面,而这时候,因为队列1为串行队列,串行队列上的任务要按先后顺序一个一个来执行,前面的任务执行完毕,后面的任务才可以开始执行,block任务2需要等待dispatch_sync任务执行完毕才能执行,这样任务互相等待就造成了死锁。

再来看下面代码:

从图中看出代码执行没有任何问题,这次跟上面不同的是,这次是自定义并发队列,跟上面一样称该队列为队列1吧,block任务1追加进队列1并存放在最后面,由队列1安排分配一个子线程来执行,跟上面一样称该子线程为线程3,所以执行handleSomething方法的当前队列为队列1,当前线程为线程3,dispatch_sync阻塞队列1,等待block任务2执行完毕才解除阻塞,block任务2还是追加进队列1,因为队列1为并发队列,并发队列上的任务是可以并发执行的,前面的任务没有执行完,后面的任务也可以开始执行(这里可能会有一点小疑惑,dispatch_sync阻塞了队列1,队列1是并发队列,阻塞并不影响下面的代码任务执行,为什么线程3不继续往下执行打印第三条日志呢,前面说过,线程是要听从队列的安排的,不是说线程想执行哪个任务就执行哪个任务),所以block任务2不用等待dispatch_sync执行完毕就可以执行,因为dispatch_sync同步,block任务2还是由线程3执行,执行完毕后dispatch_sync解除阻塞,线程3继续往下执行队列1的任务,没有造成死锁,一切运行正常。

总结:dispatch_sync死锁触发条件

所以说,分析了那么多,总结下触发死锁的条件:当前队列必须是串行队列,且block任务追加进的目标任务队列必须跟当前队列是同一个队列。

dispatch_barrier_async

barrier函数称为栅栏函数,先来分析下dispatch_barrier_async函数,看下面代码:

看上面代码,顺带提一下,子线程是默认没有开启runloop的,afterDelay方法延时执行,会在当前线程的RunLoop上创建一个Timer,所以要手动开启当前子线程的runloop,否则无法执行afterDelay调用方法。看打印日志,首先dispatch_barrier_async也是异步函数,前面2条打印日志是延时5秒后执行的,但是却是输出在前面,所以可分析出dispatch_barrier_async函数追加的block任务是等待conCurrentQueue并发队列执行完此时队列上的所有任务才追加进该并发队列,因为并发队列上的任务是可以同时并发执行的,如果直接将该block任务追加进该并发队列,那么就没法控制该block任务的执行时机了。然后接着分析,barrier函数的block任务是延时2秒后执行调用,而后面2条打印日志是没有延时的,但是却是输出在barrier函数的block任务后面,由此可分析当barrier函数的block任务追加进并发队列后,单独执行,且后面的任务需要等待该栅栏任务执行完毕才能追加进该并发队列。

属性读写的线程安全

dispatch_barrier_async函数的使用场景一般用于保证某个属性的读写任务操作的线程安全,避免正在进行读操作时,其他线程执行了写操作,导致数据读取错误混乱。正确的流程是在执行读操作时,不应该执行写操作,执行写操作时,也不应该执行读操作,读操作可以并发执行,只有写操作会影响数据问题,所以写操作应该是单独执行的,这种流程才是绝对的线程安全的。

注意:barrier栅栏任务不应该追加进全局并发队列中

如果用的是全局并发队列,那么dispatch_barrier_async函数的实现效果就跟dispatch_async一样了,没有栅栏任务功能了。看下面代码:

其实可以思考下,为什么苹果这么设计,主要是全局并发队列,该队列是全局性的,或者说该队列的生命周期是伴随整个App程序的。所以可能会出现项目的其他地方,例如一些框架,会用到全局并发队列,假如有些耗时任务执行,那么按照栅栏函数的机制,栅栏任务就会被影响,所以为了避免这种尴尬的情况,苹果规定不能用全局并发队列实现栅栏任务功能。串行队列就不用说了,串行队列如果用栅栏机制并没什么意义。

dispatch_barrier_sync

首先,dispatch_barrier_sync栅栏机制跟dispatch_barrier_async讲的一样,它们唯一的区别只是dispatch_barrier_sync是同步函数,跟dispatch_sync一样,阻塞当前队列。这里就不展开分析了。这个函数我主要说下要注意的问题。前面我讲过死锁的触发条件,而这个dispatch_barrier_sync函数的栅栏机制很特殊,可以说很苛刻了。下面我们来看下代码:

只打印了第一条日志,程序并没有崩溃报错,那么来分析下,因为栅栏函数的机制,block任务需要等待此时conCurrentQueue并发队列上的任务全部执行完毕才追加进该队列上,但是dispatch_barrier_sync本身也是一个任务,已经阻塞当前队列了,当前队列和目标任务队列为同一个队列,dispatch_barrier_sync需要等待block任务执行完毕才解除阻塞,所以说这里就存在一个任务的互相等待问题,也就是dispatch_barrier_sync函数会一直阻塞当前队列,block任务一直等待,无法追加进该队列上。所以这里只执行了第一条打印日志。

如果是串行队列,那就更不用说了,一定是造成死锁的了。

总结:dispatch_barrier_sync函数死锁触发条件

只要是当前队列和block任务追加进的目标任务队列为同一个队列,不管是串行队列还是并发队列,都会造成任务互相等待,导致死锁

GCD信号量:dispatch_semaphore

GCD信号量使用场景很广泛,机制功能很强大,可以解决很多问题,例如多个线程同时执行同一份代码,造成资源抢夺现象,导致数据读取混乱,而使用GCD信号量可以解决该问题,控制多个线程同步,保证线程安全。还可以控制一些异步操作能按预期顺序来执行,例如说网络请求,我们都知道网络请求是异步的,现在有个需求,有3个网络请求,需要按顺序一个一个执行,先执行完A请求,等待A请求回调后,再执行B请求,等待B请求回调后,再执行C请求,就以这个需求为例子,通过这个例子来讲解下GCD信号量。

首先说下信号量函数机制

dispatch_semaphore_create:创建一个semaphore信号量,并初始化信号量

dispatch_semaphore_signal:发送一个信号量,使信号量加1

dispatch_semaphore_wait:阻塞当前线程,等待信号量,直到信号量大于0才执行,执行的同时将信号量减1

看下面代码:

可以看出上面代码执行流程顺序符合需求,那么来分析下:创建一个串行队列,注意这里不能用并发队列,我后面会来讲解,创建一个信号量,初始化信号量为1,dispatch_async异步函数,不阻塞当前主队列,将block里的任务追加进自定义串行队列(存放在此时串行队列最后面),这里三个异步函数,三个block任务按顺序追加进同一个串行队列,串行队列上的任务需要按顺序一个一个来执行,前面的任务执行完毕,后面的任务才可以开始执行,串行队列只有一个线程,这三个block任务都是由同一个子线程来执行。

现在开始执行第一个block任务,因为信号量初始化为1,所以dispatch_semaphore_wait不会阻塞当前线程,会继续往下执行,同时将信号量减1,这样此时信号量则为0了。然后进行网络请求,该网络请求是我自定义的模仿网络请求操作,这里我只设有成功回调,没有失败的回调。在afNetWork:网络操作方法里,我调用了异步函数,因为真实的网络请求就是异步的,然后让回调所在的block任务追加进主队列,由主线程来执行,在block任务里,让主线程阻塞3秒,才执行回调操作。这样就相当于实际上的网络请求3秒后收到回调。

因为是异步的,所以第一个block任务里的网络请求A无需等待回调就算执行完毕了,那么这个block任务也就算执行完毕了,这样串行队列上的第二个block任务就会开始执行,此时因为信号量为0,所以dispatch_semaphore_wait阻塞当前线程,直到信号量大于0才能继续往下执行,这样就必须等待第一个网络请求A回调成功,dispatch_semaphore_signal发送一个信号量,使得信号量大于0,那么第二个block任务才能继续往下执行,同时将信号量减1,如果这里不用信号量来控制,那么第二个网络请求B还没等待网络请求A回调就开始执行了,这样明显不符合需求,所以要用信号量控制。同理,第三个网络请求C也是这样的逻辑执行流程。

这样运用信号量机制,可以完美控制网络请求执行顺序。当然,有更加简便且实际的方法,就是在网络请求的block回调里调用下一个网络请求,这样同样可以实现需求。我这里只是为了通过这个例子讲解下怎么运用信号量。

前面我提到不能用并发队列,看下面代码:

从上面代码看到,先执行第一个网络请求A,等待回调后,就执行了第二个网络请求C。这样明显不符合需求,因为需求的网络请求执行顺序是A,B,C。那么来分析下,将这三个block任务追加进该并发队列,并发队列分配三个不同的子线程来执行,这三个子线程并发执行block任务,然后第一个block任务先执行了,因为信号量为1,dispatch_semaphore_wait不阻塞当前线程,往下执行同时将信号量减1,这样信号量此时就为0了,那么其他两个子线程执行block任务就会卡在dispatch_semaphore_wait函数,阻塞当前线程。这样就只能等待第一个网络请求操作A回调后,信号量加1,才能继续往下执行。注意,并发队列上的任务是可以并发执行的,网络请求操作A回调后,其他两个子线程是并发执行的,顺序是不一定的。所以有可能导致第三个block任务dispatch_semaphore_wait先执行了,这样顺序就是A,C,B了。而串行队列的话,第二个block任务没有执行完毕,存放在其后面的第三个block任务是没有办法执行的。所以这里不能用并发队列,只能用串行队列来控制执行顺序。

GCD 队列组:dispatch_group

有时候我们可能遇见一些需求:需要通过多个网络请求,多个网络请求顺序倒是不定,从回调中拿到返回的数据并拼装成一个数据源,然后再进行UI刷新。这个需求主要有2个要点,一是多个网络请求回调成功后,才能进行UI刷新。二是网络请求是异步的,需要控制其等待回调后才算真正执行完毕。

那么实现这个需求,可以用到GCD队列组 dispatch_group和GCD信号量 dispatch_semaphore,两个相结合,就可以完美实现上述需求,那么我就通过这个需求例子来讲解下队列组。

首先看下相关函数:

dispatch_group_async:该函数是异步函数,将block任务追加进目标任务队列,然后将该队列放进group队列组中。

dispatch_group_wait:阻塞当前线程,等待group队列组中的任务全部执行完毕后,才解除阻塞,继续往下执行

dispatch_group_notify:等待group队列组中的任务全部执行完毕后,才执行notify中的block任务,该函数跟dispatch_group_wait不同的是,它的机制类似通知中心机制,并不会阻塞当前线程,可以说也是异步函数机制。

dispatch_group_enter:表示将一个任务放入group队列组中,此时group队列组任务数加1

dispatch_group_leave:表示将一个任务从group队列组撤出,此时group队列组任务数减1

dispatch_group_enter和dispatch_group_leave这两个组合可替代dispatch_group_async

先用dispatch_group_async实现

看下面代码:

来分析下流程:两个dispatch_group_async异步函数,将block任务追加进自定义并发队列中,因为两个网络请求任务无需按顺序执行,所以用并发队列,并发执行网络请求,提高效率。将该并发队列加入队列组中,将要进行的UI操作(这里就用打印日志来表示吧)追加进主队列并用dispatch_group_notify控制,只有队列组中的两个block任务执行完毕才能打印最后一条日志。因为网络请求是异步的,所以这里用信号量来控制,使得网络请求回调成功后才算真正执行完block任务,上面已经讲解过信号量了,这里就不多说了。基本流程就这样子,很完美实现需求。

dispatch_group_wait

注意,这里如果想用dispatch_group_wait来控制的话,看下面代码:

这样子的话,程序是会死锁的,一条日志都不打印,因为这里涉及到任务互相等待。dispatch_group_wait阻塞当前线程,也就是阻塞主线程,需要等待group队列组中的两个block任务执行完毕才解除阻塞,继续往下执行。而网络请求回调也是在主队列上的,由主线程执行回调任务,此时主线程已经被阻塞了,一个线程只能同时执行一个任务,所以想要执行回调任务,需要dispatch_group_wait解除阻塞,主线程才能执行回调,而回调无法执行,那么block任务里的dispatch_semaphore_wait会继续阻塞其所在的子线程,block任务就永远没法执行完毕,这样就造成任务互相等待了,导致死锁。

dispatch_group_enter , dispatch_group_leave组合

看下面代码:

dispatch_group_enter和dispatch_group_leave组合同样可以实现需求,逻辑流程简单说下,只要网络请求没有回调成功,此时group队列组的任务数则为2,只有2个block任务都执行完毕了,group队列组任务数减为0,才能执行dispatch_group_notify函数。

不管哪种方案,共同点都是只有group队列组中的任务全部执行完毕才会执行dispatch_group_notify函数。当然,这里用dispatch_group_wait一样会导致死锁,上面已经分析过了,这里就不说了。