一、引言
上篇文章中讲到,dispatch_once
使用无锁方式实现线程安全和优异的性能。本篇文章我们来分析一下dispatch_once
的性能。
从dispatch_once
的源码可以看出,其本质就是一个if-else
语句。我们使用非线程安全的纯if-else
语句作为空白对照。
纯if-else:
1 | - (void)benchmark { |
接下来是用pthread_mutex_lock
实现的线程安全的if-else
:
1 | - (void)mutexLock { |
最后是dispatch_once
:
1 | - (void)dispatchOnce { |
在我的macbook pro上测试三种方式的耗时(单位:纳秒)分别为:
- 纯if-else:41
- pthread_mutex_lock: 13429
- dispatch_once: 5次输出分别为:3457、328、298、293、194
可以看出纯if-else
语句耗时最少,而效率相对较高的pthread_mutex_lock
锁比之dispatch_once
的性能有最高40多倍的差距!(dispatch_once
首次执行耗时较多)。
回顾下上篇中讲到的dispatch_once
的三个场景:
- 第一次执行,执行block,执行完成后置predicate标记
- 非第一次执行,而步骤1尚未执行完毕,此时线程需要等待步骤1完成,步骤1完成后依次唤醒等待的线程
- 非第一次执行,且步骤1已经执行完成,线程跳过block继续执行后续任务
思考一下,在程序的实际运行中,场景1最多发生一次,场景2发生的次数很少甚至可能一次也不会发生,而场景3是程序执行中最常见的,可能成千上万次地执行。场景3的效率直接影响到整个程序的运行效率。
从上述的简单测试中发现:针对场景3,dispatch_once
只比纯if-else
语句慢不到10倍,但是它有着比pthread_mutex_lock
高40多倍的性能表现,而且这是在保证线程安全的前提下的性能,确实不可思议,dispatch_once
究竟是怎么做到的呢?
二、CPU指令流水与dispatch_once
回顾《计算机组成原理》的知识,CPU的指令执行一般包括:取指、译码、执行、回写三个步骤。
在最古老的CPU上,指令是严格按照顺序执行的:
而现代CPU为了提升运行速度,加入了指令流水线、分支预测和乱序执行等特性,此时CPU的执行顺序如下(图片出自Wikipedia):
2.1 CPU分支预测
流水线特性使得CPU能够更快速地执行线性指令序列,但是对于if-else
语句,在条件判断结果出来之前,CPU不知道应该执行哪个分支,如果此时让CPU停下来等待判断结果显然不是最优的做法。
所以现代CPU加入了分支预测的特性,分析程序以往运行记录猜测本次要执行的分支并进行预执行,这样就会产生两个结果:
- 猜测正确,CPU无需等待判断结果了,继续往下执行。这样效率是最高的。
- 猜测错误,CPU要抛弃所有预执行的结果,重置寄存器等,回滚到正确的分支,重新热启动。由于现代编译器的高度复杂性,程序运行时往往有着很长的piplines,回滚状态和热启动都是很耗时的。
幸运的是,大多数的程序都有着状态良好的(well-behaved)分支,比如:dispatch_once_f
的if-else
语句。所以现代CPU的分支预测一般能达到90%的准确率,但是面对无法预测的分支(比如,判断的条件依赖网络返回值等),分支预测就无用武之地了。
想了解更详细的分支预测,请戳这里:Wikipedia_Brach_predictor。
2.2 barrier
现代CPU为了尽可能快的运行速度,加入了分支预测和预执行技术。想象一下如下场景:
1 | static TestObject *obj; |
线程A第一次执行dispatch_once
,此时线程B、C、D执行dispatch_once
,CPU根据以往的经验预执行了“dispatch_once
已执行完毕”的分支,但是此时线程A的block
尚未执行完毕,obj
是nil
,此时程序往下执行必然会导致异常行为,严重时甚至会引起crash。
这种问题要如何避免呢?
这时,dispatch_once
需要一种类似于Memory_barrier的机制,防止CPU跑的太快导致程序的异常。当然由于memory_barrier的开销比较大,在对性能很敏感dispatch_once
中尽量不要使用。
我们在_dispatch_once
函数中发现了这样一行代码:
1 | dispatch_compiler_barrier(); |
它是一个宏:
1 |
在GCC编译器下,dispatch_compiler_barrier
使用__asm__ __volatile__("" ::: "memory")
语句创建了一个memory_barrier。
扩展:Xcode编译器的历史
由于GCC是一个开源组织维护的编译器,GCC的开发组很傲娇,经常无视或者拖延Apple对GCC提出的各种需求,同时GCC对Objecttive-C的许多新特性的支持并不友好。因此,苹果从Xcode3开始就着手使用自家的LLVM编译器代替GCC了,到Xcode5版本时,GCC已被完全废弃,使用LLVM代替。现在Xcode中使用的编译器普遍是Clang-LLVM了,这个编译器的作者是大名鼎鼎的Swift之父Chris Lattner。
Clang-LLVM比GCC优秀在哪些方面
据说新的Clang编译器编译Objective-C代码速度比GCC快3倍,并且提供了更友好的代码提示。
对比下新老版本的dispatch_once
是如何解决由于CPU预执行了错误的分支而导致的程序异常的问题。
2.2.1 libdispatch-187.9
看下相关代码:
1 | void |
老版本的libdispatch在写入端的执行block和设置predicate
值为DISPATCH_ONCE_DONE
中间加入了dispatch_atomic_maximally_synchronizing_barrier();
这样一行语句,使得dispatch_once在置predicate
的值为done之前,让CPU等待足够长的时间,防止CPU由于预执行到错误的分支而却认为自己执行对了,而继续执行从而导致程序行为异常。
另一个线程B(耗时为Ta)在预执行读取了未初始化的obj值之后,回过头来确认猜测正确性时,predicate可能被执行block的线程A置为了“done”,这就导致线程B误认为自己的预执行有效(实际上它读取了未初始化的值)
dispatch_atomic_maximally_synchronizing_barrier()
实际上是调用GCC的__sync_synchronize(...)
函数发出一个full_barrier,它其实生成了一个的memory_barrier,效率比较低。
2.2.2 libdispatch-913.1.6
由于苹果在最新版的Xcode摒弃了GCC,因此新版本的libdispatch中也放弃使用了一些GCC中的操作,比如__sync_synchronize
。
回顾下代码:
1 | void _dispatch_once_f(dispatch_once_t *predicate, void *_Nullable context, |
与老版本不同的是,新版本增加了else分支和DISPATCH_COMPILER_CAN_ASSUME
语句。
dispatch_compiler_barrier()
在GCC编译器下使用__asm__ __volatile__ ("" ::: "memory")
制造一个memory_barrier,但是在Clang-LLVM编译器下,它转换成如下语句:
1 | do { } while (0); |
可以看到,无论CPU预测的分支正确与否,预执行的语句始终被局限在_dispatch_once_f
函数中,即使第一次执行dispatch_once
时,CPU分支预测错误,预执行了else分支,但是由于else分支中实际上是一句没实际作用的do-while
代码,执行它不会对的程序行为产生异常影响。
这样一来,在dispatch_once_f_slow
写入端就可以抛弃效率低下的memory_barrier了,进一步提高了dispatch_once
的性能。这种做法相当于使用编译器给程序加了一层barrier,这也许就是dispatch_compiler_barrier
命名的由来吧。