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