GCD介紹(一): 基本概念和Dispatch Queue

什麼是GCD?

Grand Central Dispatch或者GCD,是一套低層API,提供了一種新的方法來進行併發程序編寫。從基本功能上講,GCD有點像NSOperationQueue,他們都允許程序將任務切分爲多個單一任務然後提交至工作隊列來併發地或者串行地執行。GCD比之NSOpertionQueue更底層更高效,並且它不是Cocoa框架的一部分。

除了代碼的平行執行能力,GCD還提供高度集成的事件控制系統。可以設置句柄來響應文件描述符、mach ports(Mach port 用於 OS X上的進程間通訊)、進程、計時器、信號、用戶生成事件。這些句柄通過GCD來併發執行。

GCD的API很大程度上基於block,當然,GCD也可以脫離block來使用,比如使用傳統c機制提供函數指針和上下文指針。實踐證明,當配合block使用時,GCD非常簡單易用且能發揮其最大能力。

你可以在Mac上敲命令“man dispatch”來獲取GCD的文檔。

爲何使用?

GCD提供很多超越傳統多線程編程的優勢:

  1. 易用: GCD比之thread跟簡單易用。由於GCD基於work unit而非像thread那樣基於運算,所以GCD可以控制諸如等待任務結束監視文件描述符週期執行代碼以及工作掛起等任務。基於block的血統導致它能極爲簡單得在不同代碼作用域之間傳遞上下文。
  2. 效率: GCD被實現得如此輕量和優雅,使得它在很多地方比之專門創建消耗資源的線程更實用且快速。這關係到易用性:導致GCD易用的原因有一部分在於你可以不用擔心太多的效率問題而僅僅使用它就行了。
  3. 性能: GCD自動根據系統負載來增減線程數量,這就減少了上下文切換以及增加了計算效率。

Dispatch Objects

儘管GCD是純c語言的,但它被組建成面向對象的風格。GCD對象被稱爲dispatch object。Dispatch object像Cocoa對象一樣是引用計數的。使用dispatch_release和dispatch_retain函數來操作dispatch object的引用計數來進行內存管理。但注意不像Cocoa對象,dispatch object並不參與垃圾回收系統,所以即使開啓了GC,你也必須手動管理GCD對象的內存。

Dispatch queues 和 dispatch sources(後面會介紹到)可以被掛起和恢復,可以有一個相關聯的任意上下文指針,可以有一個相關聯的任務完成觸發函數。可以查閱“man dispatch_object”來獲取這些功能的更多信息。

Dispatch Queues

GCD的基本概念就是dispatch queue。dispatch queue是一個對象,它可以接受任務,並將任務以先到先執行的順序來執行。dispatch queue可以是併發的或串行的。併發任務會像NSOperationQueue那樣基於系統負載來合適地併發進行,串行隊列同一時間只執行單一任務。

GCD中有三種隊列類型:

  1. The main queue: 與主線程功能相同。實際上,提交至main queue的任務會在主線程中執行。main queue可以調用dispatch_get_main_queue()來獲得。因爲main queue是與主線程相關的,所以這是一個串行隊列
  2. Global queues: 全局隊列是併發隊列,並由整個進程共享。進程中存在三個全局隊列:高、中(默認)、低、後臺四個優先級隊列。可以調用dispatch_get_global_queue函數傳入優先級來訪問隊列。優先級:

    #define DISPATCH_QUEUE_PRIORITY_HIGH 2

    #define DISPATCH_QUEUE_PRIORITY_DEFAULT 0

    #define DISPATCH_QUEUE_PRIORITY_LOW (-2)

    #define DISPATCH_QUEUE_PRIORITY_BACKGROUND INT16_MIN

  3. 用戶隊列: 用戶隊列 (GCD並不這樣稱呼這種隊列, 但是沒有一個特定的名字來形容這種隊列,所以我們稱其爲用戶隊列) 是用函數 dispatch_queue_create 創建的隊列. 這些隊列是串行的。正因爲如此,它們可以用來完成同步機制, 有點像傳統線程中的mutex。

創建隊列

要使用用戶隊列,我們首先得創建一個。調用函數dispatch_queue_create就行了。函數的第一個參數是一個標籤,這純是爲了debug。Apple建議我們使用倒置域名來命名隊列,比如“com.dreamingwish.subsystem.task”。這些名字會在崩潰日誌中被顯示出來,也可以被調試器調用,這在調試中會很有用。第二個參數目前還不支持,傳入NULL就行了。

提交 Job

向一個隊列提交Job很簡單:調用dispatch_async函數,傳入一個隊列和一個block。隊列會在輪到這個block執行時執行這個block的代碼。下面的例子是一個在後臺執行一個巨長的任務:

 dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        [self goDoSomethingLongAndInvolved];
        NSLog(@"Done doing something long and involved");
});

dispatch_async 函數會立即返回, block會在後臺異步執行。 

當然,通常,任務完成時簡單地NSLog個消息不是個事兒。在典型的Cocoa程序中,你很有可能希望在任務完成時更新界面,這就意味着需要在主線程中執行一些代碼。你可以簡單地完成這個任務——使用嵌套的dispatch,在外層中執行後臺任務,在內層中將任務dispatch到main queue:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        [self goDoSomethingLongAndInvolved];
        dispatch_async(dispatch_get_main_queue(), ^{
            [textField setStringValue:@"Done doing something long and involved"];
        });
});

還有一個函數叫dispatch_sync,它乾的事兒和dispatch_async相同,但是它會等待block中的代碼執行完成並返回。結合 __block類型修飾符,可以用來從執行中的block獲取一個值。例如,你可能有一段代碼在後臺執行,而它需要從界面控制層獲取一個值。那麼你可以使用dispatch_sync簡單辦到:

__block NSString *stringValue;
dispatch_sync(dispatch_get_main_queue(), ^{
        // __block variables aren't automatically retained
        // so we'd better make sure we have a reference we can keep
        stringValue = [[textField stringValue] copy];
});
[stringValue autorelease];
// use stringValue in the background now

我們還可以使用更好的方法來完成這件事——使用更“異步”的風格。不同於取界面層的值時要阻塞後臺線程,你可以使用嵌套的block來中止後臺線程,然後從主線程中獲取值,然後再將後期處理提交至後臺線程:

    dispatch_queue_t bgQueue = myQueue;
    dispatch_async(dispatch_get_main_queue(), ^{
        NSString *stringValue = [[[textField stringValue] copy] autorelease];
        dispatch_async(bgQueue, ^{
            // use stringValue in the background now
        });
    });

取決於你的需求,myQueue可以是用戶隊列也可以使全局隊列。

 

不再使用鎖(Lock)

用戶隊列可以用於替代鎖來完成同步機制。在傳統多線程編程中,你可能有一個對象要被多個線程使用,你需要一個鎖來保護這個對象:

    NSLock *lock;

訪問代碼會像這樣:

    - (id)something
    {
        id localSomething;
        [lock lock];
        localSomething = [[something retain] autorelease];
        [lock unlock];
        return localSomething;
    }

    - (void)setSomething:(id)newSomething
    {
        [lock lock];
        if(newSomething != something)
        {
            [something release];
            something = [newSomething retain];
            [self updateSomethingCaches];
        }
        [lock unlock];
    }

使用GCD,可以使用queue來替代:

    dispatch_queue_t queue;

要用於同步機制,queue必須是一個用戶隊列,而非全局隊列,所以使用usingdispatch_queue_create初始化一個。然後可以用dispatch_async 或者 dispatch_sync將共享數據的訪問代碼封裝起來:

    - (id)something
    {
        __block id localSomething;
        dispatch_sync(queue, ^{
            localSomething = [something retain];
        });
        return [localSomething autorelease];
    }

    - (void)setSomething:(id)newSomething
    {
        dispatch_async(queue, ^{
            if(newSomething != something)
            {
                [something release];
                something = [newSomething retain];
                [self updateSomethingCaches];
            }
        });
    }

 值得注意的是dispatch queue是非常輕量級的,所以你可以大用特用,就像你以前使用lock一樣。

現在你可能要問:“這樣很好,但是有意思嗎?我就是換了點代碼辦到了同一件事兒。”

實際上,使用GCD途徑有幾個好處:

  1. 平行計算: 注意在第二個版本的代碼中, -setSomething:是怎麼使用dispatch_async的。調用 -setSomething:會立即返回,然後這一大堆工作會在後臺執行。如果updateSomethingCaches是一個很費時費力的任務,且調用者將要進行一項處理器高負荷任務,那麼這樣做會很棒。
  2. 安全: 使用GCD,我們就不可能意外寫出具有不成對Lock的代碼。在常規Lock代碼中,我們很可能在解鎖之前讓代碼返回了。使用GCD,隊列通常持續運行,你必將歸還控制權。
  3. 控制: 使用GCD我們可以掛起和恢復dispatch queue,而這是基於鎖的方法所不能實現的。我們還可以將一個用戶隊列指向另一個dspatch queue,使得這個用戶隊列繼承那個dispatch queue的屬性。使用這種方法,隊列的優先級可以被調整——通過將該隊列指向一個不同的全局隊列,若有必要的話,這個隊列甚至可以被用來在主線程上執行代碼。
  4. 集成: GCD的事件系統與dispatch queue相集成。對象需要使用的任何事件或者計時器都可以從該對象的隊列中指向,使得這些句柄可以自動在該隊列上執行,從而使得句柄可以與對象自動同步。

總結

現在你已經知道了GCD的基本概念、怎樣創建dispatch queue、怎樣提交Job至dispatch queue以及怎樣將隊列用作線程同步。接下來我會向你展示如何使用GCD來編寫平行執行代碼來充分利用多核系統的性能^ ^。我還會討論GCD更深層的東西,包括事件系統和queue targeting。

GCD介紹(二): 多核心的性能

概念

爲了在單一進程中充分發揮多核的優勢,我們有必要使用多線程技術(我們沒必要去提多進程,這玩意兒和GCD沒關係)。在低層,GCD全局dispatch queue僅僅是工作線程池的抽象。這些隊列中的Block一旦可用,就會被dispatch到工作線程中。提交至用戶隊列的Block最終也會通過全局隊列進入相同的工作線程池(除非你的用戶隊列的目標是主線程,但是爲了提高運行速度,我們絕不會這麼幹)。

有兩種途徑來通過GCD“榨取”多核心繫統的性能:將單一任務或者一組相關任務併發至全局隊列中運算;將多個不相關的任務或者關聯不緊密的任務併發至用戶隊列中運算;

全局隊列

設想下面的循環:

1
2
for(id obj in array)
    [self doSomethingIntensiveWith:obj];

假定 -doSomethingIntensiveWith: 是線程安全的且可以同時執行多個.一個array通常包含多個元素,這樣的話,我們可以很簡單地使用GCD來平行運算:

1
2
3
4
5
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
for(id obj in array)
    dispatch_async(queue, ^{
        [self doSomethingIntensiveWith:obj];
    });

如此簡單,我們已經在多核心上運行這段代碼了。 

當然這段代碼並不完美。有時候我們有一段代碼要像這樣操作一個數組,但是在操作完成後,我們還需要對操作結果進行其他操作:

1
2
3
for(id obj in array)
    [self doSomethingIntensiveWith:obj];
[self doSomethingWith:array];

這時候使用GCD的 dispatch_async 就悲劇了.我們還不能簡單地使用dispatch_sync來解決這個問題, 因爲這將導致每個迭代器阻塞,就完全破壞了平行計算。

解決這個問題的一種方法是使用dispatch group。一個dispatch group可以用來將多個block組成一組以監測這些Block全部完成或者等待全部完成時發出的消息。使用函數dispatch_group_create來創建,然後使用函數dispatch_group_async來將block提交至一個dispatch queue,同時將它們添加至一個組。所以我們現在可以重新編碼:

1
2
3
4
5
6
7
8
9
10
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
for(id obj in array)
    dispatch_group_async(group, queue, ^{
        [self doSomethingIntensiveWith:obj];
    });
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
dispatch_release(group);
 
[self doSomethingWith:array];

如果這些工作可以異步執行,那麼我們可以更風騷一點,將函數-doSomethingWith:放在後臺執行。我們使用dispatch_group_async函數建立一個block在組完成後執行:

1
2
3
4
5
6
7
8
9
10
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
for(id obj in array)
    dispatch_group_async(group, queue, ^{
        [self doSomethingIntensiveWith:obj];
    });
dispatch_group_notify(group, queue, ^{
    [self doSomethingWith:array];
});
dispatch_release(group);

不僅所有數組元素都會被平行操作,後續的操作也會異步執行,並且這些異步運算都會將程序的其他部分的負載考慮在內。注意如果-doSomethingWith:需要在主線程中執行,比如操作GUI,那麼我們只要將main queue而非全局隊列傳給dispatch_group_notify函數就行了。

 

對於同步執行,GCD提供了一個簡化方法叫做dispatch_apply。這個函數調用單一block多次,並平行運算,然後等待所有運算結束,就像我們想要的那樣:

1
2
3
4
5
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_apply([array count], queue, ^(size_t index){
        [self doSomethingIntensiveWith:[array objectAtIndex:index]];
    });
    [self doSomethingWith:array];

這很棒,但是異步咋辦?dispatch_apply函數可是沒有異步版本的。但是我們使用的可是一個爲異步而生的API啊!所以我們只要用dispatch_async函數將所有代碼推到後臺就行了:

1
2
3
4
5
6
7
dispatch_queue_t queue = dispatch_get_global_qeueue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(queue, ^{
    dispatch_apply([array count], queue, ^(size_t index){
        [self doSomethingIntensiveWith:[array objectAtIndex:index]];
    });
    [self doSomethingWith:array];
});

簡單的要死!

 

這種方法的關鍵在於確定我們的代碼是在一次對不同的數據片段進行相似的操作。如果你確定你的任務是線程安全的(不在本篇討論範圍內)那麼你可以使用GCD來重寫你的循環了,更平行更風騷。

要看到性能提升,你還得進行一大堆工作。比之線程,GCD是輕量和低負載的,但是將block提交至queue還是很消耗資源的——block需要被拷貝和入隊,同時適當的工作線程需要被通知。不要將一張圖片的每個像素作爲一個block提交至隊列,GCD的優點就半途夭折了。如果你不確定,那麼請進行試驗。將程序平行計算化是一種優化措施,在修改代碼之前你必須再三思索,確定修改是有益的(還有確保你修改了正確的地方)。

Subsystem併發運算

前面的章節我們討論了在程序的單個subsystem中發揮多核心的優勢。下來我們要跨越多個子系統。

例如,設想一個程序要打開一個包含meta信息的文檔。文檔數據本身需要解析並轉換至模型對象來顯示,meta信息也需要解析和轉換。但是,文檔數據和meta信息不需要交互。我們可以爲文檔和meta各創建一個dispatch queue,然後併發執行。文檔和meta的解析代碼都會各自串行執行,從而不用考慮線程安全(只要沒有文檔和meta之間共享的數據),但是它們還是併發執行的。

一旦文檔打開了,程序需要響應用戶操作。例如,可能需要進行拼寫檢查、代碼高亮、字數統計、自動保存或者其他什麼。如果每個任務都被實現爲在不同的dispatch queue中執行,那麼這些任務會併發執行,並各自將其他任務的運算考慮在內(respect to each other),從而省去了多線程編程的麻煩。

使用dispatch source(下次我會講到),我們可以讓GCD將事件直接傳遞給用戶隊列。例如,程序中監視socket連接的代碼可以被置於它自己的dispatch queue中,這樣它會異步執行,並且執行時會將程序其他部分的運算考慮在內。另外,如果使用用戶隊列的話,這個模塊會串行執行,簡化程序。

結論

我們討論瞭如何使用GCD來提升程序性能以及發揮多核系統的優勢。儘管我們需要比較謹慎地編寫併發程序,GCD還是使得我們能更簡單地發揮系統的可用計算資源。

下一篇中,我們將討論dispatch source,也就是GCD的監視內部、外部事件的機制。

GCD介紹(三): Dispatch Sources

 何爲Dispatch Sources

簡單來說,dispatch source是一個監視某些類型事件的對象。當這些事件發生時,它自動將一個block放入一個dispatch queue的執行例程中。

說的貌似有點不清不楚。我們到底討論哪些事件類型?

下面是GCD 10.6.0版本支持的事件:

  1. Mach port send right state changes.
  2. Mach port receive right state changes.
  3. External process state change.
  4. File descriptor ready for read.
  5. File descriptor ready for write.
  6. Filesystem node event.
  7. POSIX signal.
  8. Custom timer.
  9. Custom event.

這是一堆很有用的東西,它支持所有kqueue所支持的事件(kqueue是什麼?見http://en.wikipedia.org/wiki/Kqueue)以及mach(mach是什麼?見http://en.wikipedia.org/wiki/Mach_(kernel))端口、內建計時器支持(這樣我們就不用使用超時參數來創建自己的計時器)和用戶事件。

 

用戶事件

這些事件裏面多數都可以從名字中看出含義,但是你可能想知道啥叫用戶事件。簡單地說,這種事件是由你調用dispatch_source_merge_data函數來向自己發出的信號。

這個名字對於一個發出事件信號的函數來說,太怪異了。這個名字的來由是GCD會在事件句柄被執行之前自動將多個事件進行聯結。你可以將數據“拼接”至dispatch source中任意次,並且如果dispatch queue在這期間繁忙的話,GCD只會調用該句柄一次(不要覺得這樣會有問題,看完下面的內容你就明白了)。

用戶事件有兩種: DISPATCH_SOURCE_TYPE_DATA_ADD 和 DISPATCH_SOURCE_TYPE_DATA_OR.用戶事件源有個 unsigned long data屬性,我們將一個 unsigned long傳入 dispatch_source_merge_data。當使用 _ADD版本時,事件在聯結時會把這些數字相加。當使用 _OR版本時,事件在聯結時會把這些數字邏輯與運算。當事件句柄執行時,我們可以使用dispatch_source_get_data函數訪問當前值,然後這個值會被重置爲0。

讓我假設一種情況。假設一些異步執行的代碼會更新一個進度條。因爲主線程只不過是GCD的另一個dispatch queue而已,所以我們可以將GUI更新工作push到主線程中。然而,這些事件可能會有一大堆,我們不想對GUI進行頻繁而累贅的更新,理想的情況是當主線程繁忙時將所有的改變聯結起來。

用dispatch source就完美了,使用DISPATCH_SOURCE_TYPE_DATA_ADD,我們可以將工作拼接起來,然後主線程可以知道從上一次處理完事件到現在一共發生了多少改變,然後將這一整段改變一次更新至進度條。

啥也不說了,上代碼:

    dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_main_queue());
    dispatch_source_set_event_handler(source, ^{
        [progressIndicator incrementBy:dispatch_source_get_data(source)];
    });
    dispatch_resume(source);

    dispatch_apply([array count], globalQueue, ^(size_t index) {
        // do some work on data at index
        dispatch_source_merge_data(source, 1);
    });

 (對於這段代碼,我很想說點什麼,我第一次用dispatch source時,我糾結了很久很久,真讓人蛋疼:Dispatch source啓動時默認狀態是掛起的,我們創建完畢之後得主動恢復,否則事件不會被傳遞,也不會被執行

假設你已經將進度條的min/max值設置好了,那麼這段代碼就完美了。數據會被併發處理。當每一段數據完成後,會通知dispatch source並將dispatch source data加1,這樣我們就認爲一個單元的工作完成了。事件句柄根據已完成的工作單元來更新進度條。若主線程比較空閒並且這些工作單元進行的比較慢,那麼事件句柄會在每個工作單元完成的時候被調用,實時更新。如果主線程忙於其他工作,或者工作單元完成速度很快,那麼完成事件會被聯結起來,導致進度條只在主線程變得可用時才被更新,並且一次將積累的改變更新至GUI。

現在你可能會想,聽起來倒是不錯,但是要是我不想讓事件被聯結呢?有時候你可能想讓每一次信號都會引起響應,什麼後臺的智能玩意兒統統不要。啊。。其實很簡單的,別把自己繞進去了。如果你想讓每一個信號都得到響應,那使用dispatch_async函數不就行了。實際上,使用的dispatch source而不使用dispatch_async的唯一原因就是利用聯結的優勢。

內建事件

上面就是怎樣使用用戶事件,那麼內建事件呢?看看下面這個例子,用GCD讀取標準輸入:

    dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    dispatch_source_t stdinSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ,
                                                           STDIN_FILENO,
                                                           0,
                                                           globalQueue);
    dispatch_source_set_event_handler(stdinSource, ^{
        char buf[1024];
        int len = read(STDIN_FILENO, buf, sizeof(buf));
        if(len > 0)
            NSLog(@"Got data from stdin: %.*s", len, buf);
    });
    dispatch_resume(stdinSource);

 簡單的要死!因爲我們使用的是全局隊列,句柄自動在後臺執行,與程序的其他部分並行,這意味着對這種情況的提速:事件進入程序時,程序正在處理其他事務。

這是標準的UNIX方式來處理事務的好處,不用去寫loop。如果使用經典的 read調用,我們還得萬分留神,因爲返回的數據可能比請求的少,還得忍受無厘頭的“errors”,比如 EINTR (系統調用中斷)。使用GCD,我們啥都不用管,就從這些蛋疼的情況裏解脫了。如果我們在文件描述符中留下了未讀取的數據,GCD會再次調用我們的句柄。

對於標準輸入,這沒什麼問題,但是對於其他文件描述符,我們必須考慮在完成讀寫之後怎樣清除描述符。對於dispatch source還處於活躍狀態時,我們決不能關閉描述符。如果另一個文件描述符被創建了(可能是另一個線程創建的)並且新的描述符剛好被分配了相同的數字,那麼你的dispatch source可能會在不應該的時候突然進入讀寫狀態。de這個bug可不是什麼好玩的事兒。

適當的清除方式是使用 dispatch_source_set_cancel_handler,並傳入一個block來關閉文件描述符。然後我們使用 dispatch_source_cancel來取消dispatch source,使得句柄被調用,然後文件描述符被關閉。

使用其他dispatch source類型也差不多。總的來說,你提供一個source(mach port、文件描述符、進程ID等等)的區分符來作爲diapatch source的句柄。mask參數通常不會被使用,但是對於 DISPATCH_SOURCE_TYPE_PROC 來說mask指的是我們想要接受哪一種進程事件。然後我們提供一個句柄,然後恢復這個source(前面我加粗字體所說的,得先恢復),搞定。dispatch source也提供一個特定於source的data,我們使用 dispatch_source_get_data函數來訪問它。例如,文件描述符會給出大致可用的字節數。進程source會給出上次調用之後發生的事件的mask。具體每種source給出的data的含義,看man page吧。

計時器

計時器事件稍有不同。它們不使用handle/mask參數,計時器事件使用另外一個函數 dispatch_source_set_timer 來配置計時器。這個函數使用三個參數來控制計時器觸發:

 start參數控制計時器第一次觸發的時刻。參數類型是 dispatch_time_t,這是一個opaque類型,我們不能直接操作它。我們得需要dispatch_time 和  dispatch_walltime 函數來創建它們。另外,常量  DISPATCH_TIME_NOW 和 DISPATCH_TIME_FOREVER 通常很有用。

 interval參數沒什麼好解釋的。

 leeway參數比較有意思。這個參數告訴系統我們需要計時器觸發的精準程度。所有的計時器都不會保證100%精準,這個參數用來告訴系統你希望系統保證精準的努力程度。如果你希望一個計時器沒五秒觸發一次,並且越準越好,那麼你傳遞0爲參數。另外,如果是一個週期性任務,比如檢查email,那麼你會希望每十分鐘檢查一次,但是不用那麼精準。所以你可以傳入60,告訴系統60秒的誤差是可接受的。

這樣有什麼意義呢?簡單來說,就是降低資源消耗。如果系統可以讓cpu休息足夠長的時間,並在每次醒來的時候執行一個任務集合,而不是不斷的醒來睡去以執行任務,那麼系統會更高效。如果傳入一個比較大的leeway給你的計時器,意味着你允許系統拖延你的計時器來將計時器任務與其他任務聯合起來一起執行。

總結

現在你知道怎樣使用GCD的dispatch source功能來監視文件描述符、計時器、聯結的用戶事件以及其他類似的行爲。由於dispatch source完全與dispatch queue相集成,所以你可以使用任意的dispatch queue。你可以將一個dispatch source的句柄在主線程中執行、在全局隊列中併發執行、或者在用戶隊列中串行執行(執行時會將程序的其他模塊的運算考慮在內)。

下一篇我會討論如何對dispatch queue進行掛起、恢復、重定目標操作;如何使用dispatch semaphore;如何使用GCD的一次性初始化功能。

GCD介紹(四): 完結

Dispatch Queue掛起

dispatch queue可以被掛起和恢復。使用 dispatch_suspend函數來掛起,使用  dispatch_resume 函數來恢復。這兩個函數的行爲是如你所願的。另外,這兩個函數也可以用於dispatch source。

一個要注意的地方是,dispatch queue的掛起是block粒度的。換句話說,掛起一個queue並不會將當前正在執行的block掛起。它會允許當前執行的block執行完畢,然後後續的block不再會被執行,直至queue被恢復。

還有一個注意點:從man頁上得來的:如果你掛起了一個queue或者source,那麼銷燬它之前,必須先對其進行恢復。

Dispatch Queue目標指定

所有的用戶隊列都有一個目標隊列概念。從本質上講,一個用戶隊列實際上是不執行任何任務的,但是它會將任務傳遞給它的目標隊列來執行。通常,目標隊列是默認優先級的全局隊列。

用戶隊列的目標隊列可以用函數 dispatch_set_target_queue來修改。我們可以將任意dispatch queue傳遞給這個函數,甚至可以是另一個用戶隊列,只要別構成循環就行。這個函數可以用來設定用戶隊列的優先級。比如我們可以將用戶隊列的目標隊列設定爲低優先級的全局隊列,那麼我們的用戶隊列中的任務都會以低優先級執行。高優先級也是一樣道理。

有一個用途,是將用戶隊列的目標定爲main queue。這會導致所有提交到該用戶隊列的block在主線程中執行。這樣做來替代直接在主線程中執行代碼的好處在於,我們的用戶隊列可以單獨地被掛起和恢復,還可以被重定目標至一個全局隊列,然後所有的block會變成在全局隊列上執行(只要你確保你的代碼離開主線程不會有問題)。

還有一個用途,是將一個用戶隊列的目標隊列指定爲另一個用戶隊列。這樣做可以強制多個隊列相互協調地串行執行,這樣足以構建一組隊列,通過掛起和暫停那個目標隊列,我們可以掛起和暫停整個組。想象這樣一個程序:它掃描一組目錄並且加載目錄中的內容。爲了避免磁盤競爭,我們要確定在同一個物理磁盤上同時只有一個文件加載任務在執行。而希望可以同時從不同的物理磁盤上讀取多個文件。要實現這個,我們要做的就是創建一個dispatch queue結構,該結構爲磁盤結構的鏡像。

首先,我們會掃描系統並找到各個磁盤,爲每個磁盤創建一個用戶隊列。然後掃描文件系統,併爲每個文件系統創建一個用戶隊列,將這些用戶隊列的目標隊列指向合適的磁盤用戶隊列。最後,每個目錄掃描器有自己的隊列,其目標隊列指向目錄所在的文件系統的隊列。目錄掃描器枚舉自己的目錄併爲每個文件向自己的隊列提交一個block。由於整個系統的建立方式,就使得每個物理磁盤被串行訪問,而多個物理磁盤被並行訪問。除了隊列初始化過程,我們根本不需要手動干預什麼東西。

信號量

dispatch的信號量是像其他的信號量一樣的,如果你熟悉其他多線程系統中的信號量,那麼這一節的東西再好理解不過了。

信號量是一個整形值並且具有一個初始計數值,並且支持兩個操作:信號通知和等待。當一個信號量被信號通知,其計數會被增加。當一個線程在一個信號量上等待時,線程會被阻塞(如果有必要的話),直至計數器大於零,然後線程會減少這個計數。

我們使用函數  dispatch_semaphore_create 來創建dispatch信號量,使用函數  dispatch_semaphore_signal 來信號通知,使用函數dispatch_semaphore_wait 來等待。這些函數的man頁有兩個很好的例子,展示了怎樣使用信號量來同步任務和有限資源訪問控制。

單次初始化

GCD還提供單次初始化支持,這個與pthread中的函數  pthread_once 很相似。GCD提供的方式的優點在於它使用block而非函數指針,這就允許更自然的代碼方式:

這個特性的主要用途是惰性單例初始化或者其他的線程安全數據共享。典型的單例初始化技術看起來像這樣(線程安全的):

    + (id)sharedWhatever
    {
        static Whatever *whatever = nil;
        @synchronized([Whatever class])
        {
            if(!whatever)
                whatever = [[Whatever alloc] init];
        }
        return whatever;
    }

這挺好的,但是代價比較昂貴;每次調用  +sharedWhatever 函數都會付出取鎖的代價,即使這個鎖只需要進行一次。確實有更風騷的方式來實現這個,使用類似雙向鎖或者是原子操作的東西,但是這樣挺難弄而且容易出錯。

使用GCD,我們可以這樣重寫上面的方法,使用函數 dispatch_once

    + (id)sharedWhatever
    {
        static dispatch_once_t pred;
        static Whatever *whatever = nil;
        dispatch_once(&pred, ^{
            whatever = [[Whatever alloc] init];
        });
        return whatever;
    }

這個稍微比 @synchronized方法簡單些,並且GCD確保以更快的方式完成這些檢測,它保證block中的代碼在任何線程通過  dispatch_once 調用之前被執行,但它不會強制每次調用這個函數都讓代碼進行同步控制。實際上,如果你去看這個函數所在的頭文件,你會發現目前它的實現其實是一個宏,進行了內聯的初始化測試,這意味着通常情況下,你不用付出函數調用的負載代價,並且會有更少的同步控制負載。

結論

這一章,我們介紹了dispatch queue的掛起、恢復和目標重定,以及這些功能的一些用途。另外,我們還介紹瞭如何使用dispatch 信號量和單次初始化功能。到此,我已經完成了GCD如何運作以及如何使用的介紹。

 

GCD實戰一:使用串行隊列實現簡單的預加載

其主要思路是使用gcd創建串行隊列,然後在此隊列中先後執行兩個任務:1.預加載一個viewController 2.將這個viewController推入

代碼如下:

@implementation DWAppDelegate
{
    dispatch_queue_t _serialQueue;
    UINavigationController *_navController;
}

- (dispatch_queue_t)serialQueue
{
    if (!_serialQueue) {
        _serialQueue = dispatch_queue_create("serialQueue", DISPATCH_QUEUE_SERIAL);//創建串行隊列
    }
    return _serialQueue;
}

- (void)prepareViewController
{
    dispatch_async([self serialQueue], ^{//把block中的任務放入串行隊列中執行,這是第一個任務
        self.viewController = [[[DWViewController alloc] init] autorelease];
        sleep(2);//假裝這個viewController創建起來很花時間。。其實view都還沒加載,根本不花時間。
        NSLog(@"prepared");
    });
}

- (void)goToViewController
{
    dispatch_async([self serialQueue], ^{//第二個任務,推入viewController
        NSLog(@"go");
        dispatch_async(dispatch_get_main_queue(), ^{//涉及UI更新的操作,放入主線程中
            [_navController pushViewController:self.viewController animated:YES];
        });
    });
}

- (void)dealloc
{
    dispatch_release(_serialQueue);
    [_navController release];
    [_window release];
    [_viewController release];
    [super dealloc];
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [self prepareViewController];
    
    self.window = [[[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]] autorelease];
    // Override point for customization after application launch.
    
    DWViewController *viewController = [[[DWViewController alloc] initWithNibName:@"DWViewController" bundle:nil] autorelease];
    viewController.view.backgroundColor = [UIColor blueColor];
    _navController = [[UINavigationController alloc] initWithRootViewController:viewController];
    self.window.rootViewController = _navController;
    
    [self goToViewController];
    
    [self.window makeKeyAndVisible];
    return YES;
}

 

GCD實戰2:資源競爭

概述

我將分四步來帶大家研究研究程序的併發計算。第一步是基本的串行程序,然後使用GCD把它並行計算化。如果你想順着步驟來嘗試這些程序的話,可以下載源碼。注意,別運行imagegcd2.m,這是個反面教材。。

原始程序

我們的程序只是簡單地遍歷~/Pictures然後生成縮略圖。這個程序是個命令行程序,沒有圖形界面(儘管是使用Cocoa開發庫的),主函數如下:

    int main(int argc, char **argv)
    {
        NSAutoreleasePool *outerPool = [NSAutoreleasePool new];
        
        NSApplicationLoad();
        
        NSString *destination = @"/tmp/imagegcd";
        [[NSFileManager defaultManager] removeItemAtPath: destination error: NULL];
        [[NSFileManager defaultManager] createDirectoryAtPath: destination
                                        withIntermediateDirectories: YES
                                        attributes: nil
                                        error: NULL];
        
        
        Start();
        
        NSString *dir = [@"~/Pictures" stringByExpandingTildeInPath];
        NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtPath: dir];
        int count = 0;
        for(NSString *path in enumerator)
        {
            NSAutoreleasePool *innerPool = [NSAutoreleasePool new];
            
            if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
            {
                path = [dir stringByAppendingPathComponent: path];
                
                NSData *data = [NSData dataWithContentsOfFile: path];
                if(data)
                {
                    NSData *thumbnailData = ThumbnailDataForData(data);
                    if(thumbnailData)
                    {
                        NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg", count++];
                        NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                        [thumbnailData writeToFile: thumbnailPath atomically: NO];
                    }
                }
            }
            
            [innerPool release];
        }
        
        End();
        
        [outerPool release];
    }
 

如果你要看到所有的副主函數的話,到文章頂部下載源代碼吧。當前這個程序是imagegcd1.m。程序中重要的部分都在這裏了。. Start 函數和 End 函數只是簡單的計時函數(內部實現是使用的gettimeofday函數)。ThumbnailDataForData函數使用Cocoa庫來加載圖片數據生成Image對象,然後將圖片縮小到320×320大小,最後將其編碼爲JPEG格式。

 

簡單而天真的併發

乍一看,我們感覺將這個程序併發計算化,很容易。循環中的每個迭代器都可以放入GCD global queue中。我們可以使用dispatch queue來等待它們完成。爲了保證每次迭代都會得到唯一的文件名數字,我們使用OSAtomicIncrement32來原子操作級別的增加count數:

    dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
    dispatch_group_t group = dispatch_group_create();
    __block uint32_t count = -1;
    for(NSString *path in enumerator)
    {
        dispatch_group_async(group, globalQueue, BlockWithAutoreleasePool(^{
            if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
            {
                NSString *fullPath = [dir stringByAppendingPathComponent: path];
                
                NSData *data = [NSData dataWithContentsOfFile: fullPath];
                if(data)
                {
                    NSData *thumbnailData = ThumbnailDataForData(data);
                    if(thumbnailData)
                    {
                        NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg",
                                                   OSAtomicIncrement32(&count;)];
                        NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                        [thumbnailData writeToFile: thumbnailPath atomically: NO];
                    }
                }
            }
        });
    }
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

這個就是imagegcd2.m,但是,注意,別運行這個程序,有很大的問題。 

如果你無視我的警告還是運行這個imagegcd2.m了,你現在很有可能是在重啓了電腦後,又打開了我的頁面。。如果你乖乖地沒有運行這個程序的話,運行這個程序發生的情況就是(如果你有很多很多圖片在~/Pictures中):電腦沒反應,好久好久都不動,假死了。。

 

問題在哪

問題出在哪?就在於GCD的智能上。GCD將任務放到全局線程池中運行,這個線程池的大小根據系統負載來隨時改變。例如,我的電腦有四核,所以如果我使用GCD加載任務,GCD會爲我每個cpu核創建一個線程,也就是四個線程。如果電腦上其他任務需要進行的話,GCD會減少線程數來使其他任務得以佔用cpu資源來完成。

但是,GCD也可以增加活動線程數。它會在其他某個線程阻塞時增加活動線程數。假設現在有四個線程正在運行,突然某個線程要做一個操作,比如,讀文件,這個線程就會等待磁盤響應,此時cpu核心會處於未充分利用的狀態。這是GCD就會發現這個狀態,然後創建另一個線程來填補這個資源浪費空缺。

現在,想想上面的程序發生了啥?主線程非常迅速地將任務不斷放入global queue中。GCD以一個少量工作線程的狀態開始,然後開始執行任務。這些任務執行了一些很輕量的工作後,就開始等待磁盤資源,慢得不像話的磁盤資源。

我們別忘記磁盤資源的特性,除非你使用的是SSD或者牛逼的RAID,否則磁盤資源會在競爭的時候變得異常的慢。。

剛開始的四個任務很輕鬆地就同時訪問到了磁盤資源,然後開始等待磁盤資源返回。這時GCD發現CPU開始空閒了,它繼續增加工作線程。然後,這些線程執行更多的磁盤讀取任務,然後GCD再創建更多的工資線程。。。

可能在某個時間文件讀取任務有完成的了。現在,線程池中可不止有四個線程,相反,有成百上千個。。。GCD又會嘗試將工作線程減少(太多使用CPU資源的線程),但是減少線程是由條件的,GCD不可以將一個正在執行任務的線程殺掉,並且也不能將這樣的任務暫停。它必須等待這個任務完成。所有這些情況都導致GCD無法減少工作線程數。

然後所有這上百個線程開始一個個完成了他們的磁盤讀取工作。它們開始競爭CPU資源,當然CPU在處理競爭上比磁盤先進多了。問題在於,這些線程讀完文件後開始編碼這些圖片,如果你有很多很多圖片,那麼你的內存將開始爆倉。。然後內存耗盡咋辦?虛擬內存啊,虛擬內存是啥,磁盤資源啊。Oh shit!~

然後進入了一個惡性循環,磁盤資源競爭導致更多的線程被創建,這些線程導致更多的內存使用,然後內存爆倉導致虛擬內存交換,直至GCD創建了系統規定的線程數上限(可能是512個),而這些線程又沒法被殺掉或暫停。。。

這就是使用GCD時,要注意的。GCD能智能地根據CPU情況來調整工作線程數,但是它卻無法監視其他類型的資源狀況。如果你的任務牽涉大量IO或者其他會導致線程block的東西,你需要把握好這個問題。

 

修正
問題的根源來自於磁盤IO,然後導致惡性循環。解決了磁盤資源碰撞,就解決了這個問題。

GCD的custom queue使得這個問題易於解決。Custom queue是串行的。如果我們創建一個custom queue然後將所有的文件讀寫任務放入這個隊列,磁盤資源的同時訪問數會大大降低,資源訪問碰撞就避免了。

蝦米是我們修正後的代碼,使用IO queue(也就是我們創建的custom queue專門用來讀寫磁盤):

    dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
    dispatch_queue_t ioQueue = dispatch_queue_create("com.mikeash.imagegcd.io", NULL);
    dispatch_group_t group = dispatch_group_create();
    __block uint32_t count = -1;
    for(NSString *path in enumerator)
    {
        if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
        {
            NSString *fullPath = [dir stringByAppendingPathComponent: path];
            
            dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                NSData *data = [NSData dataWithContentsOfFile: fullPath];
                if(data)
                    dispatch_group_async(group, globalQueue, BlockWithAutoreleasePool(^{
                        NSData *thumbnailData = ThumbnailDataForData(data);
                        if(thumbnailData)
                        {
                            NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg",
                                                       OSAtomicIncrement32(&count;)];
                            NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                            dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                                [thumbnailData writeToFile: thumbnailPath atomically: NO];
                            }));
                        }
                    }));
            }));
        }
    }
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

 這個就是我們的 imagegcd3.m.

GCD使得我們很容易就將任務的不同部分放入相同的隊列中去(簡單地嵌套一下dispatch)。這次我們的程序將會表現地很好。。。我是說多數情況。。。。

問題在於任務中的不同部分不是同步的,導致了整個程序的不穩定。我們的新程序的整個流程如下:

    Main Thread          IO Queue            Concurrent Queue
    
    find paths  ------>  read  ----------->  process
                                             ...
                         write <-----------  process

圖中的箭頭是非阻塞的,並且會簡單地將內存中的對象進行緩衝。

 

 現在假設一個機器的磁盤足夠快,快到比CPU處理任務(也就是圖片處理)要快。其實不難想象:雖然CPU的動作很快,但是它的工作更繁重,解碼、壓縮、編碼。從磁盤讀取的數據開始填滿IO queue,數據會佔用內存,很可能越佔越多(如果你的~/Pictures中有很多很多圖片的話)。

然後你就會內存爆倉,然後開始虛擬內存交換。。。又來了。。

這就會像第一次一樣導致惡性循環。一旦任何東西導致工作線程阻塞,GCD就會創建更多的線程,這個線程執行的任務又會佔用內存(從磁盤讀取的數據),然後又開始交換內存。。

結果:這個程序要麼就是運行地很順暢,要麼就是很低效。

注意如果磁盤速度比較慢的話,這個問題依舊會出現,因爲縮略圖會被緩衝在內存裏,不過這個問題導致的低效比較不容易出現,因爲縮略圖佔的內存少得多。

 

真正的修復

由於上一次我們的嘗試出現的問題在於沒有同步不同部分的操作,所以讓我寫出同步的代碼。最簡單的方法就是使用信號量來限制同時執行的任務數量。

那麼,我們需要限制爲多少呢?

顯然我們需要根據CPU的核數來限制這個量,我們又想馬兒好又想馬兒不吃草,我們就設置爲cpu核數的兩倍吧。不過這裏只是簡單地這樣處理,GCD的作用之一就是讓我們不用關心操作系統的內部信息(比如cpu數),現在又來讀取cpu核數,確實不太妙。也許我們在實際應用中,可以根據其他需求來定義這個限制量。

現在我們的主循環代碼就是這樣了:

    dispatch_queue_t ioQueue = dispatch_queue_create("com.mikeash.imagegcd.io", NULL);
    
    int cpuCount = [[NSProcessInfo processInfo] processorCount];
    dispatch_semaphore_t jobSemaphore = dispatch_semaphore_create(cpuCount * 2);
    
    dispatch_group_t group = dispatch_group_create();
    __block uint32_t count = -1;
    for(NSString *path in enumerator)
    {
        WithAutoreleasePool(^{
            if([[[path pathExtension] lowercaseString] isEqual: @"jpg"])
            {
                NSString *fullPath = [dir stringByAppendingPathComponent: path];
                
                dispatch_semaphore_wait(jobSemaphore, DISPATCH_TIME_FOREVER);
            
                dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                    NSData *data = [NSData dataWithContentsOfFile: fullPath];
                    dispatch_group_async(group, globalQueue, BlockWithAutoreleasePool(^{
                        NSData *thumbnailData = ThumbnailDataForData(data);
                        if(thumbnailData)
                        {
                            NSString *thumbnailName = [NSString stringWithFormat: @"%d.jpg",
                                                       OSAtomicIncrement32(&count;)];
                            NSString *thumbnailPath = [destination stringByAppendingPathComponent: thumbnailName];
                            dispatch_group_async(group, ioQueue, BlockWithAutoreleasePool(^{
                                [thumbnailData writeToFile: thumbnailPath atomically: NO];
                                dispatch_semaphore_signal(jobSemaphore);
                            }));
                        }
                        else
                            dispatch_semaphore_signal(jobSemaphore);
                    }));
                }));
            }
        });
    }
    dispatch_group_wait(group, DISPATCH_TIME_FOREVER);

最終我們寫出了一個能平滑運行且又快速處理的程序。

 

基準測試

我測試了一些運行時間,對7913張圖片:

 

程序處理時間 (秒)
imagegcd1.m 984
imagegcd2.m 沒運行,這個還是別運行了
imagegcd3.m 300
imagegcd4.m 279

 

 

注意,因爲我比較懶。所以我在運行這些測試的時候,沒有關閉電腦上的其他程序。。。嚴格的進行對照的話,實在是太蛋疼了。。

所以這個數值我們只是參考一下。

比較有意思的是,3和4的執行狀況差不多,大概是因爲我電腦有15g可用內存吧。。。內存比較小的話,這個imagegcd3應該跑的很吃力,因爲我發現它使用最多的時候,佔用了10g內存。而4的話,沒有佔多少內存。

結論

GCD是個比較范特西的技術,可以辦到很多事兒,但是它不能爲你辦所有的事兒。所以,對於進行IO操作並且可能會使用大量內存的任務,我們必須仔細斟酌。當然,即使這樣,GCD還是爲我們提供了簡單有效的方法來進行併發計算。

發佈了41 篇原創文章 · 獲贊 5 · 訪問量 7萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章