巧談GCD

wuyangnju發表於2016-06-20

談到iOS多執行緒,一般都會談到四種方式:pthread、NSThread、GCD和NSOperation。其中,蘋果推薦也是我們最經常使用的無疑是GCD。對於身為開發者的我們來說,併發一直都很棘手,如果對GCD的理解不夠透徹,那麼iOS開發的歷程絕對不會順利。這裡,我會從幾個角度淺談我對GCD的理解。

一、多執行緒背景

Although threads have been around for many years and continue to have their uses, they do not solve the general problem of executing multiple tasks in a scalable way. With threads, the burden of creating a scalable solution rests squarely on the shoulders of you, the developer. You have to decide how many threads to create and adjust that number dynamically as system conditions change. Another problem is that your application assumes most of the costs associated with creating and maintaining any threads it uses.

上述大致說出了直接操縱執行緒實現多執行緒的弊端:

  • 開發人員必須根據系統的變化動態調整執行緒的數量和狀態,即對開發者的負擔重。

  • 應用程式會在建立和維護執行緒上消耗很多成本,即效率低。

相對的,GCD是一套低層級的C API,通過 GCD,開發者只需要向佇列中新增一段程式碼塊(block或C函式指標),而不需要直接和執行緒打交道。GCD在後端管理著一個執行緒池,它不僅決定著你的程式碼塊將在哪個執行緒被執行,還根據可用的系統資源對這些執行緒進行管理。GCD的工作方式,使其擁有很多優點(快、穩、準):

  • 快,更快的記憶體效率,因為執行緒棧不暫存於應用記憶體。

  • 穩,提供了自動的和全面的執行緒池管理機制,穩定而便捷。

  • 準,提供了直接並且簡單的呼叫介面,使用方便,準確。

二、佇列和任務

初學GCD的時候,肯定會糾結一些看似很關鍵但卻毫無意義的問題。比如:GCD和執行緒到底什麼關係;非同步任務到底在哪個執行緒工作;佇列到底是個什麼東西;mian queue和main thread到底搞什麼名堂等等。現在,這些我們直接略過(最後拾遺中會談一下),蘋果既然推薦使用GCD,那麼為什麼還要糾結於執行緒呢?需要關注的只有兩個概念:佇列、任務。

1. 佇列

排程佇列是一個物件,它會以first-in、first-out的方式管理您提交的任務。GCD有三種佇列型別:

  • 序列佇列,序列佇列將任務以先進先出(FIFO)的順序來執行,所以序列佇列經常用來做訪問某些特定資源的同步處理。你可以也根據需要建立多個佇列,而這些佇列相對其他佇列都是併發執行的。換句話說,如果你建立了4個序列佇列,每一個佇列在同一時間都只執行一個任務,對這四個任務來說,他們是相互獨立且併發執行的。如果需要建立序列佇列,一般用dispatch_queue_create這個方法來實現,並指定佇列型別DISPATCH_QUEUE_SERIAL。

  • 並行佇列,併發佇列雖然是能同時執行多個任務,但這些任務仍然是按照先到先執行(FIFO)的順序來執行的。併發佇列會基於系統負載來合適地選擇併發執行這些任務。併發佇列一般指的就是全域性佇列(Global queue),程式中存在四個全域性佇列:高、中(預設)、低、後臺四個優先順序佇列,可以呼叫dispatch_get_global_queue函式傳入優先順序來訪問佇列。當然我們也可以用dispatch_queue_create,並指定佇列型別DISPATCH_QUEUE_CONCURRENT,來自己建立一個併發佇列。

  • 主佇列,與主執行緒功能相同。實際上,提交至main queue的任務會在主執行緒中執行。main queue可以呼叫dispatch_get_main_queue()來獲得。因為main queue是與主執行緒相關的,所以這是一個序列佇列。和其它序列佇列一樣,這個佇列中的任務一次只能執行一個。它能保證所有的任務都在主執行緒執行,而主執行緒是唯一可用於更新 UI 的執行緒。

額外說一句,上面也說過,佇列間的執行是並行的,但是也存在一些限制。比如,並行執行的佇列數量受到核心數的限制,無法真正做到大量佇列並行執行;比如,對於並行佇列中的全域性佇列而言,其存在優先順序關係,執行的時候也會遵循其優先順序,而不是並行。

2. 任務

linux核心中的任務的定義是描述程式的一種結構體,而GCD中的任務只是一個程式碼塊,它可以指在一個block或者函式指標。根據這個程式碼塊新增進入佇列的方式,將任務分為同步任務和非同步任務:

  • 同步任務,使用dispatch_sync將任務加入佇列。將同步任務加入序列佇列,會順序執行,一般不這樣做並且在一個任務未結束時調起其它同步任務會死鎖。將同步任務加入並行佇列,會順序執行,但是也沒什麼意義。

  • 非同步任務,使用dispatch_async將任務加入佇列。將非同步任務加入序列佇列,會順序執行,並且不會出現死鎖問題。將非同步任務加入並行佇列,會並行執行多個任務,這也是我們最常用的一種方式。

3. 簡單應用

// 佇列的建立,queue1:中(預設)優先順序的全域性並行佇列、queue2:主佇列、queue3:未指定type則為序列佇列、queue4:指定序列佇列、queue5:指定並行佇列
dispatch_queue_t queue1 = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_queue_t queue2 = dispatch_get_main_queue();
dispatch_queue_t queue3 = dispatch_queue_create("queue3", NULL);
dispatch_queue_t queue4 = dispatch_queue_create("queue4", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue5 = dispatch_queue_create("queue5", DISPATCH_QUEUE_CONCURRENT);

// 佇列中新增非同步任務
dispatch_async(queue1, ^{
// 任務
...
});

// 佇列中新增同步任務
dispatch_sync(queue1, ^{
// 任務
...
});

三、GCD常見用法和應用場景

非常喜歡一句話:Talk is cheap, show me the code.接下來對GCD的使用,我會通過程式碼展示。

1. dispatch_async

一般用法

dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(globalQueue, ^{
    // 一個非同步的任務,例如網路請求,耗時的檔案操作等等
    ...
    dispatch_async(dispatch_get_main_queue(), ^{
        // UI重新整理
        ...
    });
});

應用場景
這種用法非常常見,比如開啟一個非同步的網路請求,待資料返回後返回主佇列重新整理UI;又比如請求圖片,待圖片返回重新整理UI等等。

2. dispatch_after

一般用法

dispatch_queue_t queue= dispatch_get_main_queue();
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), queue, ^{
    // 在queue裡面延遲執行的一段程式碼
    ...
});

應用場景
這為我們提供了一個簡單的延遲執行的方式,比如在view載入結束延遲執行一個動畫等等。

3. dispatch_once

一般用法

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
    // 只執行一次的任務
    ...
});

應用場景
可以使用其建立一個單例,也可以做一些其他只執行一次的程式碼,比如做一個只能點一次的button(好像沒啥用)。

4. dispatch_group

一般用法

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();

dispatch_group_async(group, queue, ^{
    // 非同步任務1
});

dispatch_group_async(group, queue, ^{
    // 非同步任務2
});

// 等待group中多個非同步任務執行完畢,做一些事情,介紹兩種方式

// 方式1(不好,會卡住當前執行緒)
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
...

// 方式2(比較好)
dispatch_group_notify(group, mainQueue, ^{
    // 任務完成後,在主佇列中做一些操作
    ...
});

應用場景
上述的一種方式,可以適用於自己維護的一些非同步任務的同步問題;但是對於已經封裝好的一些庫,比如AFNetworking等,我們不獲取其非同步任務的佇列,這裡可以通過一種計數的方式控制任務間同步,下面為解決單介面多介面的一種方式。

// 兩個請求和引數為我專案裡面的不用在意。

// 計數+1
dispatch_group_enter(group);
[JDApiService getActivityDetailWithActivityId:self.activityId Location:stockAddressId SuccessBlock:^(NSDictionary *userInfo) {
    // 資料返回後一些處理
    ...

    // 計數-1
    dispatch_group_leave(group);
} FailureBlock:^(NSError *error) {
    // 資料返回後一些處理
    ...

    // 計數-1
    dispatch_group_leave(group);
}];

// 計數+1
dispatch_group_enter(group);
[JDApiService getAllCommentWithActivityId:self.activityId PageSize:3 PageNum:self.commentCurrentPage SuccessBlock:^(NSDictionary *userInfo) {
    // 資料返回後一些處理
    ...

    // 計數-1
    dispatch_group_leave(group);
} FailureBlock:^(NSError *error) {
    // 資料返回後一些處理
    ...

    // 計數-1
    dispatch_group_leave(group);
}];

// 其實用計數的說法可能不太對,但是就這麼理解吧。會在計數為0的時候執行dispatch_group_notify的任務。
dispatch_group_notify(group, mainQueue, ^{
    // 一般為回主佇列重新整理UI
    ...
});

5. dispatch_barrier_async

一般用法

// dispatch_barrier_async的作用可以用一個詞概括--承上啟下,它保證此前的任務都先於自己執行,此後的任務也遲於自己執行。本例中,任務4會在任務1、2、3都執行完之後執行,而任務5、6會等待任務4執行完後執行。

dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(queue, ^{
    // 任務1
    ...
});
dispatch_async(queue, ^{
    // 任務2
    ...
});
dispatch_async(queue, ^{
    // 任務3
    ...
});
dispatch_barrier_async(queue, ^{
    // 任務4
    ...
});
dispatch_async(queue, ^{
    // 任務5
    ...
});
dispatch_async(queue, ^{
    // 任務6
    ...
});

應用場景
和dispatch_group類似,dispatch_barrier也是非同步任務間的一種同步方式,可以在比如檔案的讀寫操作時使用,保證讀操作的準確性。另外,有一點需要注意,dispatch_barrier_sync和dispatch_barrier_async只在自己建立的併發佇列上有效,在全域性(Global)併發佇列、序列佇列上,效果跟dispatch_(a)sync效果一樣。

6. dispatch_apply

一般用法

// for迴圈做一些事情,輸出0123456789
for (int i = 0; i < 10; i ++) {
    NSLog(@"%d", i);
}

// dispatch_apply替換(當且僅當處理順序對處理結果無影響環境),輸出順序不定,比如1098673452
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
/*! dispatch_apply函式說明
*
*  @brief  dispatch_apply函式是dispatch_sync函式和Dispatch Group的關聯API
*         該函式按指定的次數將指定的Block追加到指定的Dispatch Queue中,並等到全部的處理執行結束
*
*  @param 10    指定重複次數  指定10次
*  @param queue 追加物件的Dispatch Queue
*  @param index 帶有引數的Block, index的作用是為了按執行的順序區分各個Block
*
*/
dispatch_apply(10, queue, ^(size_t index) {
    NSLog(@"%zu", index);
});

應用場景
那麼,dispatch_apply有什麼用呢,因為dispatch_apply並行的執行機制,效率一般快於for迴圈的類序列機制(在for一次迴圈中的處理任務很多時差距比較大)。比如這可以用來拉取網路資料後提前算出各個控制元件的大小,防止繪製時計算,提高表單滑動流暢性,如果用for迴圈,耗時較多,並且每個表單的資料沒有依賴關係,所以用dispatch_apply比較好。

7. dispatch_suspend和dispatch_resume

一般用法

dispatch_queue_t queue = dispatch_get_main_queue();
dispatch_suspend(queue); //暫停佇列queue
dispatch_resume(queue);  //恢復佇列queue

應用場景
這種用法我還沒有嘗試過,不過其中有個需要注意的點。這兩個函式不會影響到佇列中已經執行的任務,佇列暫停後,已經新增到佇列中但還沒有執行的任務不會執行,直到佇列被恢復。

8. dispatch_semaphore_signal

一般用法

// dispatch_semaphore_signal有兩類用法:a、解決同步問題;b、解決有限資源訪問(資源為1,即互斥)問題。
// dispatch_semaphore_wait,若semaphore計數為0則等待,大於0則使其減1。
// dispatch_semaphore_signal使semaphore計數加1。

// a、同步問題:輸出肯定為1、2、3。
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_semaphore_t semaphore1 = dispatch_semaphore_create(1);
dispatch_semaphore_t semaphore2 = dispatch_semaphore_create(0);
dispatch_semaphore_t semaphore3 = dispatch_semaphore_create(0);

dispatch_async(queue, ^{
    // 任務1
    dispatch_semaphore_wait(semaphore1, DISPATCH_TIME_FOREVER);
    NSLog(@"1
");
    dispatch_semaphore_signal(semaphore2);
    dispatch_semaphore_signal(semaphore1);
});

dispatch_async(queue, ^{
    // 任務2
    dispatch_semaphore_wait(semaphore2, DISPATCH_TIME_FOREVER);
    NSLog(@"2
");
    dispatch_semaphore_signal(semaphore3);
    dispatch_semaphore_signal(semaphore2);
});

dispatch_async(queue, ^{
    // 任務3
    dispatch_semaphore_wait(semaphore3, DISPATCH_TIME_FOREVER);
    NSLog(@"3
");
    dispatch_semaphore_signal(semaphore3);
});

// b、有限資源訪問問題:for迴圈看似能建立100個非同步任務,實質由於訊號限制,最多建立10個非同步任務。
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(10);
for (int i = 0; i < 100; i ++) {
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    dispatch_async(queue, ^{
    // 任務
    ...
    dispatch_semaphore_signal(semaphore);
    });
}

應用場景
其實關於dispatch_semaphore_t,並沒有看到太多應用和資料解釋,我只能參照自己對linux訊號量的理解寫了兩個用法,經測試確實相似。這裡,就不對一些死鎖問題進行討論了。

9. dispatch_set_context、dispatch_get_context和dispatch_set_finalizer_f

一般用法

// dispatch_set_context、dispatch_get_context是為了向佇列中傳遞上下文context服務的。
// dispatch_set_finalizer_f相當於dispatch_object_t的解構函式。
// 因為context的資料不是foundation物件,所以arc不會自動回收,一般在dispatch_set_finalizer_f中手動回收,所以一般講上述三個方法繫結使用。

- (void)test
{
    // 幾種建立context的方式
    // a、用C語言的malloc建立context資料。
    // b、用C++的new建立類物件。
    // c、用Objective-C的物件,但是要用__bridge等關鍵字轉為Core Foundation物件。

    dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT);
    if (queue) {
        // "123"即為傳入的context
        dispatch_set_context(queue, "123");
        dispatch_set_finalizer_f(queue, &xigou);
    }
    dispatch_async(queue, ^{
        char *string = dispatch_get_context(queue);
        NSLog(@"%s", string);
    });
}

// 該函式會在dispatch_object_t銷燬時呼叫。
void xigou(void *context)
{
    // 釋放context的記憶體(對應上述abc)

    // a、CFRelease(context);
    // b、free(context);
    // c、delete context;
}

應用場景
dispatch_set_context可以為佇列新增上下文資料,但是因為GCD是C語言介面形式的,所以其context引數型別是“void *”。需使用上述abc三種方式建立context,並且一般結合dispatch_set_finalizer_f使用,回收context記憶體。

四、記憶體和安全

稍微提一下吧,因為部分人糾結於dispatch的記憶體問題。
記憶體

  • MRC:用dispatch_retain和dispatch_release管理dispatch_object_t記憶體。

  • ARC:ARC在編譯時刻自動管理dispatch_object_t記憶體,使用retain和release會報錯。

安全
dispatch_queue是執行緒安全的,你可以隨意往裡面新增任務。

五、拾遺

這裡主要提一下GCD的一些坑和執行緒的一些問題。

1. 死鎖

dispatch_sync

// 假設這段程式碼執行於主佇列
dispatch_queue_t serialQueue = dispatch_queue_create("serialQueue", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t mainQueue = dispatch_get_main_queue();

// 在主佇列新增同步任務
dispatch_sync(mainQueue, ^{
    // 任務
    ...
});

// 在序列佇列新增同步任務 
dispatch_sync(serialQueue, ^{
    // 任務
    ...
    dispatch_sync(serialQueue, ^{
        // 任務
        ...
    });
};

dispatch_apply

// 因為dispatch_apply會卡住當前執行緒,內部的dispatch_apply會等待外部,外部的等待內部,所以死鎖。
dispatch_queue_t queue = dispatch_queue_create("queue", DISPATCH_QUEUE_CONCURRENT);
dispatch_apply(10, queue, ^(size_t) {
    // 任務
    ...
    dispatch_apply(10, queue, ^(size_t) {
        // 任務
        ...
    });
});

dispatch_barrier
dispatch_barrier_sync在序列佇列和全域性並行佇列裡面和dispatch_sync同樣的效果,所以需考慮同dispatch_sync一樣的死鎖問題。

2. dispatch_time_t

// dispatch_time_t一般在dispatch_after和dispatch_group_wait等方法裡作為引數使用。這裡最需要注意的是一些巨集的含義。
// NSEC_PER_SEC,每秒有多少納秒。
// USEC_PER_SEC,每秒有多少毫秒。
// NSEC_PER_USEC,每毫秒有多少納秒。
// DISPATCH_TIME_NOW 從現在開始
// DISPATCH_TIME_FOREVE 永久

// time為1s的寫法
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC);

3. GCD和執行緒的關係

如果你是新手,GCD和執行緒木有關係。
如果你是高手,我們做朋友吧。

六、參考文獻

1、https://developer.apple.com/library/mac/documentation/General/Conceptual/ConcurrencyProgrammingGuide/OperationQueues/OperationQueues.html#//apple_ref/doc/uid/TP40008091-CH102-SW2
2、https://developer.apple.com/library/ios/documentation/Performance/Reference/GCD_libdispatch_Ref/
3、http://tutuge.me/2015/04/03/something-about-gcd/
4、http://www.jianshu.com/p/85b75c7a6286
5、http://www.jianshu.com/p/d56064507fb8