【OC源码】GCD 源码实现分析


这里不细聊 GCD 的 api 使用和概念, 不像 runtime, 这儿没有太多难以理解的概念.
这篇着重于, 我看完常用的数据结构体及接口函数后, 对 GCD 整体结构的理解:

  • GCD 数据结构是啥
  • GCD 的队列(main, global)是从哪来的, 自己创建的队列与他是啥关系
  • 我们使用 async, sync 时内部是怎样的流程
  • 他又是怎么实现执行任务控制(semaphore, group, barrier)的
  • 其实最想的是想清楚这套框架的设计思路, 但目前只刚到了解原理, 还得花时间逐步深入

源码部分分布于各其他文章中, 里面主要是尝试读懂代码, 读完代码后的结论, 我放在本篇中:

参考文章: 逆水行舟
遗留问题:

  • ❓dispatch_barrier_async 是怎么实现让后面的执行内容等待前面执行完的
  • ❓底层是怎么唤醒让 GCD 的链表元素逐个执行的
  • ❓创建线程在哪创的

基本数据结构

GCD 中常用的数据结构, 数据结构源码解析在此
里面具体探究了源码及内部宏的展开结构, 之后不再赘述.

上图中, 下层的宏字段由其上层的宏拼接出来, 需要知道的是, GCD 的一些常见数据类型都有以下特性:

  • 都准备作为 链表节点 使用: do_next (在_DISPATCH_OBJECT_HEADER 宏中)
  • 都有决定在哪个 queue 执行的字段: do_targetq (在_DISPATCH_OBJECT_HEADER 宏中)
  • 都有内部和外部引用计数: ref_cnt xref_cnt(在 _OS_OBJECT_HEADER 宏中)
  • 都有一个类似于 runtim 中 isa 作用的, 虚函数表: do_vtable (在 _OS_OBJECT_HEADER 宏中)
    • 虚函数表会在队列初始化时赋值
    • 在整个流程中, 常用到的
      • .dq_push: 各种 async 本质是将任务 push 到队列中
      • .dq_wakeup: 异步操作, 在尝试正在执行任务前, 都是通过 wake
      • do_dispose 销毁函数,
      • do_invoke 触发执行函数

所有调用 GCD 传入的执行内容(block 或 函数)都会被包装成dispatch_continuation_s
(这个东西是独立的一套字段,和上面的宏没关系, 但是长的基本一样其他: GCD 的 数据结构 源码解析)

关于虚函数的使用

同步我们在后面会看到, 最简化 的流程是包装出dispatch_continuation_s, 然后直接当场执行
而异步复杂的多:

  • dispatch_async 直接执行的只有 dq_push
  • dq_push 的主体是前面提到的dispatch_continuation_s, 由他执行的 push
  • 然后后续有一套唤醒流程, 调用的是dq_wakeup
    do_dispose 主要在销毁时使用, 大部分的数据结构的 do_dispose 最终调的是 dealloc

关于主线程

需要注意的是, 主线程是特殊的! 主线程的数据结构是独立的dispatch_queue_static_s
比普通的 dispatch_queue_s, 内部主要多出一个链表 dq_items_tail, dq_items_head
并且在开始时已直接初始化好了

struct dispatch_queue_static_s _dispatch_main_q = {  
  DISPATCH_GLOBAL_OBJECT_HEADER(queue_main),  
  .do_targetq = _dispatch_get_default_queue(true),  
  .dq_state = DISPATCH_QUEUE_STATE_INIT_VALUE(1) |  
      DISPATCH_QUEUE_ROLE_BASE_ANON,  
  .dq_label = "com.apple.main-thread",  
  .dq_atomic_flags = DQF_THREAD_BOUND | DQF_WIDTH(1),  
  .dq_serialnum = 1,  
};  

关于线程控制数据结构

semaphoregroup 的原理类似 (甚至看一些参考文章, 早期 group 内部是用 semaphore 实现的)
都是通过对一个标志位的 + - 操作, 来控制是否执行
group :

  • 使用 dg_state / dg_bits / dg_gen 作为执行控制, 这三玩意是个联合体.三个字段代表的其实是同一块内部地址, 使用中, 经常换字段通过来表达某种含义.
  • groupsemaphore多出来的是其内部存了 notify 的链表dg_notify_head,dg_notify_tail 在合适的时机,这些 notify 会被逐个取出来执行

semaphore:

  • 使用 dsema_value 控制执行, 使用dsema_orig 记录原始值

队列从哪来

系统默认提供的队列

前面提到, dispatch_main_queue 得天独厚, 从一开始就已经直接被赋值了, 这里不再赘述
我们看常用的 queue, dispatch_global_queue, 相关的源码解析我放在: 其他: GCD 函数源码 — GETCreate
get_queue 内部的核心部分叫 get_root_queue, 可以推测 GCD 底层有一个 Root Queue 的概念.
我们在源码中将 dispatch_get_global_queue 追到低, 最终会发现

get_global_queue 本质上是从已经生成的数组中,找一个与入参相符的元素
return &_dispatch_root_queues[2 * (qos - 1) + overcommit];

_dispatch_root_queues[] 这个变量中, 定义了序号 4~15 共 12 初始队列, 这些队列名字在调试时会经常看到

  • ”com.apple.root.maintenance-qos"
  • ”com.apple.root.maintenance-qos.overcommit"
  • "com.apple.root.background-qos"
  • ”com.apple.root.background-qos.overcommit"
  • "com.apple.root.utility-qos"
  • "com.apple.root.utility-qos.overcommit"
  • "com.apple.root.default-qos"
  • "com.apple.root.default-qos.overcommit"
  • "com.apple.root.user-initiated-qos"
  • "com.apple.root.user-initiated-qos.overcommit"
  • "com.apple.root.user-interactive-qos"
  • "com.apple.root.user-interactive-qos.overcommit"

我们还注意到, main_queue 是 1, 这里是 4~15, 那么 2,3 哪去了?
被放在两个 mgr_dispatch_mgr_q, _dispatch_mgr_root_queue (我还没研究透, 先不深入)
overcommit 据一些介绍文章说, 是会独立开辟线程. 这点也是暂时源码还没看透, 先不深入

我们创建的队列

GCD 有提供dispatch_queue_create & dispatch_queue_create_with_target 接口供我们创建新的队列
源码解析写在 其他: GCD 函数源码 — GETCreate

从源码中可以得知, 这两函数内部都是在调用同一个函数 _dispatch_lane_create_with_target
区别在于不同的入参会导致最后 target 有所不同, 这个 target, (上面数据结构中, 能看到基本数据结构都带着 do_targetq), 将决定行为在哪发生
(入参中的 dispatch_queue_attr_s 在源码中是只有 OS_OBJECT_STRUCT_HEADER 宏的结构体, 也就是其中只包含一些基本的字段,可以理解只是 GCD 中最简单的基类)

省略掉中间的一些列过程, 会发现:

  • 如果没指定 Target, 那么新建的 Queue, 其 Target 会变成 Root Queue 的其中一个
  • 如果指定了 Target, 那么新建的 Queue, 其 Target 会直接或间接的变成 Root Queue 的其中一个
    (因为没有 target 不是 root queue的新 queue, 无论怎么引用, 最终都会跑到 root queue 身上)
    也就是说 我们创建的 queue 中的任务执行时其实是被初始定义的 root_queue 所控制的

队列是怎么用的

源码分析在这: 其他: GCD 函数源码 — dispatch_sync &dispatch_async

sync

这个 sync 不单指dispatch_sync而是 GCD api 的所有 sync 操作,
源码中函数跳转很多很多, 关注里面提到的执行流程:

  • 串行的情况下, 主流程共 8 步, 中间涉及函数主要是 barrier 类型, 最终调用函数为 _dispatch_client_callout
  • 并发的情况下, 主流程共 6 步, 中间涉及的函数直接就叫 sync 不带 battier 前缀, 最终函数_dispatch_client_callout

可以看到 不论并发还是串行, 最终的执行都是一样的, _dispatch_client_callout 是直接对入参函数的调用
在这个过程中, 一旦出现无法直接执行的条件, 最终都会跳入_dispatch_sync_f_slow, 根据上下文选择等待执行的策略
可以理解为 sync 就是根据状态判断是否能直接执行任务, 能则直接执行, 不能则选择一种等待策略

async

异步的操作我们要明确一件事, 不论 dispatch_async, dispatch_group_async, dispatch_barrier_async 等.他们直接的操作,都只是将任务封装成 _dispatch_continuation_t 并设置对应的 flag, 然后执行_dispatch_continuation_async
async 实质的直接操作只有 push: 将任务推到某个队列中
(后面我们讲的到 async, 最终都是不同 flag 的 push)

push 到队列后, 之后的调度到了系统底层, 这一步开始已经脱离了 async 流程
由系统底层控制, 这里开始我追溯起来太吃力. 只能根据参考文章, 找到底层调用的上层的函数_dispatch_worker_thread2 开始执行流程.
这套执行流程内部会 逐个将链表元素取出, 然后执行 do_invoke

所以总结来看, async 的任务其实分两步, 第一步是将执行体扔到链表中; 第二部是底层触发的调用, 逐个遍历了链表取出,然后执行

ps. 很遗憾的是, 我最终没弄明白被 push 的任务最后的触发执行流程以及对 Flag 的控制, GCD 有个很重要的点就在函数触发前如何控制这里.
通过参考文章, 后续流程已经到 XNU. 而我在找函数时, 底层大多都是汇编实现, 找不到主动将 push 的内容拿出来执行的地方(能找到最终执行函数,往上找调用者,分支太多了,不知道哪一分支是)

执行控制

常用的执行控制有四个

  • dispatch_once: 执行一次
  • dispatch_barrier: 前面执行完才能执行后面
  • dispatch_group: 组内执行完统一处理
  • dispatch_semaphore: 卡主线程,直到允许放行

dispatch_once

dispatch_once 重点在于怎么在多线程环境下只执行一次,
这个实现挺好想到的, 执行前用一个标志位做判断, 执行后将标志位改回来. 这两个操作放在同一个锁或同一个原子操作中
(分开处理就可能出现, 一个开始判断, 另一个线程准备改值,然后同时进入执行函数的情况)
源码解析在这: 其他: GCD 函数源码 — dispatch_once
苹果实现是 对比较和修改值作为原子操作, 所以避开了比较后另一个线程同时进入比较的情况
核心代码是下面的函数, 入参1 与入参 2 比较, 相等的情况, 入参 1 被改为入参 3

 os_atomic_cmpxchg(&l->dgo_once, DLOCK_ONCE_UNLOCKED, (uintptr_t)_dispatch_lock_value_for_self(), relaxed);  

这比较有意思的是 _dispatch_lock_value_for_self, 内部取了一个特定的线程叫 _PTHREAD_TSD_SLOT_MACH_THREAD_SELF
然后用这个线程的 tid & 一个值 来作为锁标记

semaphore & group

这两用途不同, 内部实现的原理是类似的, 都通过一个标志位, 判断当前是否能执行 ”后续的工作”

类似的控制逻辑

初始化: 初始化没太多骚操作, 都是 alloc 对应的结构体对象然后赋予初始值
标志位: 参考对数据结构的解析 其他: GCD 数据结构 源码解析:

  • semaphore 通过数据结构中的 dsema_value 控制,
  • group 通过数据结构中下面这个联合体控制, 这里三个字段,在源码中会根据需要出现, 实际上, 都是代表同一地址下的数据
    DISPATCH_UNION_LE(uint64_t volatile dg_state,  
        uint32_t dg_bits,  
        uint32_t dg_gen  
    ) DISPATCH_ATOMIC64_ALIGN;  
    

semaphoresignal wait, groupenter, leave 函数内部简化后的操作, 都有在对这个标志位做原子加减.

做的事情: 参考对函数的解析其他: GCD 函数源码 — semaphore & group & barrier:

  • semaphore_wait: 一旦 -1 后发现标志位小于 0, 就不止执行 return, 而是走到 do-while, 卡死线程
    (执行函数在_dispatch_sema4_timedwait_dispatch_sema4_wait, 内部都是 do-while)
  • group_leave: 一旦 -1 后发现标志位等于 0, 就会触发 wake, 内部实质是遍历他的 notify 链表,逐个执行
    (执行力函数在 _dispatch_group_wake, 内部是 do-while 遍历 group 存的链表)

其他函数:

dispatch_group_notify: 前文中提过 group 里有 notify 的头节点和尾节点, 这个函数简化后,就是在往链表中加数据
dispatch_group_async: 实质是两个函数的集成 1.dispatch_group_enter , 2._dispatch_continuation_async

  • 其内部最终的步骤是 _dispatch_continuation_async(我们会发现 async 操作,最终都是这个)
  • 与其他 async 不同在与, flag 为 DC_FLAG_CONSUME | DC_FLAG_GROUP_ASYNC
  • 没有直接的 leave, leave 在任务真正执行时调用的 _dispatch_continuation_with_group_invoke

不同的行为

semaphore 来说, 他控制执行的方式是堵住当前线程, 直到外界条件允许.
group, 不会干影响当前线程的事, 它主要是对加进自己组中的内容做管理, 并在组内任务都完成时统一执行一些操作
semaphore 有一点特殊的是, 他会在销毁时检查 dsema_value, 如果和 dsema_orig 不一致, 就会引发 crash

  if (dsema->dsema_value < dsema->dsema_orig) {  
    DISPATCH_CLIENT_CRASH(dsema->dsema_orig - dsema->dsema_value,  
        "Semaphore object deallocated while in use");  
  }  

dispatch_barrier_async

函数源码解析在这 其他: GCD 函数源码 — semaphore & group & barrier
barrier_async 操作其实没太多可看的, 基本上所有的 async 操作, 都是调用当前结构体, 函数表中的 dx_push
也就是说 async 操作, 本质是把一个任务 push 到队列中, 然后再视情况要不要下一步

大部分的 push, 是由任务本身(block 或函数) 封装出来的结构体_dispatch_continuation_t 执行的 push

barrier_async 的特殊之处在于, 其 push 的入参 flag 为 DC_FLAG_CONSUME | DC_FLAG_BARRIER
barrier并没有直接控制执行过程, 而是通过任务的参数, 在执行过程中间接的影响过程

需要注意的, barrier 的队列必须是自己创建的队列

在整个 GCD 调用流程中,有很多代码在判断_dispatch_queue_try_acquire_barrier_sync, 我用DC_FLAG_BARRIER追溯, 怀疑是这个判断, 让处在栅栏后的内容没有执行