iOS 如何高效的使用多线程

写在前面

多线程技术在移动端开发中应用普遍,GCD 让 iOS 开发者能轻易的使用多线程,然而这并不意味着代码就必定高效和可靠。深刻理解其原理并常常结合业务思考,才能在有限的线程控制 API 中最大化发挥并发编程的能力,也能轻易的察觉到代码可能存在的安全问题并优雅的解决它。html

本文不会讲解 GCD 和各类“锁”的基本用法,而是结合操做系统的一些知识和笔者的认识讲述偏“思惟”的东西,固然,最终也是为了能更高效的应用多线程。ios

行文可能有误欢迎指出错误。编程

1、多线程简述

线程是程序执行流的最小单元,一个线程包括:独有ID,程序计数器 (Program Counter),寄存器集合,堆栈。同一进程能够有多个线程,它们共享进程的全局变量和堆数据。swift

这里的 PC (Program Counter) 指向的是当前的指令地址,经过 PC 的更新来运行咱们的程序,一个线程同一时刻只能执行一条指令。固然咱们知道线程和进程都是虚拟的概念,实际上 PC 是 CPU 核心中的寄存器,它是实际存在的,因此也能够说一个 CPU 核心同一时刻只能执行一个线程。数组

无论是多处理器设备仍是多核设备,开发者每每只须要关心 CPU 的核心数量,而不需关心它们的物理构成。CPU 核心数量是有限的,也就是说一个设备并发执行的线程数量是有限的,当线程数量超过 CPU 核心数量时,一个 CPU 核心每每就要处理多个线程,这个行为叫作线程调度缓存

线程调度简单来讲就是:一个 CPU 核心轮流让各个线程分别执行一段时间。固然这中间还包含着复杂的逻辑,后文再来分析。安全

2、多线程的优化思路

在移动端开发中,由于系统的复杂性,开发者每每不能指望全部线程都能真正的并发执行,并且开发者也不清楚 XNU 什么时候切换内核态线程、什么时候进行线程调度,因此开发者要常常考虑到线程调度的状况。性能优化

一、减小队列切换

当线程数量超过 CPU 核心数量,CPU 核心经过线程调度切换用户态线程,意味着有上下文的转换(寄存器数据、栈等),过多的上下文切换会带来资源开销。虽然内核态线程的切换理论上不会是性能负担,开发中仍是应该尽可能减小线程的切换。bash

注意:使用 GCD 是操做队列,队列切换并不老是意味着线程的切换(GCD 会作好 CPU 亲和性),代码层面能够减小队列切换来优化。多线程

看一段简单的代码:

dispatch_queue_t queue = dispatch_queue_create("x.x.x", DISPATCH_QUEUE_CONCURRENT);
- (void)tast1 {
    dispatch_async(queue, ^{
        //执行任务1
        dispatch_async(dispatch_get_main_queue(), ^{
            //任务1完成
            [self tast2];
        });
    });
}
- (void)tast2 {
    dispatch_async(queue, ^{
        //执行任务2
        dispatch_async(dispatch_get_main_queue(), ^{
            //任务2完成
        });
    });
}
复制代码

这里建立了一个并行队列,调用-tast1会执行两个任务,任务2要等待任务1执行完成,这里一共有四次队列的切换,明显是多余的,并且也不须要并行队列来处理,优化以下:

dispatch_queue_t queue = dispatch_queue_create("x.x.x", DISPATCH_QUEUE_SERIAL);
dispatch_async(queue, ^{
    //执行任务1
    //执行任务2
    dispatch_async(dispatch_get_main_queue(), ^{
        //任务一、2完成
    });
});
复制代码

二、控制线程数量

使用 GCD 并行队列,当任务过多且耗时较长时,队列会开辟大量的线程,而部分线程里面的耗时任务已经耗尽了 CPU 资源,因此其余的线程也只能等待 CPU 时间片,过多的线程也会让线程调度过于频繁。

GCD 中并行队列并不能限制线程数量,能够建立多个串行队列来模拟并行的效果,业界知名框架 YYKit 就作了这个逻辑,经过和 CPU 核心数量相同的串行队列轮询返回来达到并行队列的效果:

static dispatch_queue_t YYAsyncLayerGetDisplayQueue() {
//最大队列数量
#define MAX_QUEUE_COUNT 16
//队列数量
    static int queueCount;
//使用栈区的数组存储队列
    static dispatch_queue_t queues[MAX_QUEUE_COUNT];
    static dispatch_once_t onceToken;
    static int32_t counter = 0;
    dispatch_once(&onceToken, ^{
//串行队列数量和处理器数量相同
        queueCount = (int)[NSProcessInfo processInfo].activeProcessorCount;
        queueCount = queueCount < 1 ? 1 : queueCount > MAX_QUEUE_COUNT ? MAX_QUEUE_COUNT : queueCount;
//建立串行队列,设置优先级
        if ([UIDevice currentDevice].systemVersion.floatValue >= 8.0) {
            for (NSUInteger i = 0; i < queueCount; i++) {
                dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0);
                queues[i] = dispatch_queue_create("com.ibireme.yykit.render", attr);
            }
        } else {
            for (NSUInteger i = 0; i < queueCount; i++) {
                queues[i] = dispatch_queue_create("com.ibireme.yykit.render", DISPATCH_QUEUE_SERIAL);
                dispatch_set_target_queue(queues[i], dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0));
            }
        }
    });
//轮询返回队列
    uint32_t cur = (uint32_t)OSAtomicIncrement32(&counter);
    return queues[cur % queueCount];
#undef MAX_QUEUE_COUNT
}
复制代码

然而这样会致使串行队列比较少,若你的任务不少时,会致使 CPU 资源利用率不高。YYKit 在异步绘制时使用这段代码,这是一个任务不算多、耗时较长的场景,因此是比较适合的。

三、线程优先级权衡

一般来讲,线程调度除了轮转法之外,还有优先级调度的方案,在线程调度时,高优先级的线程会更早的执行。有两个概念须要明确:

  • IO 密集型线程:频繁等待的线程,等待的时候会让出时间片。
  • CPU 密集型线程:不多等待的线程,意味着长时间占用着 CPU。

特殊场景下,当多个 CPU 密集型线程霸占了全部 CPU 资源,而它们的优先级都比较高,而此时优先级较低的 IO 密集型线程将持续等待,产生线程饿死的现象。固然,为了不线程饿死,系统会逐步提升被“冷落”线程的优先级,IO 密集型线程一般状况下比 CPU 密集型线程更容易获取到优先级提高。

虽然系统会自动作这些事情,可是这总归会形成时间等待,可能会影响用户体验。因此笔者认为开发者须要从两个方面权衡优先级问题:

  • 让 IO 密集型线程优先级高于 CPU 密集型线程。
  • 让紧急的任务拥有更高的优先级。

好比一个场景:大量的图片异步解压的任务,解压的图片不须要当即反馈给用户,同时又有大量的异步查询磁盘缓存的任务,而查询磁盘缓存任务完成事后须要反馈给用户。

图片解压属于 CPU 密集型线程,查询磁盘缓存属于 IO 密集型线程,然后者须要反馈给用户更加紧急,因此应该让图片解压线程的优先级低一点,查询磁盘缓存的线程优先级高一点。

值得注意的是,这里是说大量的异步任务,意味着 CPU 颇有可能满负荷运算,若 CPU 资源绰绰有余的状况下就没那个必要去处理优先级问题。

iOS 8 事后设置队列优先级的方法以下:

dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_CONCURRENT, QOS_CLASS_BACKGROUND, 0);
dispatch_queue_t queue = dispatch_queue_create("x.x.x", attr);
复制代码

这里就设置了一个QOS_CLASS_BACKGROUND优先级,比较适合后台异步下载大文件之类的业务。

四、主线程任务的优化

有些业务只能写在主线程,好比 UI 类组件的初始化及其布局。其实这方面的优化就比较多了,业界所说的性能优化大部分都是为了减轻主线程的压力,彷佛有些偏离了多线程优化的范畴了,下面就基于主线程任务的管理大体罗列几点吧:

内存复用

经过内存复用来减小开辟内存的时间消耗,这在系统 UI 类组件中应用普遍,好比 UITableViewCell 的复用。同时,减小开辟内存意味着减小了内存释放,一样能节约 CPU 资源。

懒加载任务

既然 UI 组件必须在主线程初始化,那么就须要用时再初始化吧,swift 的写时复制也是相似的思路。

任务拆分排队执行

经过监听 Runloop 即将结束等通知,将大量的任务拆分开来,在每次 Runloop 循环周期执行少许任务。其实在实践这种优化思路以前,应该想一想能不能将任务放到异步线程,而不是用这种比较极端的优化手段。

参考:iOS 任务调度器:为 CPU 和内存减负

主线程空闲时执行任务

//这里是主线程上下文
dispatch_async(dispatch_get_main_queue(), ^{
    //等到主线程空闲执行该任务
});
复制代码

这种手法挺巧,可让 block 中的任务延迟到主线程空闲再执行,不过也不适合计算量过大的任务,由于始终是在主线程嘛。

3、关于“锁”

多线程会带来线程安全问题,当原子操做不能知足业务时,每每须要使用各类“锁”来保证内存的读写安全。

经常使用的锁有互斥锁、读写锁、空转锁,一般状况下,iOS 开发中互斥锁pthread_mutex_t、dispatch_semaphore_t,读写锁pthread_rwlock_t就能知足大部分需求,而且性能不错。

在读取锁失败时,线程有可能有两种状态:

  • 空转状态:线程执行空任务循环等待,当锁可用时当即获取锁。
  • 挂起状态:线程挂起,当锁可用时须要其余线程唤醒。

唤醒线程比较耗时,线程空转须要消耗 CPU 资源而且时间越长消耗越多,由此可知空转适合少许任务、挂起适合大量任务。

实际上互斥锁和读写锁都有空转锁的特性,它们在获取锁失败时会先空转一段时间,而后才会挂起,而空转锁也不会永远的空转,在特定的空转时间事后仍然会挂起,因此一般状况下不用刻意去使用空转锁,Casa Taloyum 在博客中有详细的解释。

一、OSSpinLock 优先级反转问题

优先级反转概念:好比两个线程 A 和 B,优先级 A < B。当 A 获取锁访问共享资源时,B 尝试获取锁,那么 B 就会进入忙等状态,忙等时间越长对 CPU 资源的占用越大;而因为 A 的优先级低于 B,A 没法与高优先级的线程争夺 CPU 资源,从而致使任务迟迟完成不了。解决优先级反转的方法有“优先级天花板”和“优先级继承”,它们的核心操做都是提高当前正在访问共享资源的线程的优先级。

OSSpinLock 因为这个问题致使不少开源库都放弃使用了,有兴趣能够看看一篇文章:再也不安全的 OSSpinLock

二、避免死锁

很常见的场景是,同一线程重复获取锁致使的死锁,这种状况可使用递归锁来处理,pthread_mutex_t使用pthread_mutex_init_recursive()方法初始化就能拥有递归锁的特性。

使用pthread_mutex_trylock()等尝试获取锁的方法能有效的避免死锁的状况,在 YYCache 源码中有一段处理就比较精致:

while (!finish) {
        if (pthread_mutex_trylock(&_lock) == 0) {
            ...
            finish = YES;
            ...
            pthread_mutex_unlock(&_lock);
        } else {
            usleep(10 * 1000); //10 ms
        }
    }
复制代码

这段代码除了避免潜在的死锁状况外,还作了一个10ms的挂起操做而后循环尝试,而不是直接让线程空转浪费过多的 CPU 资源。虽然挂起线程“浪费了”互斥锁的空转期,增长了唤醒线程的资源消耗,下降了锁的性能,可是考虑到 YYCache 此处的业务是修剪内存,并不是是对锁性能要求很高的业务,而且修剪的任务量可能比较大,出现线程竞争的概率较大,因此这里放弃线程空转直接挂起线程是一个不错的处理方式。

三、最小化临界区

开发者应该充分的理解业务,将临界区尽可能缩小,不会出现线程安全问题的代码就不要用锁来保护了,这样才能提升并发时锁的性能。

四、时刻注意不可重入方法的安全

当一个方法是可重入的时候,能够放心大胆的使用,若一个方法不可重入,开发者应该多留意,思考这个方法会不会有多个线程访问的状况,如有就老老实实的加上线程锁。

五、编译器的过分优化

编译器可能会为了提升效率将变量写入寄存器而暂时不写回,方便下次使用,咱们知道一句代码转换为指令不止一条,因此在变量写入寄存器没来得及写回的过程当中,可能这个变量被其它线程读写了。编译器一样会为了提升效率对它认为顺序无关的指令调换顺序。

以上均可能会致使合理使用锁的地方仍然线程不安全,而volatile关键字就能够解决这类问题,它能阻止编译器为了效率将变量缓存到寄存器而不及时写回,也能阻止编译器调整操做volatile修饰变量的指令顺序。

原子自增函数就有相似的应用:int32_t OSAtomicIncrement32( volatile int32_t *__theValue )

六、CPU 乱序执行

CPU 也可能为了提升效率而去交换指令的顺序,致使加锁的代码也不安全,解决这类问题可使用内存屏障,CPU 越过内存屏障后会刷新寄存器对变量的分配。

OC 实现单例模式的方法:

void
_dispatch_once(dispatch_once_t *predicate,
		DISPATCH_NOESCAPE dispatch_block_t block)
{
	if (DISPATCH_EXPECT(*predicate, ~0l) != ~0l) {
		dispatch_once(predicate, block);
	} else {
		dispatch_compiler_barrier();
	}
	DISPATCH_COMPILER_CAN_ASSUME(*predicate == ~0l);
}
复制代码

其中就能看到内存屏障的宏:#define dispatch_compiler_barrier() __asm__ __volatile__("" ::: "memory");还有一个分支预测减小指令跳转的优化宏(减小跳转指令能提升 CPU 流水线执行的效率):#define DISPATCH_EXPECT(x, v) __builtin_expect((x), (v))

结语

偏底层原理的东西比较抽象,笔者认为搞清楚它为何要这么作比它作了什么更为重要,更能提高一我的的思惟。基础技术每每在业务中的做用不是那么大,可是却能让你更从容的编码,超越普通开发者的思惟也能让你在较复杂的业务中选择更合理更高效的方案,你的代码才能可靠。

共勉。