iOS 如何高效的使用多线程

写在前面

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

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

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

一、多线程简述

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

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

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

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

二、多线程的优化思路

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

1、减少队列切换

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

注意:使用 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(), ^{
        //任务1、2完成
    });
});

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 在异步绘制时使用这段代码,这是一个任务不算多、耗时较长的场景,所以是比较适合的。

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

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

主线程空闲时执行任务

//这里是主线程上下文
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))

结语

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

共勉。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • iOS多线程编程 基本知识 1. 进程(process) 进程是指在系统中正在运行的一个应用程序,就是一段程序的执...
    陵无山阅读 6,004评论 1 14
  • GCD简介 GCD 是 libdispatch 的市场名称,而 libdispatch 作为 Apple 的一个库...
    独木舟的木阅读 1,235评论 0 5
  • 又来到了一个老生常谈的问题,应用层软件开发的程序员要不要了解和深入学习操作系统呢? 今天就这个问题开始,来谈谈操...
    tangsl阅读 4,088评论 0 23
  • 单任务 单任务的特点是排队执行,也就是同步,就像再cmd输入一条命令后,必须等待这条命令执行完才可以执行下一条命令...
    Steven1997阅读 1,166评论 0 6
  • 有一种寂寞,身边添一个可谈的人,一条知心的狗,或许就可以消减。有一种寂寞,茫茫天地之间余舟一芥的无边无际无着落,人...
    我来自远方阅读 140评论 0 0