鍍金池/ 教程/ iOS/ 底層并發(fā) API
與四軸無人機的通訊
在沙盒中編寫腳本
結構體和值類型
深入理解 CocoaPods
UICollectionView + UIKit 力學
NSString 與 Unicode
代碼簽名探析
測試
架構
第二期-并發(fā)編程
Metal
自定義控件
iOS 中的行為
行為驅動開發(fā)
Collection View 動畫
截圖測試
MVVM 介紹
使 Mac 應用數(shù)據(jù)腳本化
一個完整的 Core Data 應用
插件
字符串
為 iOS 建立 Travis CI
先進的自動布局工具箱
動畫
為 iOS 7 重新設計 App
XPC
從 NSURLConnection 到 NSURLSession
Core Data 網(wǎng)絡應用實例
GPU 加速下的圖像處理
自定義 Core Data 遷移
子類
與調試器共舞 - LLDB 的華爾茲
圖片格式
并發(fā)編程:API 及挑戰(zhàn)
IP,TCP 和 HTTP
動畫解釋
響應式 Android 應用
初識 TextKit
客戶端
View-Layer 協(xié)作
回到 Mac
Android
Core Image 介紹
自定義 Formatters
Scene Kit
調試
項目介紹
Swift 的強大之處
測試并發(fā)程序
Android 通知中心
調試:案例學習
從 UIKit 到 AppKit
iOS 7 : 隱藏技巧和變通之道
安全
底層并發(fā) API
消息傳遞機制
更輕量的 View Controllers
用 SQLite 和 FMDB 替代 Core Data
字符串解析
終身學習的一代人
視頻
Playground 快速原型制作
Omni 內部
同步數(shù)據(jù)
設計優(yōu)雅的移動游戲
繪制像素到屏幕上
相機與照片
音頻 API 一覽
交互式動畫
常見的后臺實踐
糟糕的測試
避免濫用單例
數(shù)據(jù)模型和模型對象
Core Data
字符串本地化
View Controller 轉場
照片框架
響應式視圖
Square Register 中的擴張
DTrace
基礎集合類
視頻工具箱和硬件加速
字符串渲染
讓東西變得不那么糟
游戲中的多點互聯(lián)
iCloud 和 Core Data
Views
虛擬音域 - 聲音設計的藝術
導航應用
線程安全類的設計
置換測試: Mock, Stub 和其他
Build 工具
KVC 和 KVO
Core Image 和視頻
Android Intents
在 iOS 上捕獲視頻
四軸無人機項目
Mach-O 可執(zhí)行文件
UI 測試
值對象
活動追蹤
依賴注入
Swift
項目管理
整潔的 Table View 代碼
Swift 方法的多面性
為什么今天安全仍然重要
Core Data 概述
Foundation
Swift 的函數(shù)式 API
iOS 7 的多任務
自定義 Collection View 布局
測試 View Controllers
訪談
收據(jù)驗證
數(shù)據(jù)同步
自定義 ViewController 容器轉場
游戲
調試核對清單
View Controller 容器
學無止境
XCTest 測試實戰(zhàn)
iOS 7
Layer 中自定義屬性的動畫
第一期-更輕量的 View Controllers
精通 iCloud 文檔存儲
代碼審查的藝術:Dropbox 的故事
GPU 加速下的圖像視覺
Artsy
照片擴展
理解 Scroll Views
使用 VIPER 構建 iOS 應用
Android 中的 SQLite 數(shù)據(jù)庫支持
Fetch 請求
導入大數(shù)據(jù)集
iOS 開發(fā)者的 Android 第一課
iOS 上的相機捕捉
語言標簽
同步案例學習
依賴注入和注解,為什么 Java 比你想象的要好
編譯器
基于 OpenCV 的人臉識別
玩轉字符串
相機工作原理
Build 過程

底層并發(fā) API

這篇文章里,我們將會討論一些 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 了。

延后執(zhí)行

另一個常見的小伙伴就是 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)先級

你可以通過設置目標隊列為一個全局隊列來改變自己隊列的優(yōu)先級,但是你應該克制這么做的沖動。

在大多數(shù)情況下,改變優(yōu)先級不會使事情照你預想的方向運行。一些看起簡單的事情實際上是一個非常復雜的問題。你很容易會碰到一個叫做優(yōu)先級反轉的情況。我們的文章《并發(fā)編程:API 及挑戰(zhàn)》有更多關于這個問題的信息,這個問題幾乎導致了NASA的探路者火星漫游器變成磚頭。

此外,使用 DISPATCH_QUEUE_PRIORITY_BACKGROUND 隊列時,你需要格外小心。除非你理解了 throttled I/Obackground 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 的實例。

注意以下四點:

  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)。

單一資源的多讀單寫

我們能夠改善上面的那個例子。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_asyncdispatch_sync 。無論再怎么優(yōu)化,這兩個操作都不是無代價的。

令人憂傷的,不存在通用的標準來指導如何正確的平衡,你需要自己評測和調整。啟動 Instruments 觀察你的 app 忙于什么操作。

如果你看上面例子中的代碼,我們的臨界區(qū)代碼僅僅做了很簡單的事情。這可能是也可能不是好的方式,依賴于它怎么被使用。

在你自己的代碼中,要考慮自己是否在更高的層次保護了隔離隊列。舉個例子,類 Foo 有一個隔離隊列并且它本身保護著對 NSMutableDictionary 的訪問,代替的,可以有一個用到了 Foo 類的 Bar 類有一個隔離隊列保護所有對類 Foo 的使用。換句話說,你可以把類 Foo 變?yōu)榉蔷€程安全的(沒有隔離隊列),并在 Bar 中,使用一個隔離隊列來確保任何時刻只能有一個線程使用 Foo 。

全都使用異步分發(fā)

我們在這稍稍轉變以下話題。正如你在上面看到的,你可以同步和異步地分發(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,你需要記住幾種好的實踐。

正如我們剛剛提到的,你需要傾向于異步 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 的回調隊列。

迭代執(zhí)行

如果你正在倒弄一些數(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 放入隊列。由于代碼不會阻塞,所以就不會產生死鎖。

同時需要注意的是,在這個小并且簡單的例子中,我們是怎么在不同的隊列間進切換的。

對現(xiàn)有API使用 dispatch_group_t

一旦你將 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)的。

監(jiā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)視文件

這種可能性是無窮的。你能直接監(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ū)域拷貝一次,你還是使用了兩倍的存儲帶寬并且占用了兩倍的內存緩存。

GCD 和緩沖區(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_writedispatch_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_readdispatch_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)里面討論到了內存屏障。查看維基百科中關于內存屏障的文章。如果你還存在疑問,那么你很可能需要它。

計數(shù)器

OSAtomicIncrementOSAtomicDecrement 有一個很長的函數(shù)列表允許你以原子操作的方式去增加和減少一個整數(shù)值 —— 不必使用鎖(或者隊列)同時也是線程安全的。如果你需要讓一個全局的計數(shù)器值增加,而這個計數(shù)器為了統(tǒng)計目的而由多個線程操作,使用原子操作是很有幫助的。如果你要做的僅僅是增加一個全局計數(shù)器,那么無屏障版本的 OSAtomicIncrement 是很合適的,并且當沒有鎖競爭時,調用它們的代價很小。

類似的,OSAtomicOr ,OSAtomicAnd,OSAtomicXor 的函數(shù)能用來進行邏輯運算,而 OSAtomicTest 可以用來設置和清除位。

10.2、比較和交換

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 中使用它。

上一篇:Fetch 請求下一篇:在 iOS 上捕獲視頻