目錄
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)