一、很久很久以前:
或許GCD中使用最多并且被濫用功能的就是 dispatch_once 了。正確的用法看起來是這樣的:
+ (UIColor *)boringColor {
static UIColor * color;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
color = [UIColor colorWithRed:0.380f green:0.376f blue:0.376f alpha:1.000f];
});
return color;
}
上面的 block 只會運行一次。并且在連續(xù)的調用中,這種檢查是很高效的。你能使用它來初始化全局數(shù)據(jù)比如單例。
要注意的是,使用 dispatch_once_t 會使得測試變得非常困難(單例和測試不是很好配合)。
要確保 onceToken 被聲明為 static ,或者有全局作用域。任何其他的情況都會導致無法預知的行為。換句話說,不要把 dispatch_once_t 作為一個對象的成員變量,或者類似的情形。
退回到遠古時代(其實也就是幾年前),人們會使用 pthread_once ,因為 dispatch_once_t 更容易使用并且不易出錯,所以你永遠都不會再用到 pthread_once 了。
二、延時執(zhí)行:
另一個常見的小伙伴就是 dispatch_after 了。它使工作延后執(zhí)行。它是很強大的,但是要注意:你很容易就陷入到一堆麻煩中。一般用法是這樣的:
- (void)testFunction {
double delayInSeconds = 2.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t) (delayInSeconds * NSEC_PER_SEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
[self bar];
});
}
第一眼看上去這段代碼是極好的,但是這里存在一些缺點。我們不能(直接)取消我們已經提交到 dispatch_after 的代碼,它將會運行,這一點真的很坑爹。
另外一個需要注意的事情就是,當人們使用 dispatch_after 去處理他們代碼中存在的時序 bug 時,會存在一些有問題的傾向。一些代碼執(zhí)行的過早而你很可能不知道為什么會這樣,所以你把這段代碼放到了 dispatch_after 中,現(xiàn)在一切運行正常了。但是幾周以后,之前的工作不起作用了。由于你并不十分清楚你自己代碼的執(zhí)行次序,調試代碼就變成了一場噩夢。所以不要像上面這樣做。
大多數(shù)的情況下,你最好把代碼放到正確的位置。如果代碼放到 -viewWillAppear 太早,那么或許 -viewDidAppear 就是正確的地方。
通過在自己代碼中建立直接調用(類似 -viewDidAppear )而不是依賴于 dispatch_after ,你會為自己省去很多麻煩。
如果你需要一些事情在某個特定的時刻運行,那么 dispatch_after 或許會是個好的選擇。但是我還是推薦使用NSTimer,這個API雖然有點笨重,但是它允許你取消定時器的觸發(fā)。
三、隊列:
GCD 中一個基本的代碼塊就是隊列。當使用隊列的時候,給它們一個明顯的標簽會幫自己不少忙。在調試時,這個標簽會在 Xcode (和 lldb)中顯示,這會幫助你了解你的 app 是由什么決定的:
- (id)init {
self = [super init];
if (self != nil) {
NSString *label = [NSString stringWithFormat:@"%@.isolation.%p", [self class], self];
self.isolationQueue = dispatch_queue_create([label UTF8String], 0);
label = [NSString stringWithFormat:@"%@.work.%p", [self class], self];
self.workQueue = dispatch_queue_create([label UTF8String], 0);
}
return self;
}
隊列可以是并行也可以是串行的。默認情況下,它們是串行的,也就是說,任何給定的時間內,只能有一個單獨的 block 運行。這就是隔離隊列(isolation queues)的運行方式。隊列也可以是并行的,也就是同一時間內允許多個 block 一起執(zhí)行。
GCD 隊列的內部使用的是線程。GCD 管理這些線程,并且使用 GCD 的時候,你不需要自己創(chuàng)建線程。但是重要的外在部分 GCD 會呈現(xiàn)給你,也就是用戶 API,一個很大不同的抽象層級。當使用 GCD 來完成并發(fā)的工作時,你不必考慮線程方面的問題,取而代之的,只需考慮隊列和功能點(提交給隊列的 block)。雖然往下深究,依然都是線程,但是 GCD 的抽象層級為你慣用的編碼提供了更好的方式。
隊列和功能點同時解決了一個連續(xù)不斷的扇出的問題:如果我們直接使用線程,并且想要做一些并發(fā)的事情,我們很可能將我們的工作分成 100 個小的功能點,然后基于可用的 CPU 內核數(shù)量來創(chuàng)建線程,假設是 8。我們把這些功能點送到這 8 個線程中。當我們處理這些功能點時,可能會調用一些函數(shù)作為功能的一部分。寫那個函數(shù)的人也想要使用并發(fā),因此當你調用這個函數(shù)的時候,這個函數(shù)也會創(chuàng)建 8 個線程。現(xiàn)在,你有了 8 × 8 = 64 個線程,盡管你只有 8 個CPU內核——也就是說任何時候只有12%的線程實際在運行而另外88%的線程什么事情都沒做。使用 GCD 你就不會遇到這種問題,當系統(tǒng)關閉 CPU 內核以省電時,GCD 甚至能夠相應地調整線程數(shù)量。
GCD 通過創(chuàng)建所謂的線程池來大致匹配 CPU 內核數(shù)量。要記住,線程的創(chuàng)建并不是無代價的。每個線程都需要占用內存和內核資源。這里也有一個問題:如果你提交了一個 block 給 GCD,但是這段代碼阻塞了這個線程,那么這個線程在這段時間內就不能用來完成其他工作——它被阻塞了。為了確保功能點在隊列上一直是執(zhí)行的,GCD 不得不創(chuàng)建一個新的線程,并把它添加到線程池。
如果你的代碼阻塞了許多線程,這會帶來很大的問題。首先,線程消耗資源,此外,創(chuàng)建線程會變得代價高昂。創(chuàng)建過程需要一些時間。并且在這段時間中,GCD 無法以全速來完成功能點。有不少能夠導致線程阻塞的情況,但是最常見的情況與 I/O 有關,也就是從文件或者網絡中讀寫數(shù)據(jù)。正是因為這些原因,你不應該在GCD隊列中以阻塞的方式來做這些操作。看一下下面的輸入輸出段落去了解一些關于如何以 GCD 運行良好的方式來做 I/O 操作的信息。
1.目標隊列:
你能夠為你創(chuàng)建的任何一個隊列設置一個目標隊列。這會是很強大的,并且有助于調試。
為一個類創(chuàng)建它自己的隊列而不是使用全局的隊列被普遍認為是一種好的風格。這種方式下,你可以設置隊列的名字,這讓調試變得輕松許多—— Xcode 可以讓你在 Debug Navigator 中看到所有的隊列名字,如果你直接使用 lldb。(lldb) thread list 命令將會在控制臺打印出所有隊列的名字。一旦你使用大量的異步內容,這會是非常有用的幫助。
使用私有隊列同樣強調封裝性。這時你自己的隊列,你要自己決定如何使用它。
默認情況下,一個新創(chuàng)建的隊列轉發(fā)到默認優(yōu)先級的全局隊列中。我們就將會討論一些有關優(yōu)先級的東西。
你可以改變你隊列轉發(fā)到的隊列——你可以設置自己隊列的目標隊列。以這種方式,你可以將不同隊列鏈接在一起。你的 Foo 類有一個隊列,該隊列轉發(fā)到 Bar 類的隊列,Bar 類的隊列又轉發(fā)到全局隊列。
當你為了隔離目的而使用一個隊列時,這會非常有用。Foo 有一個隔離隊列,并且轉發(fā)到 Bar 的隔離隊列,與 Bar 的隔離隊列所保護的有關的資源,會自動成為線程安全的。
如果你希望多個 block 同時運行,那要確保你自己的隊列是并發(fā)的。同時需要注意,如果一個隊列的目標隊列是串行的(也就是非并發(fā)),那么實際上這個隊列也會轉換為一個串行隊列。
2.優(yōu)先級:
你可以通過設置目標隊列為一個全局隊列來改變自己隊列的優(yōu)先級,但是你應該克制這么做的沖動。
在大多數(shù)情況下,改變優(yōu)先級不會使事情照你預想的方向運行。一些看起簡單的事情實際上是一個非常復雜的問題。你很容易會碰到一個叫做優(yōu)先級反轉的情況。
此外,使用 DISPATCH_QUEUE_PRIORITY_BACKGROUND 隊列時,你需要格外小心。除非你理解了 throttled I/O 和 background status as per setpriority(2) 的意義,否則不要使用它。不然,系統(tǒng)可能會以難以忍受的方式終止你的 app 的運行。打算以不干擾系統(tǒng)其他正在做 I/O 操作的方式去做 I/O 操作時,一旦和優(yōu)先級反轉情況結合起來,這會變成一種危險的情況。
四、隔離:
隔離隊列是 GCD 隊列使用中非常普遍的一種模式。這里有兩個變種。
1.資源保護:
多線程編程中,最常見的情形是你有一個資源,每次只有一個線程被允許訪問這個資源。
我們在有關多線程技術的文章中知道資源在并發(fā)編程中意味著什么,它通常就是一塊內存或者一個對象,每次只有一個線程可以訪問它。
舉例來說,我們需要以多線程(或者多個隊列)方式訪問 NSMutableDictionary 。我們可能會照下面的代碼來做:
- (void)setCount:(NSUInteger)count forKey:(NSString *)key{
key = [key copy];
dispatch_async(self.isolationQueue, ^(){
if (count == 0) {
[self.counts removeObjectForKey:key];
} else {
self.counts[key] = @(count);
}
});
}
- (NSUInteger)countForKey:(NSString *)key{
__block NSUInteger count;
dispatch_sync(self.isolationQueue, ^(){
NSNumber *n = self.counts[key];
count = [n unsignedIntegerValue];
});
return count;
}
通過以上代碼,只有一個線程可以訪問 NSMutableDictionary 的實例。
注意以下四點:
1、不要使用上面的代碼,請先閱讀多讀單寫和鎖競爭
2、我們使用 async 方式來保存值,這很重要。我們不想也不必阻塞當前線程只是為了等待寫操作完成。當讀操作時,我們使用 sync 因為我們需要返回值。
3、從函數(shù)接口可以看出,-setCount:forKey: 需要一個 NSString 參數(shù),用來傳遞給 dispatch_async。函數(shù)調用者可以自由傳遞一個 NSMutableString 值并且能夠在函數(shù)返回后修改它。因此我們必須對傳入的字符串使用 copy 操作以確保函數(shù)能夠正確地工作。如果傳入的字符串不是可變的(也就是正常的 NSString 類型),調用copy基本上是個空操作。
4、isolationQueue 創(chuàng)建時,參數(shù) dispatch_queue_attr_t 的值必須是DISPATCH_QUEUE_SERIAL(或者0)。
2.單一資源的多讀單寫:
我們能夠改善上面的那個例子。GCD 有可以讓多線程運行的并發(fā)隊列。我們能夠安全地使用多線程來從 NSMutableDictionary 中讀取只要我們不同時修改它。當我們需要改變這個字典時,我們使用 barrier 來分發(fā)這個 block。這樣的一個 block 的運行時機是,在它之前所有計劃好的 block 完成之后,并且在所有它后面的 block 運行之前。
以如下方式創(chuàng)建隊列:
self.isolationQueue = dispatch_queue_create([label UTF8String], DISPATCH_QUEUE_CONCURRENT);
然后重寫setter函數(shù):
- (void)setCount:(NSUInteger)count forKey:(NSString *)key{
key = [key copy];
dispatch_barrier_async(self.isolationQueue, ^(){
if (count == 0) {
[self.counts removeObjectForKey:key];
} else {
self.counts[key] = @(count);
}
});
}
當使用并發(fā)隊列時,要確保所有的 barrier 調用都是 async 的。如果你使用 dispatch_barrier_sync ,那么你很可能會使你的代碼產生死鎖。寫操作需要 barrier,并且可以是 async 的。
3.鎖競爭:
首先,這里有一個警告:上面這個例子中我們保護的資源是一個 NSMutableDictionary,出于這樣的目的,這段代碼運行地相當不錯。但是在真實的代碼中,把隔離放到正確的復雜度層級下是很重要的。
如果你對 NSMutableDictionary 的訪問操作變得非常頻繁,你會碰到一個已知的叫做鎖競爭的問題。鎖競爭并不是只是在 GCD 和隊列下才變得特殊,任何使用了鎖機制的程序都會碰到同樣的問題——只不過不同的鎖機制會以不同的方式碰到。
所有對 dispatch_async,dispatch_sync 等等的調用都需要完成某種形式的鎖——以確保僅有一個線程或者特定的線程運行指定的代碼。GCD 某些程序上可以使用時序(譯注:原詞為 scheduling)來避免使用鎖,但在最后,問題只是稍有變化。根本問題仍然存在:如果你有大量的線程在相同時間去訪問同一個鎖或者隊列,你就會看到性能的變化。性能會嚴重下降。
你應該從直接復雜層次中隔離開。當你發(fā)現(xiàn)了性能下降,這明顯表明代碼中存在設計問題。這里有兩個開銷需要你來平衡。第一個是獨占臨界區(qū)資源太久的開銷,以至于別的線程都因為進入臨界區(qū)的操作而阻塞。第二個是太頻繁出入臨界區(qū)的開銷。在 GCD 的世界里,第一種開銷的情況就是一個 block 在隔離隊列中運行,它可能潛在的阻塞了其他將要在這個隔離隊列中運行的代碼。第二種開銷對應的就是調用 dispatch_async 和 dispatch_sync 。無論再怎么優(yōu)化,這兩個操作都不是無代價的。
令人憂傷的,不存在通用的標準來指導如何正確的平衡,你需要自己評測和調整。啟動 Instruments 觀察你的 app 忙于什么操作。
如果你看上面例子中的代碼,我們的臨界區(qū)代碼僅僅做了很簡單的事情。這可能是也可能不是好的方式,依賴于它怎么被使用。
在你自己的代碼中,要考慮自己是否在更高的層次保護了隔離隊列。舉個例子,類 Foo 有一個隔離隊列并且它本身保護著對 NSMutableDictionary 的訪問,代替的,可以有一個用到了 Foo 類的 Bar 類有一個隔離隊列保護所有對類 Foo 的使用。換句話說,你可以把類 Foo 變?yōu)榉蔷€程安全的(沒有隔離隊列),并在 Bar 中,使用一個隔離隊列來確保任何時刻只能有一個線程使用 Foo 。
4.全部使用異步分發(fā):
正如你在上面看到的,你可以同步和異步地分發(fā)一個 block,一個工作單元。但是我們需要正視一個一個非常普遍的問題——死鎖。在 GCD 中,以同步分發(fā)的方式非常容易出現(xiàn)這種情況。見下面的代碼:
dispatch_queue_t queueA; // assume we have this
dispatch_sync(queueA, ^(){
dispatch_sync(queueA, ^(){
foo();
});
});
一旦我們進入到第二個 dispatch_sync 就會發(fā)生死鎖。我們不能分發(fā)到queueA,因為當前線程正在隊列中并且永遠不會離開。但是有更隱晦的產生死鎖方式:
dispatch_queue_t queueA; // assume we have this
dispatch_queue_t queueB; // assume we have this
dispatch_sync(queueA, ^(){
foo();
});
void foo(void){
dispatch_sync(queueB, ^(){
bar();
});
}
void bar(void){
dispatch_sync(queueA, ^(){
baz();
});
}
單獨的每次調用 dispatch_sync() 看起來都沒有問題,但是一旦組合起來,就會發(fā)生死鎖。
這是使用同步分發(fā)存在的固有問題,如果我們使用異步分發(fā),比如:
dispatch_queue_t queueA; // assume we have this
dispatch_async(queueA, ^(){
dispatch_async(queueA, ^(){
foo();
});
});
一切運行正常。異步調用不會產生死鎖。因此值得我們在任何可能的時候都使用異步分發(fā)。我們使用一個異步調用結果 block 的函數(shù),來代替編寫一個返回值(必須要用同步)的方法或者函數(shù)。這種方式,我們會有更少發(fā)生死鎖的可能性。
異步調用的副作用就是它們很難調試。當我們在調試器里中止代碼運行,回溯并查看已經變得沒有意義了。
要牢記這些。死鎖通常是最難處理的問題。
5.如何寫出好的異步API(重點):
如果你正在給設計一個給別人(或者是給自己)使用的 API,你需要記住幾種好的實踐。
正如我們剛剛提到的,你需要傾向于異步 API。當你創(chuàng)建一個 API,它會在你的控制之外以各種方式調用,如果你的代碼能產生死鎖,那么死鎖就會發(fā)生。
如果你需要寫的函數(shù)或者方法,那么讓它們調用 dispatch_async() 。不要讓你的函數(shù)調用者來這么做,這個調用應該在你的方法或者函數(shù)中來做。
如果你的方法或函數(shù)有一個返回值,異步地將其傳遞給一個回調處理程序。這個 API 應該是這樣的,你的方法或函數(shù)同時持有一個結果 block 和一個將結果傳遞過去的隊列。你函數(shù)的調用者不需要自己來做分發(fā)。這么做的原因很簡單:幾乎所有時間,函數(shù)調用都應該在一個適當?shù)年犃兄校乙赃@種方式編寫的代碼是很容易閱讀的。總之,你的函數(shù)將會(必須)調用 dispatch_async() 去運行回調處理程序,所以它同時也可能在需要調用的隊列上做這些工作。
如果你寫一個類,讓你類的使用者設置一個回調處理隊列或許會是一個好的選擇。你的代碼可能像這樣:
- (void)processImage:(UIImage *)image completionHandler:(void(^)(BOOL success))handler{
dispatch_async(self.isolationQueue, ^(void){
// do actual processing here
dispatch_async(self.resultQueue, ^(void){
handler(YES);
});
});
}
如果你以這種方式來寫你的類,讓類之間協(xié)同工作就會變得容易。如果類 A 使用了類 B,它會把自己的隔離隊列設置為 B 的回調隊列。