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、控制线程数量

用 GCD 创建一个并行队列,并且为其添加大量的耗时并行任务:

dispatch_queue_t queue = dispatch_queue_create("x.x.x", DISPATCH_QUEUE_CONCURRENT);
for (int i = 0; i < 200; ++i) {
    dispatch_async(queue, ^{
        NSLog(@"执行任务%d, 线程:%@", i, [NSThread currentThread]);
        sleep(2);
    });
}
执行任务188, 线程:<NSThread: 0x600002566f40>{number = 125, name = (null)}
执行任务191, 线程:<NSThread: 0x600002564ac0>{number = 129, name = (null)}

可以发现线程数量已经达到了一百多个,而且也会有个有趣的现象,直到最后一个任务打印出来,用了好几秒(笔者测试大概8s)。

虽然线程众多,但是这里都用了sleep(2)操作,笔者猜测线程应该是进入了“等待”状态,会让出时间片而不占用过多的 CPU 资源,然而从结果来看实际情况更为复杂。

不管如何,可以确定的是这里过多的线程失去了意义,并没有保证所有的任务都能并发执行,并且会有大量的线程切换。所以在开发中可以控制一下线程的数量,达到优化性能的目的。

由于 GCD 中并行队列并不能限制线程数量,可以通过 n 个串行队列轮询返回来达到并行队列的效果(这里的 n 是 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 组件必须在主线程初始化,那么就需要用时再初始化吧,swift 的写时复制也是类似的思路。

任务拆分排队执行

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

主线程空闲时执行任务

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

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

三、关于“锁”

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

常用的锁有互斥锁、读写锁、空转锁,通常情况下,iOS 开发中互斥锁pthread_mutex_t、dispatch_semaphore_t,读写锁pthread_rwlock_t就能满足大部分需求,并且性能不错。

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

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

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

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

1、避免死锁

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

还有一种场景是:A线程获取到a锁,B线程获取到了b锁,同一时刻,A线程想要获取b锁,B线程想要获取a锁,A、B线程就会同时进入休眠。这种情况可以通过pthread_mutex_trylock()尝试获取锁,这个方法会立即返回。

2、最小化加锁任务

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

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

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

4、编译器的过度优化

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

以上都可能会导致合理使用锁的地方仍然线程不安全,而volatile关键字就可以解决这类问题,它能阻止编译器调整指令的顺序。

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

5、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"),还有一个分支预测减少指令跳转的优化宏#define DISPATCH_EXPECT(x, v) __builtin_expect((x), (v))

结语

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

共勉。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券