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的分支預測和預執行 做了個流程,可能能看得更清晰點

參考文章:

譯文地址

原文地址

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容