1. 程式人生 > >深入探索GCD----關於GCD你不知道的全在這裡(一)

深入探索GCD----關於GCD你不知道的全在這裡(一)

很久很久以前:

或許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 只會執行一次。並且在連續的呼叫中,這種檢查是很高效的。你能使用它來初始化全域性資料比如單例。

要注意的是,使用 dispatch_once_t 會使得測試變得非常困難(單例和測試不是很好配合)。

要確保 onceToken 被宣告為 static ,或者有全域性作用域。任何其他的情況都會導致無法預知的行為。換句話說,不要把 dispatch_once_t 作為一個物件的成員變數,或者類似的情形。

退回到遠古時代(其實也就是幾年前),人們會使用 pthread_once ,因為 dispatch_once_t 更容易使用並且不易出錯,所以你永遠都不會再用到 pthread_once 了。

延時執行:

另一個常見的小夥伴就是 dispatch_after 了。它使工作延後執行。它是很強大的,但是要注意:你很容易就陷入到一堆麻煩中。一般用法是這樣的:


- (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 時,會存在一些有問題的傾向。一些程式碼執行的過早而你很可能不知道為什麼會這樣,所以你把這段程式碼放到了 dispatch_after 中,現在一切執行正常了。但是幾周以後,之前的工作不起作用了。由於你並不十分清楚你自己程式碼的執行次序,除錯程式碼就變成了一場噩夢。所以不要像上面這樣做。

大多數的情況下,你最好把程式碼放到正確的位置。如果程式碼放到 -viewWillAppear 太早,那麼或許 -viewDidAppear 就是正確的地方。
通過在自己程式碼中建立直接呼叫(類似 -viewDidAppear )而不是依賴於 dispatch_after ,你會為自己省去很多麻煩。

如果你需要一些事情在某個特定的時刻執行,那麼 dispatch_after 或許會是個好的選擇。但是我還是推薦使用NSTimer,這個API雖然有點笨重,但是它允許你取消定時器的觸發。

佇列:

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 一起執行。
GCD 佇列的內部使用的是執行緒。GCD 管理這些執行緒,並且使用 GCD 的時候,你不需要自己建立執行緒。但是重要的外在部分 GCD 會呈現給你,也就是使用者 API,一個很大不同的抽象層級。當使用 GCD 來完成併發的工作時,你不必考慮執行緒方面的問題,取而代之的,只需考慮佇列和功能點(提交給佇列的 block)。雖然往下深究,依然都是執行緒,但是 GCD 的抽象層級為你慣用的編碼提供了更好的方式。
佇列和功能點同時解決了一個連續不斷的扇出的問題:如果我們直接使用執行緒,並且想要做一些併發的事情,我們很可能將我們的工作分成 100 個小的功能點,然後基於可用的 CPU 核心數量來建立執行緒,假設是 8。我們把這些功能點送到這 8 個執行緒中。當我們處理這些功能點時,可能會呼叫一些函式作為功能的一部分。寫那個函式的人也想要使用併發,因此當你呼叫這個函式的時候,這個函式也會建立 8 個執行緒。現在,你有了 8 × 8 = 64 個執行緒,儘管你只有 8 個CPU核心——也就是說任何時候只有12%的執行緒實際在執行而另外88%的執行緒什麼事情都沒做。使用 GCD 你就不會遇到這種問題,當系統關閉 CPU 核心以省電時,GCD 甚至能夠相應地調整執行緒數量。
GCD 通過建立所謂的執行緒池來大致匹配 CPU 核心數量。要記住,執行緒的建立並不是無代價的。每個執行緒都需要佔用記憶體和核心資源。這裡也有一個問題:如果你提交了一個 block 給 GCD,但是這段程式碼阻塞了這個執行緒,那麼這個執行緒在這段時間內就不能用來完成其他工作——它被阻塞了。為了確保功能點在佇列上一直是執行的,GCD 不得不建立一個新的執行緒,並把它新增到執行緒池。

如果你的程式碼阻塞了許多執行緒,這會帶來很大的問題。首先,執行緒消耗資源,此外,建立執行緒會變得代價高昂。建立過程需要一些時間。並且在這段時間中,GCD 無法以全速來完成功能點。有不少能夠導致執行緒阻塞的情況,但是最常見的情況與 I/O 有關,也就是從檔案或者網路中讀寫資料。正是因為這些原因,你不應該在GCD佇列中以阻塞的方式來做這些操作。看一下下面的輸入輸出段落去了解一些關於如何以 GCD 執行良好的方式來做 I/O 操作的資訊。

目標佇列:

你能夠為你建立的任何一個佇列設定一個目標佇列。這會是很強大的,並且有助於除錯。
為一個類建立它自己的佇列而不是使用全域性的佇列被普遍認為是一種好的風格。這種方式下,你可以設定佇列的名字,這讓除錯變得輕鬆許多—— Xcode 可以讓你在 Debug Navigator 中看到所有的佇列名字,如果你直接使用 lldb。(lldb) thread list 命令將會在控制檯打印出所有佇列的名字。一旦你使用大量的非同步內容,這會是非常有用的幫助。
使用私有佇列同樣強調封裝性。這時你自己的佇列,你要自己決定如何使用它。
預設情況下,一個新建立的佇列轉發到預設優先順序的全域性佇列中。我們就將會討論一些有關優先順序的東西。
你可以改變你佇列轉發到的佇列——你可以設定自己佇列的目標佇列。以這種方式,你可以將不同佇列連結在一起。你的 Foo 類有一個佇列,該佇列轉發到 Bar 類的佇列,Bar 類的佇列又轉發到全域性佇列。
當你為了隔離目的而使用一個佇列時,這會非常有用。Foo 有一個隔離佇列,並且轉發到 Bar 的隔離佇列,與 Bar 的隔離佇列所保護的有關的資源,會自動成為執行緒安全的。
如果你希望多個 block 同時執行,那要確保你自己的佇列是併發的。同時需要注意,如果一個佇列的目標佇列是序列的(也就是非併發),那麼實際上這個佇列也會轉換為一個序列佇列。

優先順序:

你可以通過設定目標佇列為一個全域性佇列來改變自己佇列的優先順序,但是你應該剋制這麼做的衝動。
在大多數情況下,改變優先順序不會使事情照你預想的方向執行。一些看起簡單的事情實際上是一個非常複雜的問題。你很容易會碰到一個叫做優先順序反轉的情況。
此外,使用 DISPATCH_QUEUE_PRIORITY_BACKGROUND 佇列時,你需要格外小心。除非你理解了 throttled I/O 和 background status as per setpriority(2) 的意義,否則不要使用它。不然,系統可能會以難以忍受的方式終止你的 app 的執行。打算以不干擾系統其他正在做 I/O 操作的方式去做 I/O 操作時,一旦和優先順序反轉情況結合起來,這會變成一種危險的情況。

隔離:

隔離佇列是 GCD 佇列使用中非常普遍的一種模式。這裡有兩個變種。

資源保護:

多執行緒程式設計中,最常見的情形是你有一個資源,每次只有一個執行緒被允許訪問這個資源。
我們在有關多執行緒技術的文章中知道資源在併發程式設計中意味著什麼,它通常就是一塊記憶體或者一個物件,每次只有一個執行緒可以訪問它。
舉例來說,我們需要以多執行緒(或者多個佇列)方式訪問 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、從函式介面可以看出,-setCount:forKey: 需要一個 NSString 引數,用來傳遞給 dispatch_async。函式呼叫者可以自由傳遞一個 NSMutableString 值並且能夠在函式返回後修改它。因此我們必須對傳入的字串使用 copy 操作以確保函式能夠正確地工作。如果傳入的字串不是可變的(也就是正常的 NSString 型別),呼叫copy基本上是個空操作。
4、isolationQueue 建立時,引數 dispatch_queue_attr_t 的值必須是DISPATCH_QUEUE_SERIAL(或者0)。

單一資源的多讀單寫:

我們能夠改善上面的那個例子。GCD 有可以讓多執行緒執行的併發佇列。我們能夠安全地使用多執行緒來從 NSMutableDictionary 中讀取只要我們不同時修改它。當我們需要改變這個字典時,我們使用 barrier 來分發這個 block。這樣的一個 block 的執行時機是,在它之前所有計劃好的 block 完成之後,並且在所有它後面的 block 執行之前。
以如下方式建立佇列:

self.isolationQueue = dispatch_queue_create([label UTF8String], DISPATCH_QUEUE_CONCURRENT);
然後重寫setter函式:
- (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);
        }
    });
}

當使用併發佇列時,要確保所有的 barrier 呼叫都是 async 的。如果你使用 dispatch_barrier_sync ,那麼你很可能會使你的程式碼產生死鎖。寫操作需要 barrier,並且可以是 async 的。

鎖競爭:

首先,這裡有一個警告:上面這個例子中我們保護的資源是一個 NSMutableDictionary,出於這樣的目的,這段程式碼執行地相當不錯。但是在真實的程式碼中,把隔離放到正確的複雜度層級下是很重要的。
如果你對 NSMutableDictionary 的訪問操作變得非常頻繁,你會碰到一個已知的叫做鎖競爭的問題。鎖競爭並不是只是在 GCD 和佇列下才變得特殊,任何使用了鎖機制的程式都會碰到同樣的問題——只不過不同的鎖機制會以不同的方式碰到。
所有對 dispatch_async,dispatch_sync 等等的呼叫都需要完成某種形式的鎖——以確保僅有一個執行緒或者特定的執行緒執行指定的程式碼。GCD 某些程式上可以使用時序(譯註:原詞為 scheduling)來避免使用鎖,但在最後,問題只是稍有變化。根本問題仍然存在:如果你有大量的執行緒在相同時間去訪問同一個鎖或者佇列,你就會看到效能的變化。效能會嚴重下降。
你應該從直接複雜層次中隔離開。當你發現了效能下降,這明顯表明程式碼中存在設計問題。這裡有兩個開銷需要你來平衡。第一個是獨佔臨界區資源太久的開銷,以至於別的執行緒都因為進入臨界區的操作而阻塞。第二個是太頻繁出入臨界區的開銷。在 GCD 的世界裡,第一種開銷的情況就是一個 block 在隔離佇列中執行,它可能潛在的阻塞了其他將要在這個隔離佇列中執行的程式碼。第二種開銷對應的就是呼叫 dispatch_async 和 dispatch_sync 。無論再怎麼優化,這兩個操作都不是無代價的。
令人憂傷的,不存在通用的標準來指導如何正確的平衡,你需要自己評測和調整。啟動 Instruments 觀察你的 app 忙於什麼操作。
如果你看上面例子中的程式碼,我們的臨界區程式碼僅僅做了很簡單的事情。這可能是也可能不是好的方式,依賴於它怎麼被使用。
在你自己的程式碼中,要考慮自己是否在更高的層次保護了隔離佇列。舉個例子,類 Foo 有一個隔離佇列並且它本身保護著對 NSMutableDictionary 的訪問,代替的,可以有一個用到了 Foo 類的 Bar 類有一個隔離佇列保護所有對類 Foo 的使用。換句話說,你可以把類 Foo 變為非執行緒安全的(沒有隔離佇列),並在 Bar 中,使用一個隔離佇列來確保任何時刻只能有一個執行緒使用 Foo 。

全部使用非同步分發:

正如你在上面看到的,你可以同步和非同步地分發一個 block,一個工作單元。但是我們需要正視一個一個非常普遍的問題——死鎖。在 GCD 中,以同步分發的方式非常容易出現這種情況。見下面的程式碼:

dispatch_queue_t queueA; // assume we have this
dispatch_sync(queueA, ^(){
    dispatch_sync(queueA, ^(){
        foo();
    });
});

一旦我們進入到第二個 dispatch_sync 就會發生死鎖。我們不能分發到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() 看起來都沒有問題,但是一旦組合起來,就會發生死鎖。
這是使用同步分發存在的固有問題,如果我們使用非同步分發,比如:

dispatch_queue_t queueA; // assume we have this
dispatch_async(queueA, ^(){
    dispatch_async(queueA, ^(){
        foo();
    });
});
一切執行正常。非同步呼叫不會產生死鎖。因此值得我們在任何可能的時候都使用非同步分發。我們使用一個非同步呼叫結果 block 的函式,來代替編寫一個返回值(必須要用同步)的方法或者函式。這種方式,我們會有更少發生死鎖的可能性。
非同步呼叫的副作用就是它們很難除錯。當我們在偵錯程式裡中止程式碼執行,回溯並檢視已經變得沒有意義了。

要牢記這些。死鎖通常是最難處理的問題。

如何寫出好的非同步API(重點):

如果你正在給設計一個給別人(或者是給自己)使用的 API,你需要記住幾種好的實踐。

正如我們剛剛提到的,你需要傾向於非同步 API。當你建立一個 API,它會在你的控制之外以各種方式呼叫,如果你的程式碼能產生死鎖,那麼死鎖就會發生。
如果你需要寫的函式或者方法,那麼讓它們呼叫 dispatch_async() 。不要讓你的函式呼叫者來這麼做,這個呼叫應該在你的方法或者函式中來做。
如果你的方法或函式有一個返回值,非同步地將其傳遞給一個回撥處理程式。這個 API 應該是這樣的,你的方法或函式同時持有一個結果 block 和一個將結果傳遞過去的佇列。你函式的呼叫者不需要自己來做分發。這麼做的原因很簡單:幾乎所有時間,函式呼叫都應該在一個適當的佇列中,而且以這種方式編寫的程式碼是很容易閱讀的。總之,你的函式將會(必須)呼叫 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);
        });
    });
}

如果你以這種方式來寫你的類,讓類之間協同工作就會變得容易。如果類 A 使用了類 B,它會把自己的隔離佇列設定為 B 的回撥佇列。