這篇文章里,我們將會討論一些 iOS 和 OS X 都可以使用的底層 API。除了 dispatch_once
,我們一般不鼓勵使用其中的任何一種技術。
但是我們想要揭示出表面之下深層次的一些可利用的方面。這些底層的 API 提供了大量的靈活性,隨之而來的是大量的復雜度和更多的責任。在我們的文章常見的后臺實踐中提到的高層的 API 和模式能夠讓你專注于手頭的任務并且免于大量的問題。通常來說,高層的 API 會提供更好的性能,除非你能承受起使用底層 API 帶來的糾結于調試代碼的時間和努力。
盡管如此,了解深層次下的軟件堆棧工作原理還是有很有幫助的。我們希望這篇文章能夠讓你更好的了解這個平臺,同時,讓你更加感謝這些高層的 API。
首先,我們將會分析大多數(shù)組成 Grand Central Dispatch 的部分。它已經存在了好幾年,并且蘋果公司持續(xù)添加功能并且改善它?,F(xiàn)在蘋果已經將其開源,這意味著它對其他平臺也是可用的了。最后,我們將會看一下原子操作——另外的一種底層代碼塊的集合。
或許關于并發(fā)編程最好的書是 M. Ben-Ari 寫的《Principles of Concurrent Programming》,ISBN 0-13-701078-8。如果你正在做任何與并發(fā)編程有關的事情,你需要讀一下這本書。這本書已經30多年了,仍然非常卓越。書中簡潔的寫法,優(yōu)秀的例子和練習,帶你領略并發(fā)編程中代碼塊的基本原理。這本書現(xiàn)在已經絕版了,但是它的一些復印版依然廣為流傳。有一個新版書,名字叫《Principles of Concurrent and Distributed Programming》,ISBN 0-321-31283-X,好像有很多相同的地方,不過我還沒有讀過。
或許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
了。
另一個常見的小伙伴就是 dispatch_after
了。它使工作延后執(zhí)行。它是很強大的,但是要注意:你很容易就陷入到一堆麻煩中。一般用法是這樣的:
- (void)foo
{
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 個線程?,F(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 有關,也就是從文件或者網(wǎng)絡中讀寫數(shù)據(jù)。正是因為這些原因,你不應該在GCD隊列中以阻塞的方式來做這些操作。看一下下面的輸入輸出段落去了解一些關于如何以 GCD 運行良好的方式來做 I/O 操作的信息。
你能夠為你創(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ā)),那么實際上這個隊列也會轉換為一個串行隊列。
你可以通過設置目標隊列為一個全局隊列來改變自己隊列的優(yōu)先級,但是你應該克制這么做的沖動。
在大多數(shù)情況下,改變優(yōu)先級不會使事情照你預想的方向運行。一些看起簡單的事情實際上是一個非常復雜的問題。你很容易會碰到一個叫做優(yōu)先級反轉的情況。我們的文章《并發(fā)編程:API 及挑戰(zhàn)》有更多關于這個問題的信息,這個問題幾乎導致了NASA的探路者火星漫游器變成磚頭。
此外,使用 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 隊列使用中非常普遍的一種模式。這里有兩個變種。
多線程編程中,最常見的情形是你有一個資源,每次只有一個線程被允許訪問這個資源。
我們在有關多線程技術的文章中討論了資源在并發(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
的實例。
注意以下四點:
async
方式來保存值,這很重要。我們不想也不必阻塞當前線程只是為了等待寫操作完成。當讀操作時,我們使用 sync
因為我們需要返回值。-setCount:forKey:
需要一個 NSString
參數(shù),用來傳遞給 dispatch_async
。函數(shù)調用者可以自由傳遞一個 NSMutableString
值并且能夠在函數(shù)返回后修改它。因此我們必須對傳入的字符串使用 copy 操作以確保函數(shù)能夠正確地工作。如果傳入的字符串不是可變的(也就是正常的 NSString
類型),調用copy基本上是個空操作。isolationQueue
創(chuàng)建時,參數(shù) dispatch_queue_attr_t
的值必須是DISPATCH_QUEUE_SERIAL(或者0)。我們能夠改善上面的那個例子。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 的。
首先,這里有一個警告:上面這個例子中我們保護的資源是一個 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
。
我們在這稍稍轉變以下話題。正如你在上面看到的,你可以同步和異步地分發(fā)一個 block,一個工作單元。我們在《并發(fā)編程:API 及挑戰(zhàn)》)中討論的一個非常普遍的問題就是死鎖。在 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ā)生死鎖的可能性。
異步調用的副作用就是它們很難調試。當我們在調試器里中止代碼運行,回溯并查看已經變得沒有意義了。
要牢記這些。死鎖通常是最難處理的問題。
如果你正在給設計一個給別人(或者是給自己)使用的 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 的回調隊列。
如果你正在倒弄一些數(shù)字,并且手頭上的問題可以拆分出同樣性質的部分,那么 dispatch_apply
會很有用。
如果你的代碼看起來是這樣的:
for (size_t y = 0; y < height; ++y) {
for (size_t x = 0; x < width; ++x) {
// Do something with x and y here
}
}
小小的改動或許就可以讓它運行的更快:
dispatch_apply(height, dispatch_get_global_queue(0, 0), ^(size_t y) {
for (size_t x = 0; x < width; x += 2) {
// Do something with x and y here
}
});
代碼運行良好的程度取決于你在循環(huán)內部做的操作。
block 中運行的工作必須是非常重要的,否則這個頭部信息就顯得過于繁重了。除非代碼受到計算帶寬的約束,每個工作單元為了很好適應緩存大小而讀寫的內存都是臨界的。這會對性能會帶來顯著的影響。受到臨界區(qū)約束的代碼可能不會很好地運行。詳細討論這些問題已經超出了這篇文章的范圍。使用 dispatch_apply
可能會對性能提升有所幫助,但是性能優(yōu)化本身就是個很復雜的主題。維基百科上有一篇關于 Memory-bound function 的文章。內存訪問速度在 L2,L3 和主存上變化很顯著。當你的數(shù)據(jù)訪問模式與緩存大小不匹配時,10倍性能下降的情況并不少見。
很多時候,你發(fā)現(xiàn)需要將異步的 block 組合起來去完成一個給定的任務。這些任務中甚至有些是并行的。現(xiàn)在,如果你想要在這些任務都執(zhí)行完成后運行一些代碼,"groups" 可以完成這項任務??催@里的例子:
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_group_async(group, queue, ^(){
// Do something that takes a while
[self doSomeFoo];
dispatch_group_async(group, dispatch_get_main_queue(), ^(){
self.foo = 42;
});
});
dispatch_group_async(group, queue, ^(){
// Do something else that takes a while
[self doSomeBar];
dispatch_group_async(group, dispatch_get_main_queue(), ^(){
self.bar = 1;
});
});
// This block will run once everything above is done:
dispatch_group_notify(group, dispatch_get_main_queue(), ^(){
NSLog(@"foo: %d", self.foo);
NSLog(@"bar: %d", self.bar);
});
需要注意的重要事情是,所有的這些都是非阻塞的。我們從未讓當前的線程一直等待直到別的任務做完。恰恰相反,我們只是簡單的將多個 block 放入隊列。由于代碼不會阻塞,所以就不會產生死鎖。
同時需要注意的是,在這個小并且簡單的例子中,我們是怎么在不同的隊列間進切換的。
一旦你將 groups 作為你的工具箱中的一部分,你可能會懷疑為什么大多數(shù)的異步API不把 dispatch_group_t
作為一個可選參數(shù)。這沒有什么無法接受的理由,僅僅是因為自己添加這個功能太簡單了,但是你還是要小心以確保自己使用 groups 的代碼是成對出現(xiàn)的。
舉例來說,我們可以給 Core Data 的 -performBlock:
API 函數(shù)添加上 groups,就像這樣:
- (void)withGroup:(dispatch_group_t)group performBlock:(dispatch_block_t)block
{
if (group == NULL) {
[self performBlock:block];
} else {
dispatch_group_enter(group);
[self performBlock:^(){
block();
dispatch_group_leave(group);
}];
}
}
當 Core Data 上的一系列操作(很可能和其他的代碼組合起來)完成以后,我們可以使用 dispatch_group_notify
來運行一個 block 。
很明顯,我們可以給 NSURLConnection
做同樣的事情:
+ (void)withGroup:(dispatch_group_t)group
sendAsynchronousRequest:(NSURLRequest *)request
queue:(NSOperationQueue *)queue
completionHandler:(void (^)(NSURLResponse*, NSData*, NSError*))handler
{
if (group == NULL) {
[self sendAsynchronousRequest:request
queue:queue
completionHandler:handler];
} else {
dispatch_group_enter(group);
[self sendAsynchronousRequest:request
queue:queue
completionHandler:^(NSURLResponse *response, NSData *data, NSError *error){
handler(response, data, error);
dispatch_group_leave(group);
}];
}
}
為了能正常工作,你需要確保:
dispatch_group_enter()
必須要在 dispatch_group_leave()
之前運行。dispatch_group_enter()
和 dispatch_group_leave()
一直是成對出現(xiàn)的(就算有錯誤產生時)。GCD 有一個較少人知道的特性:事件源 dispatch_source_t
。
跟 GCD 一樣,它也是很底層的東西。當你需要用到它時,它會變得極其有用。它的一些使用是秘傳招數(shù),我們將會接觸到一部分的使用。但是大部分事件源在 iOS 平臺不是很有用,因為在 iOS 平臺有諸多限制,你無法啟動進程(因此就沒有必要監(jiān)視進程),也不能在你的 app bundle 之外寫數(shù)據(jù)(因此也就沒有必要去監(jiān)視文件)等等。
GCD 事件源是以極其資源高效的方式實現(xiàn)的。
如果一些進程正在運行而你想知道他們什么時候存在,GCD 能夠做到這些。你也可以使用 GCD 來檢測進程什么時候分叉,也就是產生子進程或者傳送給了進程的一個信號(比如 SIGTERM
)。
NSRunningApplication *mail = [NSRunningApplication
runningApplicationsWithBundleIdentifier:@"com.apple.mail"];
if (mail == nil) {
return;
}
pid_t const pid = mail.processIdentifier;
self.source = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC, pid,
DISPATCH_PROC_EXIT, DISPATCH_TARGET_QUEUE_DEFAULT);
dispatch_source_set_event_handler(self.source, ^(){
NSLog(@"Mail quit.");
});
dispatch_resume(self.source);
當 Mail.app 退出的時候,這個程序會打印出 Mail quit.。
注意:在所有的事件源被傳遞到你的事件處理器之前,必須調用 dispatch_resume()
。
這種可能性是無窮的。你能直接監(jiān)視一個文件的改變,并且當改變發(fā)生時事件源的事件處理將會被調用。
你也可以使用它來監(jiān)視文件夾,比如創(chuàng)建一個 watch folder:
NSURL *directoryURL; // assume this is set to a directory
int const fd = open([[directoryURL path] fileSystemRepresentation], O_EVTONLY);
if (fd < 0) {
char buffer[80];
strerror_r(errno, buffer, sizeof(buffer));
NSLog(@"Unable to open \"%@\": %s (%d)", [directoryURL path], buffer, errno);
return;
}
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_VNODE, fd,
DISPATCH_VNODE_WRITE | DISPATCH_VNODE_DELETE, DISPATCH_TARGET_QUEUE_DEFAULT);
dispatch_source_set_event_handler(source, ^(){
unsigned long const data = dispatch_source_get_data(source);
if (data & DISPATCH_VNODE_WRITE) {
NSLog(@"The directory changed.");
}
if (data & DISPATCH_VNODE_DELETE) {
NSLog(@"The directory has been deleted.");
}
});
dispatch_source_set_cancel_handler(source, ^(){
close(fd);
});
self.source = source;
dispatch_resume(self.source);
你應該總是添加 DISPATCH_VNODE_DELETE
去檢測文件或者文件夾是否已經被刪除——然后就停止監(jiān)聽。
大多數(shù)情況下,對于定時事件你會選擇 NSTimer
。定時器的GCD版本是底層的,它會給你更多控制權——但要小心使用。
需要特別重點指出的是,為了讓 OS 節(jié)省電量,需要為 GCD 的定時器接口指定一個低的余地值(譯注:原文leeway value)。如果你不必要的指定了一個低余地值,將會浪費更多的電量。
這里我們設定了一個5秒的定時器,并允許有十分之一秒的余地值:
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER,
0, 0, DISPATCH_TARGET_QUEUE_DEFAULT);
dispatch_source_set_event_handler(source, ^(){
NSLog(@"Time flies.");
});
dispatch_time_t start
dispatch_source_set_timer(source, DISPATCH_TIME_NOW, 5ull * NSEC_PER_SEC,
100ull * NSEC_PER_MSEC);
self.source = source;
dispatch_resume(self.source);
所有的事件源都允許你添加一個 cancel handler 。這對清理你為事件源創(chuàng)建的任何資源都是很有幫助的,比如關閉文件描述符。GCD 保證在 cancel handle 調用前,所有的事件處理都已經完成調用。
參考上面的監(jiān)視文件例子中對 dispatch_source_set_cancel_handler()
的使用。
寫出能夠在繁重的 I/O 處理情況下運行良好的代碼是一件非常棘手的事情。GCD 有一些能夠幫上忙的地方。不會涉及太多的細節(jié),我們只簡單的分析下問題是什么,GCD 是怎么處理的。
習慣上,當你從一個網(wǎng)絡套接字中讀取數(shù)據(jù)時,你要么做一個阻塞的讀操作,也就是讓你個線程一直等待直到數(shù)據(jù)變得可用,或者是做反復的輪詢。這兩種方法都是很浪費資源并且無法度量。然而,kqueue
通過當數(shù)據(jù)變得可用時傳遞一個事件解決了輪詢的問題,GCD 也采用了同樣的方法,但是更加優(yōu)雅。當向套接字寫數(shù)據(jù)時,同樣的問題也存在,這時你要么做阻塞的寫操作,要么等待套接字直到能夠接收數(shù)據(jù)。
在處理 I/O 時,還有一個問題就是數(shù)據(jù)是以數(shù)據(jù)塊的形式到達的。當從網(wǎng)絡中讀取數(shù)據(jù)時,依據(jù) MTU(最大傳輸單元),數(shù)據(jù)塊典型的大小是在1.5K字節(jié)左右。這使得數(shù)據(jù)塊內可以是任何內容。一旦數(shù)據(jù)到達,你通常只是對跨多個數(shù)據(jù)塊的內容感興趣。而且通常你會在一個大的緩沖區(qū)里將數(shù)據(jù)組合起來然后再進行處理。假設(人為例子)你收到了這樣8個數(shù)據(jù)塊:
0: HTTP/1.1 200 OK\r\nDate: Mon, 23 May 2005 22:38
1: :34 GMT\r\nServer: Apache/1.3.3.7 (Unix) (Red-H
2: at/Linux)\r\nLast-Modified: Wed, 08 Jan 2003 23
3: :11:55 GMT\r\nEtag: "3f80f-1b6-3e1cb03b"\r\nCon
4: tent-Type: text/html; charset=UTF-8\r\nContent-
5: Length: 131\r\nConnection: close\r\n\r\n<html>\r
6: \n<head>\r\n <title>An Example Page</title>\r\n
7: </head>\r\n<body>\r\n Hello World, this is a ve
如果你是在尋找 HTTP 的頭部,將所有數(shù)據(jù)塊組合成一個大的緩沖區(qū)并且從中查找 \r\n\r\n
是非常簡單的。但是這樣做,你會大量地復制這些數(shù)據(jù)。大量 舊的 C 語言 API 存在的另一個問題就是,緩沖區(qū)沒有所有權的概念,所以函數(shù)不得不將數(shù)據(jù)再次拷貝到自己的緩沖區(qū)中——又一次的拷貝??截悢?shù)據(jù)操作看起來是無關緊要的,但是當你正在做大量的 I/O 操作的時候,你會在 profiling tool(Instruments) 中看到這些拷貝操作大量出現(xiàn)。即使你僅僅每個內存區(qū)域拷貝一次,你還是使用了兩倍的存儲帶寬并且占用了兩倍的內存緩存。
最直接了當?shù)姆椒ㄊ鞘褂脭?shù)據(jù)緩沖區(qū)。GCD 有一個 dispatch_data_t
類型,在某種程度上和 Objective-C 的 NSData
類型很相似。但是它能做別的事情,而且更通用。
注意,dispatch_data_t
可以被 retained 和 releaseed ,并且 dispatch_data_t
擁有它持有的對象。
這看起來無關緊要,但是我們必須記住 GCD 只是純 C 的 API,并且不能使用Objective-C。通常的做法是創(chuàng)建一個緩沖區(qū),這個緩沖區(qū)要么是基于棧的,要么是 malloc
操作分配的內存區(qū)域 —— 這些都沒有所有權。
dispatch_data_t
的一個相當獨特的屬性是它可以基于零碎的內存區(qū)域。這解決了我們剛提到的組合內存的問題。當你要將兩個數(shù)據(jù)對象連接起來時:
dispatch_data_t a; // Assume this hold some valid data
dispatch_data_t b; // Assume this hold some valid data
dispatch_data_t c = dispatch_data_create_concat(a, b);
數(shù)據(jù)對象 c 并不會將 a 和 b 拷貝到一個單獨的,更大的內存區(qū)域里去。相反,它只是簡單地 retain 了 a 和 b。你可以使用 dispatch_data_apply
來遍歷對象 c 持有的內存區(qū)域:
dispatch_data_apply(c, ^bool(dispatch_data_t region, size_t offset, const void *buffer, size_t size) {
fprintf(stderr, "region with offset %zu, size %zu\n", offset, size);
return true;
});
類似的,你可以使用 dispatch_data_create_subrange
來創(chuàng)建一個不做任何拷貝操作的子區(qū)域。
在 GCD 的核心里,調度 I/O(譯注:原文為 Dispatch I/O) 與所謂的通道有關。調度 I/O 通道提供了一種與從文件描述符中讀寫不同的方式。創(chuàng)建這樣一個通道最基本的方式就是調用:
dispatch_io_t dispatch_io_create(dispatch_io_type_t type, dispatch_fd_t fd,
dispatch_queue_t queue, void (^cleanup_handler)(int error));
這將返回一個持有文件描述符的創(chuàng)建好的通道。在你通過它創(chuàng)建了通道之后,你不準以任何方式修改這個文件描述符。
有兩種從根本上不同類型的通道:流和隨機存取。如果你打開了硬盤上的一個文件,你可以使用它來創(chuàng)建一個隨機存取的通道(因為這樣的文件描述符是可尋址的)。如果你打開了一個套接字,你可以創(chuàng)建一個流通道。
如果你想要為一個文件創(chuàng)建一個通道,你最好使用需要一個路徑參數(shù)的 dispatch_io_create_with_path
,并且讓 GCD 來打開這個文件。這是有益的,因為GCD會延遲打開這個文件以限制相同時間內同時打開的文件數(shù)量。
類似通常的 read(2),write(2) 和 close(2) 的操作,GCD 提供了 dispatch_io_read
,dispatch_io_write
和 dispatch_io_close
。無論何時數(shù)據(jù)讀完或者寫完,讀寫操作調用一個回調 block 來結束。這些都是以非阻塞,異步 I/O 的形式高效實現(xiàn)的。
在這你得不到所有的細節(jié),但是這里會提供一個創(chuàng)建TCP服務端的例子:
首先我們創(chuàng)建一個監(jiān)聽套接字,并且設置一個接受連接的事件源:
_isolation = dispatch_queue_create([[self description] UTF8String], 0);
_nativeSocket = socket(PF_INET6, SOCK_STREAM, IPPROTO_TCP);
struct sockaddr_in sin = {};
sin.sin_len = sizeof(sin);
sin.sin_family = AF_INET6;
sin.sin_port = htons(port);
sin.sin_addr.s_addr= INADDR_ANY;
int err = bind(result.nativeSocket, (struct sockaddr *) &sin, sizeof(sin));
NSCAssert(0 <= err, @"");
_eventSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, _nativeSocket, 0, _isolation);
dispatch_source_set_event_handler(result.eventSource, ^{
acceptConnection(_nativeSocket);
});
當接受了連接,我們創(chuàng)建一個I/O通道:
typedef union socketAddress {
struct sockaddr sa;
struct sockaddr_in sin;
struct sockaddr_in6 sin6;
} socketAddressUnion;
socketAddressUnion rsa; // remote socket address
socklen_t len = sizeof(rsa);
int native = accept(nativeSocket, &rsa.sa, &len);
if (native == -1) {
// Error. Ignore.
return nil;
}
_remoteAddress = rsa;
_isolation = dispatch_queue_create([[self description] UTF8String], 0);
_channel = dispatch_io_create(DISPATCH_IO_STREAM, native, _isolation, ^(int error) {
NSLog(@"An error occured while listening on socket: %d", error);
});
//dispatch_io_set_high_water(_channel, 8 * 1024);
dispatch_io_set_low_water(_channel, 1);
dispatch_io_set_interval(_channel, NSEC_PER_MSEC * 10, DISPATCH_IO_STRICT_INTERVAL);
socketAddressUnion lsa; // remote socket address
socklen_t len = sizeof(rsa);
getsockname(native, &lsa.sa, &len);
_localAddress = lsa;
如果我們想要設置 SO_KEEPALIVE
(如果使用了HTTP的keep-alive),我們需要在調用 dispatch_io_create
前這么做。
創(chuàng)建好 I/O 通道后,我們可以設置讀取處理程序:
dispatch_io_read(_channel, 0, SIZE_MAX, _isolation, ^(bool done, dispatch_data_t data, int error){
if (data != NULL) {
if (_data == NULL) {
_data = data;
} else {
_data = dispatch_data_create_concat(_data, data);
}
[self processData];
}
});
如果所有你想做的只是讀取或者寫入一個文件,GCD 提供了兩個方便的封裝: dispatch_read
和 dispatch_write
。你需要傳遞給 dispatch_read
一個文件路徑和一個在所有數(shù)據(jù)塊讀取后調用的 block。類似的,dispatch_write
需要一個文件路徑和一個被寫入的 dispatch_data_t
對象。
在 GCD 的一個不起眼的角落,你會發(fā)現(xiàn)一個適合優(yōu)化代碼的靈巧小工具:
uint64_t dispatch_benchmark(size_t count, void (^block)(void));
把這個聲明放到你的代碼中,你就能夠測量給定的代碼執(zhí)行的平均的納秒數(shù)。例子如下:
size_t const objectCount = 1000;
uint64_t n = dispatch_benchmark(10000, ^{
@autoreleasepool {
id obj = @42;
NSMutableArray *array = [NSMutableArray array];
for (size_t i = 0; i < objectCount; ++i) {
[array addObject:obj];
}
}
});
NSLog(@"-[NSMutableArray addObject:] : %llu ns", n);
在我的機器上輸出了:
-[NSMutableArray addObject:] : 31803 ns
也就是說添加1000個對象到 NSMutableArray 總共消耗了31803納秒,或者說平均一個對象消耗32納秒。
正如 dispatch_benchmark
的幫助頁面指出的,測量性能并非如看起來那樣不重要。尤其是當比較并發(fā)代碼和非并發(fā)代碼時,你需要注意特定硬件上運行的特定計算帶寬和內存帶寬。不同的機器會很不一樣。如果代碼的性能與訪問臨界區(qū)有關,那么我們上面提到的鎖競爭問題就會有所影響。
不要把它放到發(fā)布代碼中,事實上,這是無意義的,它是私有API。它只是在調試和性能分析上起作用。
訪問幫助界面:
curl "http://opensource.apple.com/source/libdispatch/libdispatch-84.5/man/dispatch_benchmark.3?txt"
| /usr/bin/groffer --tty -T utf8
頭文件 libkern/OSAtomic.h
里有許多強大的函數(shù),專門用來底層多線程編程。盡管它是內核頭文件的一部分,它也能夠在內核之外來幫助編程。
這些函數(shù)都是很底層的,并且你需要知道一些額外的事情。就算你已經這樣做了,你還可能會發(fā)現(xiàn)一兩件你不能做,或者不易做的事情。當你正在為編寫高性能代碼或者正在實現(xiàn)無鎖的和無等待的算法工作時,這些函數(shù)會吸引你。
這些函數(shù)在 atomic(3)
的幫助頁里全部有概述——運行 man 3 atomic
命令以得到完整的文檔。你會發(fā)現(xiàn)里面討論到了內存屏障。查看維基百科中關于內存屏障的文章。如果你還存在疑問,那么你很可能需要它。
OSAtomicIncrement
和 OSAtomicDecrement
有一個很長的函數(shù)列表允許你以原子操作的方式去增加和減少一個整數(shù)值 —— 不必使用鎖(或者隊列)同時也是線程安全的。如果你需要讓一個全局的計數(shù)器值增加,而這個計數(shù)器為了統(tǒng)計目的而由多個線程操作,使用原子操作是很有幫助的。如果你要做的僅僅是增加一個全局計數(shù)器,那么無屏障版本的 OSAtomicIncrement
是很合適的,并且當沒有鎖競爭時,調用它們的代價很小。
類似的,OSAtomicOr
,OSAtomicAnd
,OSAtomicXor
的函數(shù)能用來進行邏輯運算,而 OSAtomicTest
可以用來設置和清除位。
OSAtomicCompareAndSwap
能用來做無鎖的惰性初始化,如下:
void * sharedBuffer(void)
{
static void * buffer;
if (buffer == NULL) {
void * newBuffer = calloc(1, 1024);
if (!OSAtomicCompareAndSwapPtrBarrier(NULL, newBuffer, &buffer)) {
free(newBuffer);
}
}
return buffer;
}
如果沒有 buffer,我們會創(chuàng)建一個,然后原子地將其寫到 buffer
中如果 buffer
為NULL。在極少的情況下,其他人在當前線程同時設置了 buffer
,我們簡單地將其釋放掉。因為比較和交換方法是原子的,所以它是一個線程安全的方式去惰性初始化值。NULL的檢測和設置 buffer
都是以原子方式完成的。
明顯的,使用 dispatch_once()
我們也可以完成類似的事情。
OSAtomicEnqueue()
和 OSAtomicDequeue()
可以讓你以線程安全,無鎖的方式實現(xiàn)一個LIFO隊列(常見的就是棧)。對有潛在精確要求的代碼來說,這會是強大的代碼。
還有 OSAtomicFifoEnqueue()
和 OSAtomicFifoDequeue()
函數(shù)是為了操作FIFO隊列,但這些只有在頭文件中才有文檔 —— 閱讀他們的時候要小心。
最后,OSAtomic.h
頭文件定義了使用自旋鎖的函數(shù):OSSpinLock
。同樣的,維基百科有深入的有關自旋鎖的信息。使用命令 man 3 spinlock
查看幫助頁的 spinlock(3)
。當沒有鎖競爭時使用自旋鎖代價很小。
在合適的情況下,使用自旋鎖對性能優(yōu)化是很有幫助的。一如既往:先測量,然后優(yōu)化。不要做樂觀的優(yōu)化。
下面是 OSSpinLock 的一個例子:
@interface MyTableViewCell : UITableViewCell
@property (readonly, nonatomic, copy) NSDictionary *amountAttributes;
@end
@implementation MyTableViewCell
{
NSDictionary *_amountAttributes;
}
- (NSDictionary *)amountAttributes;
{
if (_amountAttributes == nil) {
static __weak NSDictionary *cachedAttributes = nil;
static OSSpinLock lock = OS_SPINLOCK_INIT;
OSSpinLockLock(&lock);
_amountAttributes = cachedAttributes;
if (_amountAttributes == nil) {
NSMutableDictionary *attributes = [[self subtitleAttributes] mutableCopy];
attributes[NSFontAttributeName] = [UIFont fontWithName:@"ComicSans" size:36];
attributes[NSParagraphStyleAttributeName] = [NSParagraphStyle defaultParagraphStyle];
_amountAttributes = [attributes copy];
cachedAttributes = _amountAttributes;
}
OSSpinLockUnlock(&lock);
}
return _amountAttributes;
}
就上面的例子而言,或許用不著這么麻煩,但它演示了一種理念。我們使用了ARC的 __weak
來確保一旦 MyTableViewCell
所有的實例都不存在, amountAttributes
會調用 dealloc
。因此在所有的實例中,我們可以持有字典的一個單獨實例。
這段代碼運行良好的原因是我們不太可能訪問到方法最里面的部分。這是很深奧的——除非你真正需要,不然不要在你的 App 中使用它。