dispatch_once

目錄

dispatch_once

dispatch_once低負(fù)載特性

備注

參考文章

相信大家對dispatch_once都不陌生了,但有個問題的來自我們使用的單例,以前我們寫單例:

+ (instancetype)sharedInstance

{

static Test *test = nil;

@synchronized (self) {

test = [Test new];

}

return test;

}

從GCD引入后,現(xiàn)在我們大多會這樣寫:

+ (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

作用:對于某個任務(wù)執(zhí)行一次,且只執(zhí)行一次。?

參數(shù):第一個參數(shù)predicate用來保證執(zhí)行一次,第二個參數(shù)是要執(zhí)行一次的任務(wù)block。

寫法

static dispatch_once_t predicate;

dispatch_once(&predicate, ^{

// some one-time task

});

使用:單例、緩存等代碼中,用以保證在初始化時執(zhí)行一次某任務(wù)。

備注:dispatch_once在單線程程序中毫無意義,但在多線程程序中,發(fā)揮出了其低負(fù)載、高可依賴性、接口簡單等特性。

我們現(xiàn)在主要看的就是dispatch_once的低負(fù)載特性。

dispatch_once低負(fù)載特性

要討論dispatch_once的低負(fù)載性,我們要討論三種場景:

1、第一次執(zhí)行,block需要被調(diào)用,調(diào)用結(jié)束后需要置標(biāo)記變量

2、非第一次執(zhí)行,而此時#1尚未完成,線程需要等待#1完成

3、非第一次執(zhí)行,而此時#1已經(jīng)完成,線程直接跳過block而進(jìn)行后續(xù)任務(wù)

對于場景#1,整體任務(wù)的效率瓶頸完全不在于dispatch_once,而在于block本身占用的cpu時間,并且也只會發(fā)生一次。

對于場景#2,發(fā)生的次數(shù)并不會很多,甚至很多時候一次都不會發(fā)生,假如發(fā)生了,那么也只是一個符合預(yù)期的行為:后來的線程需要等待第一線程完成。即使你寫一個受虐型的單元測試來故意模擬場景#2,也不能說明什么問題。

對于場景#3,在程序進(jìn)行過程中,可能發(fā)生成千上萬次或者天文數(shù)字次,這才是效率提升的關(guān)鍵之處。

一、需求的初衷

dispatch_once本來是被用作第一次的執(zhí)行保護(hù),等第一次執(zhí)行完畢之后,其職責(zé)就完成了,作為程序設(shè)計者,當(dāng)然希望它對后續(xù)執(zhí)行沒有任何影響,但這是做不到的,所以只能寄希望于盡量降低后續(xù)調(diào)用的負(fù)載。

負(fù)載的Benchmark

對于后續(xù)調(diào)用的負(fù)載,到底要降低到什么程度,需要一個基準(zhǔn)值,負(fù)荷最低的空白對照就是非線程安全的純if判斷語句了,在我的電腦上,一次包含if判斷語句的函數(shù)單例返回大概在0.5納秒左右,而dispatch_once確實(shí)做到了接近這個數(shù)值,有興趣可以親自寫一段測試代碼來試試。

//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;

二、負(fù)載的探究:重實(shí)現(xiàn)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);

}

這樣的實(shí)現(xiàn)確實(shí)是線程安全的,但是pthread_mutex_lock的效率太低了,后續(xù)調(diào)用負(fù)載是兩次鎖操作(加鎖解鎖),在我的macbookpro上,這個函數(shù)需要30ns,這戰(zhàn)斗力太渣了,拋棄。

自旋鎖

自旋鎖比之互斥鎖,其優(yōu)勢在于某些情況下負(fù)載更低,因此在發(fā)現(xiàn)互斥鎖效率較低的情況下,我們試一下自旋鎖。然后,我來改一下我的函數(shù)實(shí)現(xiàn):

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實(shí)在是太龜速了。

原子操作

原子操作是低級CPU操作,不用鎖也是線程安全的(實(shí)際上,原子操作使用硬件級別的鎖),原子操作使得自己實(shí)現(xiàn)軟件級別鎖成為可能。當(dāng)鎖負(fù)載太高時,可以直接使用原子操作來替代鎖。

以原子操作來替代鎖的編程方式很取巧,比較容易出現(xiàn)問題。bug很難找,使用需謹(jǐn)慎。

我們使用“原子比較交換函數(shù)” __sync_bool_compare_and_swap來實(shí)現(xiàn)新的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是一個被實(shí)現(xiàn)為cpu原子操作的函數(shù),所以比較和交換操作是一個整體操作并且是線程安全的。

所以新的實(shí)現(xiàn)就成為:

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)

;//注意這里沒有循環(huán)體

__sync_synchronize();

}

}

新的實(shí)現(xiàn)首先檢查predicate是否為2,假如為2,則調(diào)用__sync_synchronize這個builtin函數(shù)并返回,調(diào)用此函數(shù)會產(chǎn)生一個memory barrier,用以保證cpu讀寫順序嚴(yán)格按照程序的編寫順序來進(jìn)行,關(guān)于memory barrier的更多信息,還是查wiki吧。

緊接著是一個volatile修飾符修飾的指針臨時變量,如此編譯器就會假定此指針指向的值可能會隨時被其它線程改變,從而防止編譯器對此指針指向的值的讀寫進(jìn)行優(yōu)化,比如cache,reorder等。

然后進(jìn)行“原子比較交換”,如果predicate為0,則將predicate置為1,表示正在執(zhí)行block,并返回true,如此便進(jìn)入了block執(zhí)行分支,在block執(zhí)行完畢之后,我們依舊需要一個memory barrier,最后我們將predicate置為2,表示執(zhí)行已經(jīng)完成,后續(xù)調(diào)用應(yīng)該直接返回。

當(dāng)某個線程A正在執(zhí)行block時,任何線程B再進(jìn)入此函數(shù),便會進(jìn)入else分支,然后在此分支中進(jìn)行等待,直至線程A將predicate置為2,然后調(diào)用__sync_synchronize并返回

這個實(shí)現(xiàn)是線程安全的,并且是無鎖的,但是,依舊需要消耗11.5ns來執(zhí)行,比自旋鎖都慢,實(shí)際上memory barrier是很慢的。至于為什么比自旋鎖還慢,memory barrier有好幾種,__sync_synchronize產(chǎn)生的是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);

}

}

這其實(shí)是一個不線程安全的實(shí)現(xiàn),現(xiàn)代的CPU都是異步的,為了滿足用戶“又想馬兒好,又想馬兒不吃草”的奢望,CPU廠商堪稱無所不用其極,所以現(xiàn)代的CPU在提升速度上有很多優(yōu)化,其中之一就是流水線特性,當(dāng)執(zhí)行一條cpu指令時,發(fā)生了如下事情:

1.從內(nèi)存加載指令

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

3.加載輸入數(shù)據(jù)

4.執(zhí)行操作

5.保存輸出數(shù)據(jù)

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

在現(xiàn)代CPU上,cpu是這樣干活的:

? ? ?加載指令? ? ? ? ? ? ? ? ? ? ...

? ? ? ? 解碼 ? ? ? ? ? ? ? ? ? ?加載指令

? ? ?加載數(shù)據(jù) ? ? ? ? ? ? ? ? ? 解碼

? ? ? ? 執(zhí)行 ? ? ? ? ? ? ? ? ? ? 加載數(shù)據(jù)

保存輸出數(shù)據(jù) ? ? ? ? ? ? ? ? ?執(zhí)行

? ? ? ? ? ... ? ? ? ? ? ? ? ? ? ?保存輸出數(shù)據(jù)

這可就快得多了,cpu會將其認(rèn)為可以同時執(zhí)行的指令并行執(zhí)行,并根據(jù)優(yōu)化速度的需要來調(diào)整執(zhí)行順序,比如:

x = 1;

y = 2;

cpu可能會先執(zhí)行y=2,另外,編譯器也可能為了優(yōu)化而為你生成一個先執(zhí)行y=2的代碼,即使關(guān)閉編譯器優(yōu)化,cpu還是可能會先執(zhí)行y=2,在多核處理器中,其它的cpu就會看到這個兩個賦值操作順序顛倒了,即使賦值操作沒有顛倒,其它c(diǎn)pu也可能顛倒讀取順序,最后導(dǎo)致的結(jié)果可能是另一個線程在讀取到y(tǒng)為2時,卻發(fā)現(xiàn)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之前被讀取,那一個線程可能另一個線程執(zhí)行完block之前就取得了一個nil值;假如predicate被讀取為“已完成”,并且此時另一個線程正在初始化這個obj,那么接下來調(diào)用函數(shù)可能會導(dǎo)致程序崩潰。

所以,dispatch_once需要memory barrier或者類似的東西,但是它肯定沒有使用memory barrier,因?yàn)閙emory barrier實(shí)在是很慢。要明白dispatch_once如何避免memory barrier,先要了解cpu的分支預(yù)測和預(yù)執(zhí)行。

cpu的分支預(yù)測和預(yù)執(zhí)行

流水線特性使得CPU能更快地執(zhí)行線性指令序列,但是當(dāng)遇到條件判斷分支時,麻煩來了,在判定語句返回結(jié)果之前,cpu不知道該執(zhí)行哪個分支,那就得等著(術(shù)語叫做pipeline stall),這怎么能行呢,所以,CPU會進(jìn)行預(yù)執(zhí)行,cpu先猜測一個可能的分支,然后開始執(zhí)行分支中的指令?,F(xiàn)代CPU一般都能做到超過90%的猜測命中率,這可比NBA選手發(fā)球命中率高多了。然后當(dāng)判定語句返回,加入cpu猜錯分支,那么之前進(jìn)行的執(zhí)行都會被拋棄,然后從正確的分支重新開始執(zhí)行。

在dispatch_once中,唯一一個判斷分支就是predicate,dispatch_once會讓CPU預(yù)執(zhí)行條件不成立的分支,這樣可以大大提升函數(shù)執(zhí)行速度。但是這樣的預(yù)執(zhí)行導(dǎo)致的結(jié)果是使用了未初始化的obj并將函數(shù)返回,這顯然不是預(yù)期結(jié)果。

不對稱barrier

編寫barrier時,應(yīng)該是對稱的,在寫入端,要有一個barrier來保證順序?qū)懭?,同時,在讀取端,也要有一個barrier來保證順序讀取。但是,我們的dispatch_once實(shí)現(xiàn)要求寫入端快不快無所謂,而讀取端盡可能的快。所以,我們要解決前述的預(yù)執(zhí)行引起的問題。

當(dāng)一個預(yù)執(zhí)行最終被發(fā)現(xiàn)是錯誤的猜測時,所有的預(yù)執(zhí)行狀態(tài)以及結(jié)果都會被清除,然后cpu會從判斷分支處重新執(zhí)行正確的分支,也就意味著被讀取的未初始化的obj也會被拋棄,然后讀取。假如dispatch_once能做到在執(zhí)行完block并正確賦值給obj后,告訴其它c(diǎn)pu核心:你們這群無知的cpu啊,你們剛才都猜錯了!然后這群“無知的cpu”就會重新從分支處開始執(zhí)行,進(jìn)而獲取正確的obj值并返回。

從最早的預(yù)執(zhí)行到條件判斷語句最終結(jié)果被計算出來,這之間有很長時間(記作Tb),具體多長取決于cpu的設(shè)計,但是不論如何,這個時間最多幾十圈cpu時鐘時間,假如寫入端能在【初始化并寫入obj】與【置predicate值】之間等待足夠長的時間Ta使得Ta大于等于Tb,那問題就都解決了。

如果覺得這個”解決”難以理解,那么反過來思考,假如Ta小于Tb,那么Ta就有可能被Tb完全包含,也就是說,線程A(耗時為Ta)在預(yù)執(zhí)行讀取了未初始化的obj值之后,回過頭來確認(rèn)猜測正確性時,predicate可能被執(zhí)行block的線程B置為了“完成”,這就導(dǎo)致線程A認(rèn)為自己的預(yù)執(zhí)行有效(實(shí)際上它讀取了未初始化的值)。而假如Ta大于等于Tb,任何讀取了未初始化的obj值的預(yù)執(zhí)行都會被判定為未命中,從而進(jìn)入內(nèi)層dispatch_once而進(jìn)行等待。

要保證足夠的等待時間,需要一些trick。在intel的CPU上,dispatch_once動用了cpuid指令來達(dá)成這個目的。cpuid本來是用作取得cpu的信息,但是這個指令也同時強(qiáng)制將指令流串行化,并且這個指令是需要比較長的執(zhí)行時間的(在某些cpu上,甚至需要幾百圈cpu時鐘),這個時間Tc足夠超過Tb了。

查看dispatch_once讀取端的實(shí)現(xiàn):

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,并且這個代碼是在頭文件中的,是強(qiáng)制inline的,DISPATCH_EXPECT是用來告訴cpu *predicate等于~0l是更有可能的判定結(jié)果,這就使得cpu能猜測到更正確的分支,并提高效率,最重要的是,這一句是個簡單的if判定語句,負(fù)載無限接近benchmark。

在寫入端,dispatch_once在執(zhí)行了block之后,會調(diào)用dispatch_atomic_maximally_synchronizing_barrier();宏函數(shù),在intel處理器上,這個函數(shù)編譯出的是cpuid指令,在其他廠商處理器上,這個宏函數(shù)編譯出的是合適的其它指令。

如此一來,dispatch_once就保證了場景#3的執(zhí)行速度無限接近benchmark,實(shí)現(xiàn)了寫入端的最低負(fù)載。

備注:

我使用的是MacBook Pro? Xcode編譯器,至于測試所說的0.5ns這些數(shù)據(jù)無法驗(yàn)證,我在Xcode上編譯兩個形式的單例,在連續(xù)多次調(diào)用(多線程)的情況下,確實(shí)使用dispatch_once所使用的時間要少,@synchronized ()是互斥鎖,負(fù)載相對要高一些。

獲取納秒級時間的代碼如下:

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的分支預(yù)測和預(yù)執(zhí)行 做了個流程,可能能看得更清晰點(diǎn)

參考文章:

譯文地址

原文地址

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,363評論 6 532
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內(nèi)容