[Diving into WWDC 2017] Modernizing Grand Central Dispatch Usage

GCD 的现代化应用

前言:GCD 在 Swift 3.0 前后的区别

随着苹果推出 Swift 3.0,很多 API 都发生了变化,在本次 Seesion 开始之前,我们先来总结下 GCD 在 Swift3.0 前后的区别:

GCD 在 Swift 3.0 之后的变化主要体现在接口层面,设计思想层面并无明显变化。

  • 队列的创建

比如在 Swift 3.0 之前 我们创建一个queue是这样的:

        let queue = dispatch_queue_create("com.test.myqueue", nil)
        dispatch_async(queue) {
            print("Hello World")
        }

而在 Swift 3.0 后创建一个队列,执行一个任务都比以前要简单,易读性也好一些,看如下示例:

运行一下,看一下效果:

从结果我们可以看到,同步队列是阻塞式执行的,而异步队列和主线程的函数是交替执行 也就是说二者同步的输出。

这是因为:异步队列不会阻塞当前线程 而是会另开一个线程来执行当前的任务,而主线程上的任务也就不会被阻塞,所以二者是同步输出的。

但同时主线程有更多的执行机会(优先打印完),这个和队列间的优先级设定有关,主队列的优先级比较高,会优先执行,在主队列的任务执行的间隙,cpu 会调整策略让低优先级的得到一部分执行。

  • Swift 3.0 开始取消 dispatch_once.

  • dispatch_after 的用法改成如下:

let delayInSeconds = 1.0 // 1  
DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { // 2  
  //do something
}

其它类型的修改参考下表:

Old New
dispatch_object_t DispatchObject
dispatch_queue_t DispatchQueue
dispatch_group_t DispatchGroup
dispatch_data_t DispatchData
dispatch_semaphore_t DispatchSemaphore
dispatch_source_t DispatchSource
dispatch_time_t DispatchTime

本次 seesion 主要讨论三个主题:

  • 并行和并发
  • 运用GCD实现并发
  • 统一队列标识

并行(Parallelism)和并发(concurrency)

概念

并行是 CPU 的多核心同时执行多个任务,并发是 CPU 的单核心交替执行两个任务。 并行永远指的一个以上的处理器同时处理多个事情。

以 iPhone7 处理器 A10 为例, A10 处理器是双核单 CPU,却不是双核双 CPU,

Why?

多个单核 CPU,那么每一个 CPU 都需要有较为独立的电路支持,有自己的 Cache,而他们之间通过板上的总线进行通信。假如在这样的架构上,我们要跑一个多线程的程序(常见典型情况),不考虑超线程,那么每一个线程就要跑在一个独立的 CPU 上,线程间的所有协作都要走总线,而共享的数据更是有可能要在好几个 Cache 里同时存在。这样的话,总线开销相比较而言是很大的,怎么办?那么多 Cache,即使我们不心疼存储能力的浪费,一致性怎么保证?如果真正做出来,还要在主板上占多块地盘,给布局布线带来更大的挑战,怎么搞定?

如果我们选择多核单 CPU,那么我们只需要一套芯片组,一套存储,多核之间通过芯片内部总线进行通信,共享使用内存。在这样的架构上,如果我们跑一个多线程的程序,那么线程间通信将比上一种情形更快。如果最终实现出来,对板上空间的占用较小,布局布线的压力也较小。

这也解释了 iPhone 上的处理器都是多核单 CPU 的。

并行的任务通常是一组紧密相关又彼此独立的的计算任务,考虑下面的场景,图片实现一个碎片后向四周发散的效果,那么每2个图片直接的移动是彼此独立的,但所有的图片加起来又为了实现一个共同的动画效果, 这就可以充分利用多核并行进行。

GCD 中实现并行

GCD 为并行运算提供了很好的支持,通过 DispatchQueue.concurrentPerform 实现并行任务。

DispatchQueue.concurrentPerform(1000) { i in /* iteration i */ }  

DispatchQueue.concurrentPerform 和 Swift 3.0 之前的
dispatch_apply(DISPATCH_APPLY_AUTO, 1000, ^(size_t i){ /* iteration i */ }) 是一样的,

concurrentPerform 指定次数的 Block 追加到队列中 DispatchQueue.concurrentPerform 函数是 sync 函数和 Dispatch Group 的关联 API。按指定的次数将指定的 Block 追加到指定的 Dispatch Queue 中,并等待全部处理执行结束。
因为 concurrentPerform 函数也与 sync 函数一样,会等待处理结束,因此推荐在 async 函数中异步执行 concurrentPerform 函数。concurrentPerform 函数可以实现高性能的循环迭代。

GCD 中实现并行的优化

这里循环的次数是一个比较微妙的数字,比如如果我们有一个任务拆成 3 段并行执行,然后全部执行结束后,对外输出结果,则其运行效果可能是这样的:

GCD 中实现并发

并发 Concurrency 的概念

并发是指一个核心内执行各种独立的运算,譬如一个新闻类应用,有视图展示层(main thread),有网络请求模块,有数据存储模块。 考虑到主线程有较高的执行优先级,他们的执行过程可能是这样的:

UI 在主线程中进行,其拥有最高的优先级,在cpu内某个次级线程在执行任务的时候,如果发生了用户操作,则该次线程有可能被打断执行高优操作。并发机制几乎在所有平台,所有语言都有很好的支持,他的好处是毋庸置疑的:

  1. 可以提高资源利用率, 机器上我们谈到资源,一般指的是 CPU,但通常情况下网络或磁盘的 I/O 要比内存 I/O 慢的多,所以我们在执行频繁 I/O 的任务时,CPU 很多时候都处于闲置状态。这时如果我们开启多个线程,在 A 线程 I/O 的同时让 CPU 执行 B,在 B 线程 I/O 的同时再执行 A。这样就比 AB 串行执行时 CPU 的利用率更高。
  2. 响应更快,我们在主线程接受用户请求后,将耗时操作交给子线程,然后告诉用户在等待的同时还可以干点别的。

并发的缺点

并发的主要原理是操作系统利用时间片轮转的方式,

CPU 给每个任务都服务一定的时间, 然后把当前任务的状态保存下来, 在加载下一任务的状态后, 继续服务下一任务,时间片足够细,让用户感觉这些任务正在同时进行。 而任务的状态保存及再加载, 这段过程就叫做上下文切换。CPU 从一个线程切换到另外一个线程,需要保存当前任务的运行环境,恢复将要运行任务的运行环境,必然带来性能消耗。

上下文切换的性能消耗 Context Switchs 过高,导致 CPU 就像个搬运工一样,频繁在寄存器 (CPU Register) 和运行队列 (run queue) 之间奔波,系统更多的时间都花费在线程切换上,而不是花在真正做有用工作的线程上。

直接消耗包括: CPU 寄存器需要保存和加载, 系统调度器的代码需要执行, TLB 实例需要重新加载, CPU 的 pipeline 需要刷掉。

间接消耗:多核的 cache 之间得共享数据。间接消耗对于程序的影响要看线程工作区操作数据的大小。

过多的线程间切换,就像下面这样,白块都是用于线程间的资源消耗,有个 task 可能只需要 20 us,但是线程间切换就需要 10 us。

通常引起线程间切换的原因: 对于我们经常使用的抢占式操作系统来说, 引起上下文切换的原因大概有以下几种:

  1. 当前执行任务的时间片用完之后, 系统 CPU 正常调度下一个任务
  2. 当前执行任务碰到 I/O 阻塞, 调度器将挂起此任务, 继续下一任务
  3. 多个任务抢占锁资源, 当前任务没有抢到,被调度器挂起, 继续下一任务
  4. 用户代码挂起当前任务, 让出 CPU 时间

并发和锁

和多线程伴生最多的概念就是锁(Lock)了, 多线程间难免对同一个资源感兴趣,为了保证资源的状态有序,我们需要对资源的读和写进行控制:

对于锁,我们在开发中接触到的有:

  • NSLock 最基本的互斥锁
  • NSRecursiveLock 递归锁,可以被一个线程多次获得,而不会引起死锁。
  • NSCondition 条件,包含一个锁和一个线程检查器。
  • NSConditionLock 条件锁, 不需要检查器,条件锁自带一个探测条件,是否满足
  • pthread_mutex POSIX 互斥锁是一种超级易用的互斥锁,比较熟悉用的较多。
  • pthread_rwlock 读写锁,在对文件进行操作的时候,写操作是排他的,一旦有多个线程对同一个文件进行写操作,后果不可估量,但读是可以的,多个线程读取时没有问题的
  • OSSpinLock 自旋锁,和互斥锁类似,都是为了保证线程安全的锁。但二者的区别是不一样的,对于互斥锁,当一个线程获得这个锁之后,其他想要获得此锁的线程将会被阻塞,直到该锁被释放。但自旋锁不一样,当一个线程获得锁之后,其他线程将会一直循环在哪里查看是否该锁被释放。
  • osunfairlock apple为了解决优先级反转提供的锁,下面会单独讲一下。
  • dispatch_semaphore 信号量机制实现锁. 用的也是比较多的。
  • @synchronized 一个便捷的创建互斥锁的方式,它做了其他互斥锁所做的所有的事情。性能堪忧。

上面的各种锁讲到一些概念, 自旋锁,互斥锁

  • 互斥锁(mutex):当上一个线程的任务没有执行完毕的时候(被锁住),那么下一个线程会进入睡眠状态等待任务执行完毕 (sleep-waiting),当上一个线程的任务执行完毕,下一个线程会自动唤醒然后执行任务。
  • 自旋锁(Spin lock):当上一个线程的任务没有执行完毕的时候(被锁住),那么下一个线程会一直等待(busy-waiting),当上一个线程的任务执行完毕,下一个线程会立即执行。

看起来自旋锁不会进入睡眠,效率较高,但当自旋锁一直锁定一个资源的时候,有可能触发优先级反转,什么是优先级反转?

高优先级和低优先级的任务之间共享资源时,就可能发生优先级反转。

当低优先级的任务获得了共享资源的锁时,该任务应该迅速完成,并释放掉锁,这样高优先级的任务就可以在没有明显延时的情况下继续执行。

然而高优先级任务会在低优先级的任务持有锁的期间被阻塞。如果这时候有一个中优先级的任务(该任务不需要那个共享资源),那么它就有可能会抢占低优先级任务而被执行,因为此时高优先级任务是被阻塞的,所以中优先级任务是目前所有可运行任务中优先级最高的。

此时,中优先级任务就会阻塞着低优先级任务,导致低优先级任务不能释放掉锁,这也就会引起高优先级任务一直在等待锁的释放。

对应 iOS 平台,苹果为了解决优先级反转的问题,提供了一个新的锁 osunfairlock_t 解决了优先级反转问题。

os_unfair_lock_t unfairLock;  
unfairLock = &(OS_UNFAIR_LOCK_INIT);  
os_unfair_lock_lock(unfairLock);  
os_unfair_lock_unlock(unfairLock);

锁的拥有者: 像 pthreadmutex,osunfairlock,锁住的资源同时只能被一个线程拥有,其他都需等待。而像信号量(dispatchsemaphore),条件(nscondition)锁住的资源并不归属某个线程拥有,满足了条件的线程会被允许通过,read 或 write 资源。而像 pthread_rwlock,可能有多个线程同时拥有这个共享资源的读权限。

Using GCD for Concurrency

Serial Dispatch Queue 串行队列

串行队列是指队列中的任务是一个接一个地执行的,队首的任务执行完毕后才能执行其后面的任务,直至执行队尾的任务(使用一个线程),串行队列有如下特点:

Fundamental GCD primitive

  • 互斥(Mutual exclusion), 队列内不存在2个任务同时执行
  • 队列存储(FIFO ordered),先进先出(执行)
  • 入队是原子操作级别(Concurrent atomic enqueue) 避免乱序
  • 逐个出队执行(Single dequeuer)

考虑如下代码的执行效果,前2个任务是异步,第三个任务是同步操作,运行结果是什么呢?

运行结果是:

Dispatch Source

Dispatch Source 是 GCD 中的一种基本数据类型,从字面意思可称其为调度源,它用于处理特定的系统底层事件,即:当一些特定的系统底层事件发生时,调度源会捕捉到这些事件,然后可以做相应的逻辑处理。

创建一个 dispatch_source 的一般做法:

  1. 创建 dispatch_source

  2. 设置事件处理器

  3. 设置取消处理器

  4. 更改目标队列 可以和1,在初始化的时候做。

  5. 激活

Quality of Service

在 Swift 3.0 之前我们使用 global 队列,系统也提供了 4 种优先级:

DISPATCH_QUEUE_PRIORITY_HIGH,DISPATCH_QUEUE_PRIORITY_DEFAULT,DISPATCH_QUEUE_PRIORITY_LOW,DISPATCH_QUEUE_PRIORITY_BACKGROUND

GCD内队列主要分为主队列(main queue),四种通用调度队列,自己定制的队列。其中4种通用调度队列分布为:

  • QOS_CLASS_USER_INTERACTIVE:user interactive等级表示任务需要被立即执行提供好的体验,用来更新UI,响应事件等。这个等级最好保持小规模。
  • QOS_CLASS_USER_INITIATED:user initiated等级表示任务由UI发起异步执行。适用场景是需要及时结果同时又可以继续交互的时候。
  • QOS_CLASS_UTILITY:utility等级表示需要长时间运行的任务,伴有用户可见进度指示器。经常会用来做计算,I/O,网络,持续的数据填充等任务。这个任务节能。
  • QOS_CLASS_BACKGROUND:background等级表示用户不会察觉的任务,使用它来处理预加载,或者不需要用户交互和对时间不敏感的任务。

并发的粒度(Granularity of Concurrency)

在上面我们有讲到过线程间切换的成本 和 锁的性能代价。那么我们如何控制并发的粒度呢?考虑下面一个非常常见的场景:

考虑我们有一个数组,数组里是待拉取正文的文章 id, 我们可以通过一个 dispatch_group 去做这个事情,当网络 I/O 需要时间的时候,CPU 发现有空闲的算力,就会尽可能的分配更多的下载线程, 这样导致实际的请求任务非常多,就像下面这样:

正确的做法是把每个下载线程指向一个最终的队列 EQ, EQ 来统一调配可以并发的资源:

Avoid Unbounded Concurrency

另外我们在开发中经常的一种做法是把任务交给 global queue,譬如数据下载任务和数据存储任务都放在 global queue 里。有时候优先级也是一样的, 但有一个任务 block 的时候,更多的线程被创建,甚至导致线程爆炸。其实更好的做法是 One Queue Hierarchy per Subsystem。 即每一类任务有一个串行队列。

Introducing Unified Queue Identity 统一队列标识

统一队列标识是指我们在工程中散落在各处的创建队列,如果队列标识是一样的,他们在内核中会被 bind 在一起,其效率可以提高 30%。Apple 没有告诉我们其内核是怎么做到的,它提供了这样的建议,如果一类操作重要性程度或其他属性接近,亦或开发者希望散落在工程各处的代码可以放在同一个队列里去控制,那么我们在创建队列的时候就可以指定一个共同的标识符。 然后系统在内核中会把这些标识相同的队列 bind 到一起来管理。

总结

Swift 3.0 后对 GCD 的代码创建进行了一些优化,可读性更佳,本文没有列出所有 GCD 在 Swift 3.0后的改变,有兴趣的可以移步 WWDC Session 进行学习。 在本 Session 中 Apple 主要给出了一些如何高效利用 GCD 的最佳实践指南,如果作者有理解不到位的地方,希望大家多指正。

参考资料

https://devstreaming-cdn.apple.com/videos/wwdc/2017/706byvveyendgv5l/706/706modernizinggrandcentraldispatch_usage.pdf

https://devstreaming-cdn.apple.com/videos/wwdc/2016/720w6g8t9zhd23va0ai/720/720concurrentprogrammingwithgcdinswift_3.pdf