欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

iOS 如何高效的使用多线程

程序员文章站 2022-03-04 23:21:10
一、多线程简述线程是程序执行流的最小单元,一个线程包括:独有id,程序计数器 (program counter),寄存器集合,堆栈。同一进程可以有多个线程,它们共享进程的全局变量和堆数据。这里的 pc...

一、多线程简述

线程是程序执行流的最小单元,一个线程包括:独有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 循环周期执行少量任务。其实在实践这种优化思路之前,应该想想能不能将任务放到异步线程,而不是用这种比较极端的优化手段。

  • 主线程空闲时执行任务
//这里是主线程上下文

`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 多线程的资料请关注其它相关文章!