iOS 如何高效的使用多线程
写在前面
多线程技术在移动端开发中应用广泛,GCD 让 iOS 开发者能轻易的使用多线程,然而这并不意味着代码就一定高效和可靠。深入理解其原理并经常结合业务思考,才能在有限的线程控制 API 中最大化发挥并发编程的能力,也能轻易的察觉到代码可能存在的安全问题并优雅的解决它。
本文不会讲解 GCD 和各种“锁”的基本用法,而是结合操作系统的一些知识和笔者的认识讲述偏“思维”的东西,当然,最终也是为了能更高效的应用多线程。
行文可能有误欢迎指出错误。
一、多线程简述
线程是程序执行流的最小单元,一个线程包括:独有ID,程序计数器 (Program Counter),寄存器集合,堆栈。同一进程可以有多个线程,它们共享进程的全局变量和堆数据。
这里的 PC (Program Counter) 指向的是当前的指令地址,通过 PC 的更新来运行我们的程序,一个线程同一时刻只能执行一条指令。当然我们知道线程和进程都是虚拟的概念,实际上 PC 是 CPU 核心中的寄存器,它是实际存在的,所以也可以说一个 CPU 核心同一时刻只能执行一个线程。
不管是多处理器设备还是多核设备,开发者往往只需要关心 CPU 的核心数量,而不需关心它们的物理构成。CPU 核心数量是有限的,也就是说一个设备并发执行的线程数量是有限的,当线程数量超过 CPU 核心数量时,一个 CPU 核心往往就要处理多个线程,这个行为叫做线程调度。
线程调度简单来说就是:一个 CPU 核心轮流让各个线程分别执行一段时间。当然这中间还包含着复杂的逻辑,后文再来分析。
二、多线程的优化思路
在移动端开发中,因为系统的复杂性,开发者往往不能期望所有线程都能真正的并发执行,而且开发者也不清楚 XNU 何时切换内核态线程、何时进行线程调度,所以开发者要经常考虑到线程调度的情况。
1、减少线程切换
当线程数量超过 CPU 核心数量,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(), ^{
//任务1、2完成
});
});
2、控制线程数量
先看一段代码:
CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent();
for (int i = 0; i < 10000; ++i) {
NSLog(@"执行任务%d, 线程:%@", i, [NSThread currentThread]);
if (i == 9999) {
NSLog(@"总耗时: %f ms", (CFAbsoluteTimeGetCurrent() - startTime) *1000.0);
}
}
执行任务9998, 线程:<NSThread: 0x6000022423c0>{number = 1, name = main}
执行任务9999, 线程:<NSThread: 0x6000022423c0>{number = 1, name = main}
总耗时: 280.156970 ms
在主线程打印 10000 次,耗时 280 ms,接下来利用 GCD 让打印任务并行执行:
dispatch_queue_t queue = dispatch_queue_create("x.x.x", DISPATCH_QUEUE_CONCURRENT);
CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent();
for (int i = 0; i < 10000; ++i) {
dispatch_async(queue, ^{
NSLog(@"执行任务%d, 线程:%@", i, [NSThread currentThread]);
if (i == 9999) {
NSLog(@"总耗时: %f ms", (CFAbsoluteTimeGetCurrent() - startTime) *1000.0);
}
});
}
执行任务9998, 线程:<NSThread: 0x60000260d300>{number = 31, name = (null)}
执行任务9945, 线程:<NSThread: 0x6000029d15c0>{number = 60, name = (null)}
总耗时: 953.640938 ms
可以发现线程数量已经达到了好几十个,而且耗时是单线程的三倍多。理论上不管用不用 CGD 并发执行,任务的总量是一样的,同样是执行 10000 次NSLog
,为什么使用多线程反而效率更低?
原因是过多的线程调度导致资源浪费性能下降。
由于 GCD 中并行队列并不能限制线程数量,可以通过和 CPU 核心数量相同的串行队列轮询返回来达到并行队列的效果,业界知名框架 YYKit 就使用了这种思路来优化并行线程:
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);
if (cur < 0) cur = -cur;
return queues[cur % queueCount];
#undef MAX_QUEUE_COUNT
}
3、线程优先级权衡
通常来说,线程调度除了轮转法以外,还有优先级调度的方案,在线程调度时,高优先级的线程会更早的执行。有两个概念需要明确:
- 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
优先级,比较适合后台异步下载大文件之类的业务。
4、主线程任务的优化
有些业务只能写在主线程,比如 UI 类组件的初始化及其布局。其实这方面的优化就比较多了,业界所说的性能优化大部分都是为了减轻主线程的压力,似乎有些偏离了多线程优化的范畴了,下面就基于主线程任务的管理大致罗列几点吧:
内存复用
通过内存复用来减少开辟内存的时间消耗,这在系统 UI 类组件中应用广泛,比如 UITableViewCell 的复用。同时,减少开辟内存意味着减少了内存释放,同样能节约 CPU 资源。
懒加载任务
既然 UI 组件必须在主线程初始化,那么就需要用时再初始化吧,swift 的写时复制也是类似的思路。
任务拆分排队执行
通过监听 Runloop 即将结束等通知,将大量的任务拆分开来,在每次 Runloop 循环周期执行少量任务。其实在实践这种优化思路之前,应该想想能不能将任务放到异步线程,而不是用这种比较极端的优化手段。
主线程空闲时执行任务
//这里是主线程上下文
dispatch_async(dispatch_get_main_queue(), ^{
//等到主线程空闲执行该任务
});
这种手法挺巧,可以让 block 中的任务延迟到主线程空闲再执行,不过也不适合计算量过大的任务,因为始终是在主线程嘛。
三、关于“锁”
多线程会带来线程安全问题,当原子操作不能满足业务时,往往需要使用各种“锁”来保证内存的读写安全。
常用的锁有互斥锁、读写锁、空转锁,通常情况下,iOS 开发中互斥锁pthread_mutex_t、dispatch_semaphore_t
,读写锁pthread_rwlock_t
就能满足大部分需求,并且性能不错。
在读取锁失败时,线程有可能有两种状态:
- 空转状态:线程执行空任务循环等待,当锁可用时立即获取锁。
- 挂起状态:线程挂起,当锁可用时需要其他线程唤醒。
唤醒线程比较耗时,线程空转需要消耗 CPU 资源并且时间越长消耗越多,由此可知空转适合少量任务、挂起适合大量任务。
实际上互斥锁和读写锁都有空转锁的特性,它们在获取锁失败时会先空转一段时间,然后才会挂起,而空转锁也不会永远的空转,在特定的空转时间过后仍然会挂起,所以通常情况下不用刻意去使用空转锁,Casa Taloyum 在博客中有详细的解释。
1、OSSpinLock 优先级反转问题
优先级反转概念:比如两个线程 A 和 B,优先级 A < B。当 A 获取锁访问共享资源时,B 尝试获取锁,那么 B 就会进入忙等状态,忙等时间越长对 CPU 资源的占用越大;而由于 A 的优先级低于 B,A 无法与高优先级的线程争夺 CPU 资源,从而导致任务迟迟完成不了。解决优先级反转的方法有“优先级天花板”和“优先级继承”,它们的核心操作都是提升当前正在访问共享资源的线程的优先级。
OSSpinLock 由于这个问题导致很多开源库都放弃使用了,有兴趣可以看看一篇文章:不再安全的 OSSpinLock。
2、避免死锁
很常见的场景是,同一线程重复获取锁导致的死锁,这种情况可以使用递归锁来处理,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 此处的业务是修剪内存,并非是对锁性能要求很高的业务,并且修剪的任务量可能比较大,出现线程竞争的几率较大,所以这里放弃线程空转直接挂起线程是一个不错的处理方式。
3、最小化加锁任务
开发者应该充分的理解业务,将锁包含的代码区域尽量缩小,不会出现线程安全问题的代码就不要用锁来保护了,这样才能提高并发时锁的性能。
4、时刻注意不可重入方法的安全
当一个方法是可重入的时候,可以放心大胆的使用,若一个方法不可重入,开发者应该多留意,思考这个方法会不会有多个线程访问的情况,若有就老老实实的加上线程锁。
5、编译器的过度优化
编译器可能会为了提高效率将变量写入寄存器而暂时不写回,方便下次使用,我们知道一句代码转换为指令不止一条,所以在变量写入寄存器没来得及写回的过程中,可能这个变量被其它线程读写了。编译器同样会为了提高效率对它认为顺序无关的指令调换顺序。
以上都可能会导致合理使用锁的地方仍然线程不安全,而volatile
关键字就可以解决这类问题,它能阻止编译器为了效率将变量缓存到寄存器而不及时写回,也能阻止编译器调整操作volatile
修饰变量的指令顺序。
原子自增函数就有类似的应用:int32_t OSAtomicIncrement32( volatile int32_t *__theValue )
。
6、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))
。
结语
偏底层原理的东西比较抽象,笔者认为搞清楚它为什么要这么做比它做了什么更为重要,更能提升一个人的思维。基础技术往往在业务中的作用不是那么大,但是却能让你更从容的编码,超越普通开发者的思维也能让你在较复杂的业务中选择更合理更高效的方案,你的代码才能可靠。
共勉。