天天看点

iOS刨根问底-深入理解GCD

iOS刨根问底-深入理解GCD

做过iOS开发的同学相信对于GCD(Grand Central Dispatch)并不陌生,因为在平时多线程开发过程中GCD应该是使用最多的技术甚至它要比它的上层封装NSOperation还要常用,其中最主要的原因是简单易用功能强大。本文将从GCD的原理和使用两个层面分析GCD的内容,本文会结合源码和实例分析使用GCD的注意事项,源码解读部分主要通过注释源码的方式方便进行源码分析,具体到细节通过在源码解释说明。

iOS刨根问底-深入理解GCD

和前面一篇文章深入了解Runloop一样GCD的代码是开源的(也可以直接从苹果官网下载),这样要弄清GCD的很多实现原理就有了可能,所以文中不涉及的很多细节大家可以通过源代码进行了解。下面让我们看一下关于常见的几个类型的源码:

dispatch_queue_t应该是平时接触最多的一个GCD类型,比如说创建一个队列,它返回的就是一个dispatch_queue_t类型:

<code>dispatch_queue_t serialDispatch = dispatch_queue_create("com.cmjstudio.dispatch", nil);</code>

通过查看源码可以看到dispatch_queue_t的定义:

上面的源代码拆分过程尽管繁琐但是每一步都可以在源码中顺利的找到倒也不是太复杂。最终可以看到 dispatch_queue_t 本身存储了我们平时常见的label、priority、specific等,本身就是isa指针和引用计数器等一些信息。

需要说明的是 dispatch 版本众多,如果查看当前版本可以直接打印<code>DISPATCH_API_VERSION</code>即可。

dispatch_queue_create 用于创建一个队列,返回类型是上面分析过的dispatch_queue_t ,那么现在看一下如何创建一个队列:

从源码注释也可以看出主要有两步操作,第一步是 Normalize arguments,第二部才是真正创建队列,忽略一些参数规范化操作。首先<code>_dispatch_get_root_queue</code>用于获取root队列,它有两个参数:一个是队列优先级(有6个:userInteractive&gt;default&gt;unspecified&gt;userInitiated&gt;utility&gt;background),另一个是支持不支持过载overcommit(支持overcommit的队列在创建队列时无论系统是否有足够的资源都会重新开一个线程),所以总共就有12个root队列。对应的源代码如下(其实是从一个数组中获取):

至于12个root队列可以查看源代码:

其实我们平时用到的全局队列也是其中一个root队列,这个只要查看<code>dispatch_get_global_queue</code>代码就可以了:

可以很清楚的看到,<code>dispatch_get_global_queue</code>的本质就是调用_dispatch_get_root_queue,其中的flag只是一个苹果予保留字段,通常我们传0(你可以试试传1应该队列创建失败),而代入上面的数组当使用<code>dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0)</code>。如果打印这个返回结果可以看到:

首先通过上面数组进行索引<code>2 * (qos - 1) + overcommit</code> = 2*(4-1)+0 = 6 ,可以索引得到 dq_serialnum=10的队列,刚好label=com.apple.root.default-qos。至于qos参数为什么是4呢?

然后我们分析一下<code>dispatch_queue_create</code>中的<code>DISPATCH_VTABLE</code>这个宏:

解析之后就是按队列类型分别获取不同队列类型的类: OS_dispatch_queue_concurrent_class 和 OS_dispatch_queue_serial_class ,对比我们平时打印一个队列的信息(如下),可以看到 OS_dispatch_queue_serial 或者 OS_dispatch_queue_concurrent_class :

接着看<code>_dispatch_object_alloc</code>和<code>_dispatch_queue_init</code>,分别用于申请对应类型的内存和初始化。首先看前者的实现:

然后看一下内存分配之后的初始化<code>_dispatch_queue_init</code>源码,也只是简单的进行了初始化工作,不过值得一提的是<code>dqai.dqai_concurrent ? DISPATCH_QUEUE_WIDTH_MAX : 1</code>这个参数,<code>DISPATCH_QUEUE_WIDTH_MAX</code>其实看一下源码就知道是0x1000ull-2就是0xffe,而如果是串行队列就是1,这也是为什么可以在上面打印中看到<code>width = 0x1</code>的原因,width本身就是并发数的个数,对于串行队列是1而对于并发队列是不限制的(回过头去看全局队列width为什么是0xfff呢,因为它的width是#define DISPATCH_QUEUE_WIDTH_POOL (DISPATCH_QUEUE_WIDTH_FULL - 1)

)=0x1000ull-1:

接着看<code>dispatch_queue_create</code>的<code>dq-&gt;do_targetq = tq;</code>这句话是什么意思呢?这个其实是当使用<code>dispatch_queue_create</code>创建的自定义队列(事实上包括主队列和管理队列,也就是非全局队列[可以看一下上面的源代码全局队列并没有设置do_targetq,但是事实上它本身就是root队列]),都需要压入到全局队列(这里指的是root队列)进行处理,这个目标队列的目的就是允许我们将一个队列放在另一个队列里执行任务。看一下上面创建自定义队列的源码不难发现,如果是自定义一个串行队列其实最终就是一个root队列。

为了验证上面关于主队列也是root队列的说法不放看一下主队列的源码:

可以看到主队列do_targetq也是一个root队列(通过获取_dispatch_root_queues),DISPATCH_ROOT_QUEUE_IDX_DEFAULT_QOS =6 所以 <code>_dispatch_root_queues[6+1]</code>就是<code>com.apple.root.default-qos.overcommit</code>,不妨打印一些主队列(如下),可以看到target正是<code>com.apple.root.default-qos.overcommit</code>,而且width=1,其次由于<code>dispatch_queue_main_t</code>是对dispatch_queue_serial的重写所以也是一个串行队列:

到了这里关于队列的创建我们已经基本介绍完了,可以看到不管是自定义队列、全局队列还是主队列最终都直接或者间接的依赖12个root队列来执行任务调度(尽管如此主队列有自己的label,如果按照label计算总共16个,除了上面的12个,就是<code>com.apple.main-thread</code>还有两个内部管理队列<code>com.apple.libdispatch-manager</code>和<code>com.apple.root.libdispatch-manager</code>以及runloop的运行队列)。下面看一下几个常用的队列任务的执行方法的源码,对于任务的执行GCD其实主要用两个方法<code>dispatch_sync</code>和<code>dispatch_async</code>。

上面提到一个重要概念是overcommit,overcommit的队列在队列创建时会新建一个线程,非overcommit队列创建队列则未必创建线程。另外width=1意味着是串行队列,只有一个线程可用,width=0xffe则意味着并行队列,线程则是从线程池获取,可用线程数是64个。

可以看到全局队列是非overcommit的(flat保留字只能传0,如果默认优先级则是com.apple.root.default-qos,但是width=0xffe是并行队列);主队列是overcommit的com.apple.root.default-qos.overcommit,不过它是串行队列,width=1,并且运行的这个线程只能是主线程;自定义串行队列是overcommit的,默认优先级则是 com.apple.root.default-qos.overcommit,并行队列则是非overcommit的。

这里看一下为什么上面说并行队列最大线程数是64个,不妨结合几个例子来查看:

可以看到对于 dispatch_asyn 的调用(同步操作线程都在主线程不再赘述)串行队列是overcommit的,创建队列会创建1个新的线程,并行队列是非overcommit的,不一定会新建线程,会从线程池中的64个线程中获取并使用。另外上面的dispatch_set_target_queue 操作和前面源码中的do_targetq是作用一样的。

这样以来反而串行队列是开发中应该注意的,因为一旦新建一个串行队列就会新建一个线程,避免在类似循环操作中新建串行队列,这个上限是多少是任意多吗?其实也不是最多新增512个(不算主线程,number从4开始到515)但是这明显已经是灾难性的了。另外对于多个同一优先级的自定义串行队列(比如:com.apple.root.default-qos.overcommit)对于 dispatch_asyn 调用又怎么保证调用顺序呢?尽管是overcommit可以创建多个线程,毕竟都在一个root队列中执行,优先级又是相同的。

先看一段代码:

三次执行顺序依次如下:

确实单次执行都创建了新的线程(和前面说的 overcommit 是相符的),但是执行任务的顺序可以说是随机的,这个和线程调度有关,那么如果有比较重的任务会不会造成影响呢?这个答案是如果都分别创建了队列(overcommit)一般不会有影响,除非创建超过了512个,因为尽管是同一个root队列但是会创建不同的线程,此时当前root队列仅仅控制任务FIFO,但是并不是只有第一个任务执行完第二个任务才能开始,也就是说FIFO控制的是开始的节奏,但是任务在不同的thread执行不会阻塞。当然一个串行队列中的多个异步task是相互有执行顺序的,比如下面的代码task2一定会被task1阻塞,但是都不会阻塞task3:

可以看到首先通过width判定是串行队列还是并发队列,如果是并发队列则调用<code>_dispatch_sync_invoke_and_complete</code>,串行队列则调用<code>_dispatch_barrier_sync_f</code>。先展开看一下串行队列的同步执行源代码:

首先获取线程id,然后处理死锁的情况,因此这里先看一下死锁的情况:

队列push以后就是用<code>_dispatch_lock_is_locked_by</code>判断将要调度的和当前等待的队列是不是同一个,如果相同则返回YES,产生死锁<code>DISPATCH_CLIENT_CRASH</code>;如果没有产生死锁,则执行 _dispatch_trace_item_pop()出队列执行。如何执行调度呢,需要看一下<code>_dispatch_sync_invoke_and_complete_recurse</code>?

可以比较清楚的看到最终执行f函数,这个就是外界传过来的回调block。

可以看到<code>dx_push</code>已经到了<code>_dispatch_root_queue_push</code>,这是可以接着查看<code>_dispatch_root_queue_push</code>:

到了这里可以清楚的看到对于全局队列使用<code>_pthread_workqueue_addthreads</code>开辟线程,对于其他队列使用<code>pthread_create</code>开辟新的线程。那么任务执行的代码为什么没看到?其实_dispatch_root_queues_init中会首先执行第一个任务:

另外对于<code>_dispatch_continuation_init</code>的代码中的并没有对其进行展开,其实_dispatch_continuation_init中的<code>func</code>就是<code>_dispatch_call_block_and_release</code>(源码如下),它在<code>dx_push</code>调用时包装进了<code>qos</code>。

dispatch_async代码实现看起来比较复杂,因为其中的数据结构较多,分支流程控制比较复杂。不过思路其实很简单,用链表保存所有提交的 block(先进先出,,在队列本身维护了一个链表新加入block放到链表尾部),然后在底层线程池中,依次取出 block 并执行。

类似的可以看到<code>dispatch_barrier_async</code>源码和dispatch_async几乎一致,仅仅多了一个标记位<code>DC_FLAG_BARRIER</code>,这个标记位用于在取出任务时进行判断,正常的异步调用会依次取出,而如果遇到了<code>DC_FLAG_BARRIER</code>则会返回,所以可以等待所有任务执行结束执行dx_push(不过提醒一下dispatch_barrier_async必须在自定义队列才有用,原因是global队列没有v_table结构,同时不要试图在主队列调用,否则会crash):

下面的代码在objc开发中应该很常见,这种方式可以保证instance只会创建一次:

不放分析一下dispatch_once的源码:

说到这里,从swift3.0以后已经没办法使用dispach_once了,其实原因很简单因为在swift1.x的<code>static var/let</code>属性就已经是<code>dispatch_once</code>在后台执行的了,所以对于单例的创建没有必要显示调用了。但是有时候其他情况我们还是需要使用单次执行怎么办呢?代替方法:使用全局变量(例如创建一个对象实例或者初始化成一个立即执行的闭包:let g = {}();_ = g;),当然习惯于dispatch_once的朋友有时候并不适应这种方法,这里给出一个比较简单的方案:

dispatch_after也是一个常用的延迟执行的方法,比如常见的使用方法是:

在查看<code>dispatch_after</code>源码之前先看一下另一个内容事件源<code>dispatch_source_t</code>,其实<code>dispatch_source_t</code>是一个很少让开发者和GCD联想到一起的一个类型,它本身也有对应的创建方法<code>dispatch_source_create</code>(事实上它的使用甚至可以追踪到Runloop)。多数开发者认识<code>dispatch_source_t</code>都是通过定时器,很多文章会教你如何创建一个比较准确的定时器,比如下面的代码:

如果你知道上面一个定时器如何执行的那么下面看一下dispatch_after应该就比较容易明白了:

代码并不是太复杂,无时间差则直接调用<code>dispatch_async</code>,否则先创建一个<code>dispatch_source_t</code>,不同的是这里的类型并不是<code>DISPATCH_SOURCE_TYPE_TIMER</code>而是<code>_dispatch_source_type_after</code>,查看源码不难发现它只是dispatch_source_type_s类型的一个常量和<code>_dispatch_source_type_timer</code>并没有明显区别:

而和dispatch_activate()其实和dispatch_resume() 是一样的开启定时器。那么为什么看不到<code>dispatch_source_set_event_handler</code>来给timer设置handler呢?不放看一下<code>dispatch_source_set_event_handler</code>的源代码:

可以看到最终还是封装成一个<code>dispatch_continuation_t</code>进行同步或者异步调用,而上面<code>_dispatch_after</code>直接构建了<code>dispatch_continuation_t</code>进行执行。

使用<code>dispatch_after</code>还有一个问题就是取消问题,当然通常遇到了这种问题大部分答案就是使用下面的方式:

不过如果你使用的是iOS 8及其以上的版本,那么其实是可以取消的(如下),当然如果你还在支持iOS 8以下的版本不妨试试这个自定义的dispatch_cancelable_block_t类:

如果你用的是swift那么恭喜你,很简单:

信号量是线程同步操作中很常用的一个操作,常用的几个类型:

dispatch_semaphore_t:信号量类型

dispatch_semaphore_create:创建一个信号量

dispatch_semaphore_wait:发送一个等待信号,信号量-1,当信号量为0阻塞线程,大于0则开始执行后面的逻辑(也就是说执行dispatch_semaphore_wait前如果信号量&lt;=0则阻塞,否则正常执行后面的逻辑)

dispatch_semaphore_signal:发送唤醒信号,信号量会+1

比如我们有个操作foo()在异步线程已经开始执行,同时可能用户会手动再次触发动作bar(),但是bar依赖foo完成则可以使用信号量:

那么信号量是如何实现的呢,不妨看一下它的源码:

信号量是一个比较重要的内容,合理使用可以让你的程序更加的优雅,比如说一个常见的情况:大家知道<code>PHImageManager.requestImage</code>是一个释放消耗内存的方法,有时我们需要批量获取到图片执行一些操作的话可能就没办法直接for循环,不然内存会很快爆掉,因为每个requestImage操作都需要占用大量内存,即使外部嵌套autoreleasepool也不一定可以及时释放(想想for执行的速度,释放肯定来不及),那么requestImage又是一个异步操作,如此只能让一个操作执行完再执行另一个循环操作才能解决。也就是说这个问题就变成for循环内部的异步操作串行执行的问题。要解决这个问题有几种思路:1.使用requestImage的同步请求照片 2.使用递归操作一个操作执行完再执行另外一个操作移除for操作 3.使用信号量解决。当然第一个方法并非普适,有些异步操作并不能轻易改成同步操作,第二个方法相对普适,但是递归调用本身因为要改变原来的代码结构看起来不是那么优雅,自然当前讨论的信号量是更好的方式。我们假设requestImage是一个bar(callback:((_ image)-&gt; Void))操作,整个请求是一个foo(callback:((_ images)-&gt;Void))那么它的实现方式如下:

可以看到信号量在做线程同步时简单易用,不过有时候不经意间容易出错,比如下面的代码会出现<code>EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)</code>错误,原因是之前的信号量还在使用:

为什么会这样呢?原因和上面<code>dispatch_semaphore_create</code>中的<code>DISPATCH_VTABLE(semaphore)</code>有关系,这个宏我们上面分析过,最终展开就是<code>OS_dispatch_semaphore_class</code>实例的引用,那么它的实例是什么呢?它当然是通过<code>_dispatch_object_alloc</code>创建的,沿着查找<code>_dispatch_object_alloc</code>的源码可以找到下面的代码:

不难看出就是依靠<code>class_createInstance</code>创建一个<code>OS_dispatch_semaphore_class</code>实例,这个代码在libdispatch是找不到的,它在runtime源码中。不过在这里可以找到它的实例的定义(其实类似的通过vtable结构创建的实例都包含在libdispatch的init.c中):

不难看出这个对象是包含一个dispose方法的,就是<code>_dispatch_semaphore_dispose</code>,我们可以看到它的源码,其实这里对我们排查问题最重要的就是if条件语句,信号量的当前值小于初始化,会发生闪退,因为信号量已经被释放了,如果此时没有crash其实就会意味着一直有线程在信号量等待:

<code>dispatch_group</code>常常用来同步多个任务(注意和<code>dispatch_barrier_sync</code>不同的是它可以是多个队列的同步),所以其实上面先分析<code>dispatch_semaphore</code>也是这个原因,它本身是依靠信号量来完成的同步管理。典型的用法如下:

下面看一下<code>dispatch_group</code>相关的源码:

简单的说就是<code>dispatch_group_async</code>和<code>dispatch_group_notify</code>本身就是和<code>dispatch_group_enter</code>、<code>dispatch_group_leave</code>没有本质区别,后者相对更加灵活。当然这里还有一个重要的操作就是<code>dispatch_group_wait</code>,还没有看:

上面第一个<code>dispatch_group</code>例子介绍的情况很简单,任务本身都是同步的,只是将一个同步任务放到了<code>dispatch_group_async</code>中,现实中这个操作可能是一个网络请求,你现在想让10个请求都完成后再执行某个操作怎么办(网络请求假设方法是request(url:String,complete:Callback))?你现在不可能在网络请求方法内部做出修改了,怎么保证操作同步呢?

之前看到过这种操作:

其实这种方法基本没有用<code>dispatch_group</code>,直接用信号量就可以解决,有了上面的分析使用<code>dispatch_enter</code>和<code>dispatch_leave</code>就可以了。

dispatch_apply设计的主要目的是提高并行能力(注意不是并发,等同于Swift中的DispatchQueue.concurrentPerform),所以一般我们用来并行执行多个结构类似的任务,比如:

在GCD中其实总共有两个线程池进行线程管理,一个是主线程池,另一个是除了主线程池之外的线程池。主线程池由序列为1的主队列管理,使用objc.io上的一幅图表示如下:

iOS刨根问底-深入理解GCD

大家都知道使用dispatch_sync很有可能会发生死锁那么这是为什么呢?

不妨回顾一下dispatch_sync的过程:

重点在<code>_dq_state_drain_locked_by(dq_state, dsc-&gt;dsc_waiter)</code>这个条件,成立则会发生死锁,那么它成立的条件就是<code>((lock_value ^ tid) &amp; DLOCK_OWNER_MASK) == 0</code>首先lock_value和tid进行异或操作,相同为0不同为1,然后和DLOCK_OWNER_MASK(0xfffffffc)进行按位与操作,一个为0则是0,所以若干lock_value和tid相同则会发生死锁。

__builtin_expect是一个针对编译器优化的内置函数,让编译更加优化。比如说我们会写这种代码:

如果我们更加倾向于使用a那么可将其设为默认值,极特殊情况下才会使用b条件。CPU读取指定是多条一起加载的,可能先加载进来的是a,那么如果遇到执行b的情况则再加载b,那么对于条件a的情况就造成了性能浪费。long __builtin_expect (long EXP, long C) 第一个参数是要预测变量,第二个参数是预测值,这样__builtin_expect(a,false)说明多数情况a应该是false,极少数情况可能是true,这样不至于造成性能浪费。其实对于编译器在汇编时会优化成<code>if !a</code>的形式:

看了likely和unlikely可以了解,likely表示更大可能成立,unlikely表示更大可能不成立。likely就是 if(likely(x == 0)) 就是if (x==0)。

第二个参数与第一个参数值比较,如果相等,第三个参数的值替换第一个参数的值。如果不相等,把第一个参数的值赋值到第二个参数上。

将第二个参数保存到第一个参数中

第一个参数赋值为1

第二个参数加1并返回

第二个参数-1并返回

dispatch_barrier_async

dispatch_apply()

iOS刨根问底-深入理解GCD

本作品采用知识共享署名 2.5 中国大陆许可协议进行许可,欢迎转载,演绎或用于商业目的。但转载请注明来自崔江涛(KenshinCui),并包含相关链接。