dispatch_once

目录

dispatch_once

dispatch_once低负载特性

备注

参考文章

相信大家对dispatch_once都不陌生了,但有个问题的来自我们使用的单例,以前我们写单例:

+ (instancetype)sharedInstance

{

static Test *test = nil;

@synchronized (self) {

test = [Test new];

}

return test;

}

从GCD引入后,现在我们大多会这样写:

+ (instancetype)sharedInstance

{

static Test *test = nil;

static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{

test = [Test new];

});

return test;

}

那为什么我们要使用dispatch_once呢?

我们先来了解dispatch_once。

dispatch_once

作用:对于某个任务执行一次,且只执行一次。 

参数:第一个参数predicate用来保证执行一次,第二个参数是要执行一次的任务block。

写法

static dispatch_once_t predicate;

dispatch_once(&predicate, ^{

// some one-time task

});

使用:单例、缓存等代码中,用以保证在初始化时执行一次某任务。

备注:dispatch_once在单线程程序中毫无意义,但在多线程程序中,发挥出了其低负载、高可依赖性、接口简单等特性。

我们现在主要看的就是dispatch_once的低负载特性。

dispatch_once低负载特性

要讨论dispatch_once的低负载性,我们要讨论三种场景:

1、第一次执行,block需要被调用,调用结束后需要置标记变量

2、非第一次执行,而此时#1尚未完成,线程需要等待#1完成

3、非第一次执行,而此时#1已经完成,线程直接跳过block而进行后续任务

对于场景#1,整体任务的效率瓶颈完全不在于dispatch_once,而在于block本身占用的cpu时间,并且也只会发生一次。

对于场景#2,发生的次数并不会很多,甚至很多时候一次都不会发生,假如发生了,那么也只是一个符合预期的行为:后来的线程需要等待第一线程完成。即使你写一个受虐型的单元测试来故意模拟场景#2,也不能说明什么问题。

对于场景#3,在程序进行过程中,可能发生成千上万次或者天文数字次,这才是效率提升的关键之处。

一、需求的初衷

dispatch_once本来是被用作第一次的执行保护,等第一次执行完毕之后,其职责就完成了,作为程序设计者,当然希望它对后续执行没有任何影响,但这是做不到的,所以只能寄希望于尽量降低后续调用的负载。

负载的Benchmark

对于后续调用的负载,到底要降低到什么程度,需要一个基准值,负荷最低的空白对照就是非线程安全的纯if判断语句了,在我的电脑上,一次包含if判断语句的函数单例返回大概在0.5纳秒左右,而dispatch_once确实做到了接近这个数值,有兴趣可以亲自写一段测试代码来试试。

//dispatch_once

static id object;

static dispatch_once_t predicate;

dispatch_once(&predicate, ^{

object = ...;

});

return object;

//if判断

static id object = nil;

if (!object)

{

object = ...;

}

return object;

二、负载的探究:重实现dispatch_once

互斥锁

多线程情景下,很容易想到pthread_mutex_lock互斥锁,先保证线程安全:

void DWDispatchOnce(dispatch_once_t *predicate, dispatch_block_t block) {

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock(&mutex);

if(!*predicate) {

block();

*predicate = 1;

}

pthread_mutex_unlock(&mutex);

}

这样的实现确实是线程安全的,但是pthread_mutex_lock的效率太低了,后续调用负载是两次锁操作(加锁解锁),在我的macbookpro上,这个函数需要30ns,这战斗力太渣了,抛弃。

自旋锁

自旋锁比之互斥锁,其优势在于某些情况下负载更低,因此在发现互斥锁效率较低的情况下,我们试一下自旋锁。然后,我来改一下我的函数实现:

void DWDispatchOnce(dispatch_once_t *predicate, dispatch_block_t block) {

static OSSpinLock lock = OS_SPINLOCK_INIT;

OSSpinLockLock(&lock);

if(!*predicate) {

block();

*predicate = 1;

}

OSSpinLockUnlock(&lock);

}

嗯,提升很不错,这次提升到了6.5ns,自旋锁在低碰撞情况下,效率果然不是盖的,不过对于dispatch_once来说,还是太渣了,6ns实在是太龟速了。

原子操作

原子操作是低级CPU操作,不用锁也是线程安全的(实际上,原子操作使用硬件级别的锁),原子操作使得自己实现软件级别锁成为可能。当锁负载太高时,可以直接使用原子操作来替代锁。

以原子操作来替代锁的编程方式很取巧,比较容易出现问题。bug很难找,使用需谨慎。

我们使用“原子比较交换函数” __sync_bool_compare_and_swap来实现新的DWDispatchOnce,__sync_bool_compare_and_swap的作用大概等同于:

BOOL DWCompareAndSwap(long *ptr, long testValue, long newValue) {

if(*ptr == testValue) {

*ptr = newValue;

return YES;

}

return NO;

}

不同的是,__sync_bool_compare_and_swap是一个被实现为cpu原子操作的函数,所以比较和交换操作是一个整体操作并且是线程安全的。

所以新的实现就成为:

void DWDispatchOnce(dispatch_once_t *predicate, dispatch_block_t block)

{

if(*predicate == 2)

{

__sync_synchronize();

return;

}

volatile dispatch_once_t *volatilePredicate = predicate;

if(__sync_bool_compare_and_swap(volatilePredicate, 0, 1)) {

block();

__sync_synchronize();

*volatilePredicate = 2;

} else {

while(*volatilePredicate != 2)

;//注意这里没有循环体

__sync_synchronize();

}

}

新的实现首先检查predicate是否为2,假如为2,则调用__sync_synchronize这个builtin函数并返回,调用此函数会产生一个memory barrier,用以保证cpu读写顺序严格按照程序的编写顺序来进行,关于memory barrier的更多信息,还是查wiki吧。

紧接着是一个volatile修饰符修饰的指针临时变量,如此编译器就会假定此指针指向的值可能会随时被其它线程改变,从而防止编译器对此指针指向的值的读写进行优化,比如cache,reorder等。

然后进行“原子比较交换”,如果predicate为0,则将predicate置为1,表示正在执行block,并返回true,如此便进入了block执行分支,在block执行完毕之后,我们依旧需要一个memory barrier,最后我们将predicate置为2,表示执行已经完成,后续调用应该直接返回。

当某个线程A正在执行block时,任何线程B再进入此函数,便会进入else分支,然后在此分支中进行等待,直至线程A将predicate置为2,然后调用__sync_synchronize并返回

这个实现是线程安全的,并且是无锁的,但是,依旧需要消耗11.5ns来执行,比自旋锁都慢,实际上memory barrier是很慢的。至于为什么比自旋锁还慢,memory barrier有好几种,__sync_synchronize产生的是mfenceCPU指令,是最蛋疼的一种,跟那蛋疼到忧伤的SSE4指令集是一路货。但不管怎么样,我想说的是,memory barrier是有不小的开销的。

假如去除掉memory barrier会如何呢?

void DWDispatchOnce(dispatch_once_t *predicate, dispatch_block_t block) {

if(*predicate == 2)   return;

volatile dispatch_once_t *volatilePredicate = predicate;

if(__sync_bool_compare_and_swap(volatilePredicate, 0, 1)) {

           block();

           *volatilePredicate = 2;

} else {

            while(*volatilePredicate != 2);

}

}

这其实是一个不线程安全的实现,现代的CPU都是异步的,为了满足用户“又想马儿好,又想马儿不吃草”的奢望,CPU厂商堪称无所不用其极,所以现代的CPU在提升速度上有很多优化,其中之一就是流水线特性,当执行一条cpu指令时,发生了如下事情:

1.从内存加载指令

2.指令解码(解析指令是什么,操作是什么)

3.加载输入数据

4.执行操作

5.保存输出数据

在古董级cpu上面,cpu是串行干活的,

在现代CPU上,cpu是这样干活的:

     加载指令                    ...

        解码                    加载指令

     加载数据                   解码

        执行                     加载数据

保存输出数据                  执行

          ...                    保存输出数据

这可就快得多了,cpu会将其认为可以同时执行的指令并行执行,并根据优化速度的需要来调整执行顺序,比如:

x = 1;

y = 2;

cpu可能会先执行y=2,另外,编译器也可能为了优化而为你生成一个先执行y=2的代码,即使关闭编译器优化,cpu还是可能会先执行y=2,在多核处理器中,其它的cpu就会看到这个两个赋值操作顺序颠倒了,即使赋值操作没有颠倒,其它cpu也可能颠倒读取顺序,最后导致的结果可能是另一个线程在读取到y为2时,却发现x还没被赋值为1。

解决这种问题的方法就是加入memory barrier,但是memory barrier的目的就在于防止cpu“跑太快”,所以,开销的惩罚那是大大的。

所以,对于dispatch_once:

static SomeClass *obj;

static dispatch_once_t predicate;

dispatch_once(&predicate, ^{ obj = [[SomeClass alloc] init]; });

[obj doSomething];

假如obj在predicate之前被读取,那一个线程可能另一个线程执行完block之前就取得了一个nil值;假如predicate被读取为“已完成”,并且此时另一个线程正在初始化这个obj,那么接下来调用函数可能会导致程序崩溃。

所以,dispatch_once需要memory barrier或者类似的东西,但是它肯定没有使用memory barrier,因为memory barrier实在是很慢。要明白dispatch_once如何避免memory barrier,先要了解cpu的分支预测和预执行。

cpu的分支预测和预执行

流水线特性使得CPU能更快地执行线性指令序列,但是当遇到条件判断分支时,麻烦来了,在判定语句返回结果之前,cpu不知道该执行哪个分支,那就得等着(术语叫做pipeline stall),这怎么能行呢,所以,CPU会进行预执行,cpu先猜测一个可能的分支,然后开始执行分支中的指令。现代CPU一般都能做到超过90%的猜测命中率,这可比NBA选手发球命中率高多了。然后当判定语句返回,加入cpu猜错分支,那么之前进行的执行都会被抛弃,然后从正确的分支重新开始执行。

在dispatch_once中,唯一一个判断分支就是predicate,dispatch_once会让CPU预执行条件不成立的分支,这样可以大大提升函数执行速度。但是这样的预执行导致的结果是使用了未初始化的obj并将函数返回,这显然不是预期结果。

不对称barrier

编写barrier时,应该是对称的,在写入端,要有一个barrier来保证顺序写入,同时,在读取端,也要有一个barrier来保证顺序读取。但是,我们的dispatch_once实现要求写入端快不快无所谓,而读取端尽可能的快。所以,我们要解决前述的预执行引起的问题。

当一个预执行最终被发现是错误的猜测时,所有的预执行状态以及结果都会被清除,然后cpu会从判断分支处重新执行正确的分支,也就意味着被读取的未初始化的obj也会被抛弃,然后读取。假如dispatch_once能做到在执行完block并正确赋值给obj后,告诉其它cpu核心:你们这群无知的cpu啊,你们刚才都猜错了!然后这群“无知的cpu”就会重新从分支处开始执行,进而获取正确的obj值并返回。

从最早的预执行到条件判断语句最终结果被计算出来,这之间有很长时间(记作Tb),具体多长取决于cpu的设计,但是不论如何,这个时间最多几十圈cpu时钟时间,假如写入端能在【初始化并写入obj】与【置predicate值】之间等待足够长的时间Ta使得Ta大于等于Tb,那问题就都解决了。

如果觉得这个”解决”难以理解,那么反过来思考,假如Ta小于Tb,那么Ta就有可能被Tb完全包含,也就是说,线程A(耗时为Ta)在预执行读取了未初始化的obj值之后,回过头来确认猜测正确性时,predicate可能被执行block的线程B置为了“完成”,这就导致线程A认为自己的预执行有效(实际上它读取了未初始化的值)。而假如Ta大于等于Tb,任何读取了未初始化的obj值的预执行都会被判定为未命中,从而进入内层dispatch_once而进行等待。

要保证足够的等待时间,需要一些trick。在intel的CPU上,dispatch_once动用了cpuid指令来达成这个目的。cpuid本来是用作取得cpu的信息,但是这个指令也同时强制将指令流串行化,并且这个指令是需要比较长的执行时间的(在某些cpu上,甚至需要几百圈cpu时钟),这个时间Tc足够超过Tb了。

查看dispatch_once读取端的实现:

DISPATCH_INLINE DISPATCH_ALWAYS_INLINE DISPATCH_NONNULL_ALL DISPATCH_NOTHROW

void _dispatch_once(dispatch_once_t *predicate, dispatch_block_t block)

{

           if (DISPATCH_EXPECT(*predicate, ~0l) != ~0l) {

                         dispatch_once(predicate, block);

            }

}

#define dispatch_once _dispatch_once

没有barrier,并且这个代码是在头文件中的,是强制inline的,DISPATCH_EXPECT是用来告诉cpu *predicate等于~0l是更有可能的判定结果,这就使得cpu能猜测到更正确的分支,并提高效率,最重要的是,这一句是个简单的if判定语句,负载无限接近benchmark。

在写入端,dispatch_once在执行了block之后,会调用dispatch_atomic_maximally_synchronizing_barrier();宏函数,在intel处理器上,这个函数编译出的是cpuid指令,在其他厂商处理器上,这个宏函数编译出的是合适的其它指令。

如此一来,dispatch_once就保证了场景#3的执行速度无限接近benchmark,实现了写入端的最低负载。

备注:

我使用的是MacBook Pro  Xcode编译器,至于测试所说的0.5ns这些数据无法验证,我在Xcode上编译两个形式的单例,在连续多次调用(多线程)的情况下,确实使用dispatch_once所使用的时间要少,@synchronized ()是互斥锁,负载相对要高一些。

获取纳秒级时间的代码如下:

struct timespec time_start={0, 0},time_end={0, 0};

clock_gettime(CLOCK_REALTIME, &time_start);

//your code ...

clock_gettime(CLOCK_REALTIME, &time_end);

printf("duration: %ldns", time_end.tv_nsec-time_start.tv_nsec);


另外对于 cpu的分支预测和预执行 做了个流程,可能能看得更清晰点

参考文章:

译文地址

原文地址

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

推荐阅读更多精彩内容