iOS 如何高效的使用多线程

时间:2022-09-20 10:00:08

一、多线程简述

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

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

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

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

二、多线程的优化思路

在移动端开发中,因为系统的复杂性,开发者往往不能期望所有线程都能真正的并发执行,而且开发者也不清楚 XNU 何时切换内核态线程、何时进行线程调度,所以开发者要经常考虑到线程调度的情况。

1、减少线程切换

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

2、线程优先级权衡

通常来说,线程调度除了轮转法以外,还有优先级调度的方案,在线程调度时,高优先级的线程会更早的执行。有两个概念需要明确:

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

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

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

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

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

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

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

3、主线程任务的优化

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

  • 内存复用

通过内存复用来减少开辟内存的时间消耗,这在系统 UI 类组件中应用广泛,比如 UITableViewCell 的复用。同时,减少开辟内存意味着减少了内存释放,同样能节约 CPU 资源。

  • 懒加载任务

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

  • 任务拆分排队执行

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

  • 主线程空闲时执行任务
?
1
2
3
4
5
6
7
//这里是主线程上下文
 
`dispatch_async(dispatch_get_main_queue(), ^{
 
//等到主线程空闲执行该任务
 
});

三、关于“锁”

多线程会带来线程安全问题,当原子操作不能满足业务时,往往需要使用各种“锁”来保证内存的读写安全。

常用的锁有互斥锁、读写锁、空转锁,通常情况下,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 资源,从而导致任务迟迟完成不了。解决优先级反转的方法有“优先级天花板”和“优先级继承”,它们的核心操作都是提升当前正在访问共享资源的线程的优先级。

2、避免死锁

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

使用pthread_mutex_trylock()等尝试获取锁的方法能有效的避免死锁的情况

3、最小化加锁任务

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

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

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

5、编译器的过度优化

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

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

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

6、CPU 乱序执行

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

以上就是iOS 如何高效使用的多线程的详细内容,更多关于ios 多线程的资料请关注服务器之家其它相关文章!

原文链接:https://segmentfault.com/a/1190000023787764?utm_source=tuicool&utm_medium=referral