1. 程式人生 > >併發程式設計:API 及挑戰

併發程式設計:API 及挑戰

併發所描述的概念就是同時執行多個任務。這些任務可能是以在單核 CPU 上分時(時間共享)的形式同時執行,也可能是在多核 CPU 上以真正的並行方式來執行。

OS X 和 iOS 提供了幾種不同的 API 來支援併發程式設計。每一個 API 都具有不同的功能和使用限制,這使它們適合不同的任務。同時,這些 API 處在不同的抽象層級上。我們有可能用其進行非常深入底層的操作,但是這也意味著揹負起將任務進行良好處理的巨大責任。

實際上,併發程式設計是一個很有挑戰的主題,它有許多錯綜複雜的問題和陷阱。當開發者在使用類似 Grand Central Dispatch(GCD)或 NSOperationQueue

的 API 時,很容易遺忘這些問題和陷阱。本文首先對 OS X 和 iOS 中不同的併發程式設計 API 進行一些介紹,然後再深入瞭解併發程式設計中獨立於與你所使用的特定 API 的一些內在挑戰。

OS X 和 iOS 中的併發程式設計

蘋果的移動和桌面作業系統中提供了相同的併發程式設計API。 本文會介紹 pthreadNSThreadGCDNSOperationQueue,以及 NSRunLoop。實際上把 run loop 也列在其中是有點奇怪,因為它並不能實現真正的並行,不過因為它與併發程式設計有莫大的關係,因此值得我們進行一些深入瞭解。

由於高層 API 是基於底層 API 構建的,所以我們首先將從底層的 API 開始介紹,然後逐步擴充套件到高層 API。不過在具體程式設計中,選擇 API 的順序剛好相反:因為大多數情況下,選擇高層的 API 不僅可以完成底層 API 能完成的任務,而且能夠讓併發模型變得簡單。

如果你對我們為何堅持推薦使用高抽象層級以及簡單的並行程式碼有所疑問的話,那麼你可以看看這篇文章的第二部分併發程式設計中面臨的挑戰,以及 Peter Steinberger 寫的關於執行緒安全的文章。

執行緒

執行緒(thread)是組成程序的子單元,作業系統的排程器可以對執行緒進行單獨的排程。實際上,所有的併發程式設計 API 都是構建於執行緒之上的 —— 包括 GCD 和操作佇列(operation queues)。

多執行緒可以在單核 CPU 上同時(或者至少看作同時)執行。作業系統將小的時間片分配給每一個執行緒,這樣就能夠讓使用者感覺到有多個任務在同時進行。如果 CPU 是多核的,那麼執行緒就可以真正的以併發方式被執行,從而減少了完成某項操作所需要的總時間。

你可以使用 Instruments 中的 CPU strategy view 來得知你的程式碼或者你在使用的框架程式碼是如何在多核 CPU 中排程執行的。

需要重點關注的是,你無法控制你的程式碼在什麼地方以及什麼時候被排程,以及無法控制執行多長時間後將被暫停,以便輪換執行別的任務。這種執行緒排程是非常強大的一種技術,但是也非常複雜,我們稍後研究。

先把執行緒排程的複雜情況放一邊,開發者可以使用 POSIX 執行緒 API,或者 Objective-C 中提供的對該 API 的封裝 NSThread,來建立自己的執行緒。下面這個小示例利用 pthread 來在一百萬個數字中查詢最小值和最大值。其中併發執行了 4 個執行緒。從該示例複雜的程式碼中,應該可以看出為什麼你不會希望直接使用 pthread 。

#import <pthread.h>

struct threadInfo {
    uint32_t * inputValues;
    size_t count;
};

struct threadResult {
    uint32_t min;
    uint32_t max;
};

void * findMinAndMax(void *arg)
{
    struct threadInfo const * const info = (struct threadInfo *) arg;
    uint32_t min = UINT32_MAX;
    uint32_t max = 0;
    for (size_t i = 0; i < info->count; ++i) {
        uint32_t v = info->inputValues[i];
        min = MIN(min, v);
        max = MAX(max, v);
    }
    free(arg);
    struct threadResult * const result = (struct threadResult *) malloc(sizeof(*result));
    result->min = min;
    result->max = max;
    return result;
}

int main(int argc, const char * argv[])
{
    size_t const count = 1000000;
    uint32_t inputValues[count];

    // 使用隨機數字填充 inputValues
    for (size_t i = 0; i < count; ++i) {
        inputValues[i] = arc4random();
    }

    // 開始4個尋找最小值和最大值的執行緒
    size_t const threadCount = 4;
    pthread_t tid[threadCount];
    for (size_t i = 0; i < threadCount; ++i) {
        struct threadInfo * const info = (struct threadInfo *) malloc(sizeof(*info));
        size_t offset = (count / threadCount) * i;
        info->inputValues = inputValues + offset;
        info->count = MIN(count - offset, count / threadCount);
        int err = pthread_create(tid + i, NULL, &findMinAndMax, info);
        NSCAssert(err == 0, @"pthread_create() failed: %d", err);
    }
    // 等待執行緒退出
    struct threadResult * results[threadCount];
    for (size_t i = 0; i < threadCount; ++i) {
        int err = pthread_join(tid[i], (void **) &(results[i]));
        NSCAssert(err == 0, @"pthread_join() failed: %d", err);
    }
    // 尋找 min 和 max
    uint32_t min = UINT32_MAX;
    uint32_t max = 0;
    for (size_t i = 0; i < threadCount; ++i) {
        min = MIN(min, results[i]->min);
        max = MAX(max, results[i]->max);
        free(results[i]);
        results[i] = NULL;
    }

    NSLog(@"min = %u", min);
    NSLog(@"max = %u", max);
    return 0;
}

NSThread 是 Objective-C 對 pthread 的一個封裝。通過封裝,在 Cocoa 環境中,可以讓程式碼看起來更加親切。例如,開發者可以利用 NSThread 的一個子類來定義一個執行緒,在這個子類的中封裝需要在後臺執行緒執行的程式碼。針對上面的那個例子,我們可以定義一個這樣的 NSThread 子類:

@interface FindMinMaxThread : NSThread
@property (nonatomic) NSUInteger min;
@property (nonatomic) NSUInteger max;
- (instancetype)initWithNumbers:(NSArray *)numbers;
@end

@implementation FindMinMaxThread {
    NSArray *_numbers;
}

- (instancetype)initWithNumbers:(NSArray *)numbers
{
    self = [super init];
    if (self) {
        _numbers = numbers;
    }
    return self;
}

- (void)main
{
    NSUInteger min;
    NSUInteger max;
    // 進行相關資料的處理
    self.min = min;
    self.max = max;
}
@end

要想啟動一個新的執行緒,需要建立一個執行緒物件,然後呼叫它的 start 方法:

NSMutableSet *threads = [NSMutableSet set];
NSUInteger numberCount = self.numbers.count;
NSUInteger threadCount = 4;
for (NSUInteger i = 0; i < threadCount; i++) {
    NSUInteger offset = (count / threadCount) * i;
    NSUInteger count = MIN(numberCount - offset, numberCount / threadCount);
    NSRange range = NSMakeRange(offset, count);
    NSArray *subset = [self.numbers subarrayWithRange:range];
    FindMinMaxThread *thread = [[FindMinMaxThread alloc] initWithNumbers:subset];
    [threads addObject:thread];
    [thread start];
}

現在,我們可以通過檢測到執行緒的 isFinished 屬性來檢測新生成的執行緒是否已經結束,並獲取結果。我們將這個練習留給感興趣的讀者,這主要是因為不論使用 pthread 還是 NSThread 來直接對執行緒操作,都是相對糟糕的程式設計體驗,這種方式並不適合我們以寫出良好程式碼為目標的編碼精神。

直接使用執行緒可能會引發的一個問題是,如果你的程式碼和所基於的框架程式碼都建立自己的執行緒時,那麼活動的執行緒數量有可能以指數級增長。這在大型工程中是一個常見問題。例如,在 8 核 CPU 中,你建立了 8 個執行緒來完全發揮 CPU 效能。然而在這些執行緒中你的程式碼所呼叫的框架程式碼也做了同樣事情(因為它並不知道你已經建立的這些執行緒),這樣會很快產生成成百上千的執行緒。程式碼的每個部分自身都沒有問題,然而最後卻還是導致了問題。使用執行緒並不是沒有代價的,每個執行緒都會消耗一些記憶體和核心資源。

接下來,我們將介紹兩個基於佇列的併發程式設計 API :GCD 和 operation queue 。它們通過集中管理一個被大家協同使用的執行緒池,來解決上面遇到的問題。

Grand Central Dispatch

為了讓開發者更加容易的使用裝置上的多核CPU,蘋果在 OS X 10.6 和 iOS 4 中引入了 Grand Central Dispatch(GCD)。在下一篇關於底層併發 API 的文章中,我們將更深入地介紹 GCD。

通過 GCD,開發者不用再直接跟執行緒打交道了,只需要向佇列中新增程式碼塊即可,GCD 在後端管理著一個執行緒池。GCD 不僅決定著你的程式碼塊將在哪個執行緒被執行,它還根據可用的系統資源對這些執行緒進行管理。這樣可以將開發者從執行緒管理的工作中解放出來,通過集中的管理執行緒,來緩解大量執行緒被建立的問題。

GCD 帶來的另一個重要改變是,作為開發者可以將工作考慮為一個佇列,而不是一堆執行緒,這種並行的抽象模型更容易掌握和使用。

GCD 公開有 5 個不同的佇列:執行在主執行緒中的 main queue,3 個不同優先順序的後臺佇列,以及一個優先順序更低的後臺佇列(用於 I/O)。 另外,開發者可以建立自定義佇列:序列或者並行佇列。自定義佇列非常強大,在自定義佇列中被排程的所有 block 最終都將被放入到系統的全域性佇列中和執行緒池中。

GCD queues

使用不同優先順序的若干個佇列乍聽起來非常直接,不過,我們強烈建議,在絕大多數情況下使用預設的優先順序佇列就可以了。如果執行的任務需要訪問一些共享的資源,那麼在不同優先順序的佇列中排程這些任務很快就會造成不可預期的行為。這樣可能會引起程式的完全掛起,因為低優先順序的任務阻塞了高優先順序任務,使它不能被執行。更多相關內容,在本文的優先順序反轉部分中會有介紹。

雖然 GCD 是一個低層級的 C API ,但是它使用起來非常的直接。不過這也容易使開發者忘記併發程式設計中的許多注意事項和陷阱。讀者可以閱讀本文後面的併發程式設計中面臨的挑戰,這樣可以注意到一些潛在的問題。本期的另外一篇優秀文章:底層併發 API 中,包含了很多深入的解釋和一些有價值的提示。

Operation Queues

操作佇列(operation queue)是由 GCD 提供的一個佇列模型的 Cocoa 抽象。GCD 提供了更加底層的控制,而操作佇列則在 GCD 之上實現了一些方便的功能,這些功能對於 app 的開發者來說通常是最好最安全的選擇。

NSOperationQueue 有兩種不同型別的佇列:主佇列和自定義佇列。主佇列執行在主執行緒之上,而自定義佇列在後臺執行。在兩種型別中,這些佇列所處理的任務都使用 NSOperation 的子類來表述。

你可以通過重寫 main 或者 start 方法 來定義自己的 operations 。前一種方法非常簡單,開發者不需要管理一些狀態屬性(例如 isExecutingisFinished),當 main 方法返回的時候,這個 operation 就結束了。這種方式使用起來非常簡單,但是靈活性相對重寫 start 來說要少一些。

@implementation YourOperation
    - (void)main
    {
        // 進行處理 ...
    }
@end

如果你希望擁有更多的控制權,以及在一個操作中可以執行非同步任務,那麼就重寫 start 方法:

@implementation YourOperation
    - (void)start
    {
        self.isExecuting = YES;
        self.isFinished = NO;
        // 開始處理,在結束時應該呼叫 finished ...
    }

    - (void)finished
    {
        self.isExecuting = NO;
        self.isFinished = YES;
    }
@end

注意:這種情況下,你必須手動管理操作的狀態。 為了讓操作佇列能夠捕獲到操作的改變,需要將狀態的屬性以配合 KVO 的方式進行實現。如果你不使用它們預設的 setter 來進行設定的話,你就需要在合適的時候傳送合適的 KVO 訊息。

為了能使用操作佇列所提供的取消功能,你需要在長時間操作中時不時地檢查 isCancelled 屬性:

- (void)main
{
    while (notDone && !self.isCancelled) {
        // 進行處理
    }
}

當你定義好 operation 類之後,就可以很容易的將一個 operation 新增到佇列中:

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
YourOperation *operation = [[YourOperation alloc] init];
[queue  addOperation:operation];

另外,你也可以將 block 新增到操作佇列中。這有時候會非常的方便,比如你希望在主佇列中排程一個一次性任務:

[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    // 程式碼...
}];

雖然通過這種的方式在佇列中新增操作會非常方便,但是定義你自己的 NSOperation 子類會在除錯時很有幫助。如果你重寫 operation 的description 方法,就可以很容易的標示出在某個佇列中當前被排程的所有操作 。

除了提供基本的排程操作或 block 外,操作佇列還提供了在 GCD 中不太容易處理好的特性的功能。例如,你可以通過 maxConcurrentOperationCount 屬性來控制一個特定佇列中可以有多少個操作參與併發執行。將其設定為 1 的話,你將得到一個序列佇列,這在以隔離為目的的時候會很有用。

另外還有一個方便的功能就是根據佇列中 operation 的優先順序對其進行排序,這不同於 GCD 的佇列優先順序,它隻影響當前佇列中所有被排程的 operation 的執行先後。如果你需要進一步在除了 5 個標準的優先順序以外對 operation 的執行順序進行控制的話,還可以在 operation 之間指定依賴關係,如下:

[intermediateOperation addDependency:operation1];
[intermediateOperation addDependency:operation2];
[finishedOperation addDependency:intermediateOperation];

這些簡單的程式碼可以確保 operation1operation2intermediateOperation 之前執行,當然,也會在 finishOperation 之前被執行。對於需要明確的執行順序時,操作依賴是非常強大的一個機制。它可以讓你建立一些操作組,並確保這些操作組在依賴它們的操作被執行之前執行,或者在併發佇列中以序列的方式執行操作。

從本質上來看,操作佇列的效能比 GCD 要低那麼一點,不過,大多數情況下這點負面影響可以忽略不計,操作佇列是併發程式設計的首選工具。

Run Loops

實際上,Run loop並不像 GCD 或者操作佇列那樣是一種併發機制,因為它並不能並行執行任務。不過在主 dispatch/operation 佇列中, run loop 將直接配合任務的執行,它提供了一種非同步執行程式碼的機制。

Run loop 比起操作佇列或者 GCD 來說容易使用得多,因為通過 run loop ,你不必處理併發中的複雜情況,就能非同步地執行任務。

一個 run loop 總是繫結到某個特定的執行緒中。main run loop 是與主執行緒相關的,在每一個 Cocoa 和 CocoaTouch 程式中,這個 main run loop 都扮演了一個核心角色,它負責處理 UI 事件、計時器,以及其它核心相關事件。無論你什麼時候設定計時器、使用 NSURLConnection 或者呼叫 performSelector:withObject:afterDelay:,其實背後都是 run loop 在處理這些非同步任務。

無論何時你使用 run loop 來執行一個方法的時候,都需要記住一點:run loop 可以執行在不同的模式中,每種模式都定義了一組事件,供 run loop 做出響應。這在對應 main run loop 中暫時性的將某個任務優先執行這種任務上是一種聰明的做法。

關於這點,在 iOS 中非常典型的一個示例就是滾動。在進行滾動時,run loop 並不是執行在預設模式中的,因此, run loop 此時並不會響應比如滾動前設定的計時器。一旦滾動停止了,run loop 會回到預設模式,並執行新增到佇列中的相關事件。如果在滾動時,希望計時器能被觸發,需要將其設為 NSRunLoopCommonModes 的模式,並新增到 run loop 中。

主執行緒一般來說都已經配置好了 main run loop。然而其他執行緒預設情況下都沒有設定 run loop。你也可以自行為其他執行緒設定 run loop ,但是一般來說我們很少需要這麼做。大多數時間使用 main run loop 會容易得多。如果你需要處理一些很重的工作,但是又不想在主執行緒裡做,你仍然可以在你的程式碼在 main run loop 中被呼叫後將工作分配給其他佇列。Chris 在他關於常見的後臺實踐的文章裡闡述了一些關於這種模式的很好的例子。

如果你真需要在別的執行緒中新增一個 run loop ,那麼不要忘記在 run loop 中至少新增一個 input source 。如果 run loop 中沒有設定好的 input source,那麼每次執行這個 run loop ,它都會立即退出。

併發程式設計中面臨的挑戰

使用併發程式設計會帶來許多陷阱。只要一旦你做的事情超過了最基本的情況,對於併發執行的多工之間的相互影響的不同狀態的監視就會變得異常困難。 問題往往發生在一些不確定性(不可預見性)的地方,這使得在除錯相關併發程式碼時更加困難。

關於併發程式設計的不可預見性有一個非常有名的例子:在1995年, NASA (美國宇航局)傳送了開拓者號火星探測器,但是當探測器成功著陸在我們紅色的鄰居星球后不久,任務戛然而止,火星探測器莫名其妙的不停重啟,在計算機領域內,遇到的這種現象被定為為優先順序反轉,也就是說低優先順序的執行緒一直阻塞著高優先順序的執行緒。稍後我們會看到關於這個問題的更多細節。在這裡我們想說明的是,即使擁有豐富的資源和大量優秀工程師的智慧,併發也還是會在不少情況下反咬你一口。

資源共享

併發程式設計中許多問題的根源就是在多執行緒中訪問共享資源。資源可以是一個屬性、一個物件,通用的記憶體、網路裝置或者一個檔案等等。在多執行緒中任何一個共享的資源都可能是一個潛在的衝突點,你必須精心設計以防止這種衝突的發生。

為了演示這類問題,我們舉一個關於資源的簡單示例:比如僅僅用一個整型值來做計數器。在程式執行過程中,我們有兩個並行執行緒 A 和 B,這兩個執行緒都嘗試著同時增加計數器的值。問題來了,你通過 C 語言或 Objective-C 寫的程式碼大多數情況下對於 CPU 來說不會僅僅是一條機器指令。要想增加計數器的值,當前的必須被從記憶體中讀出,然後增加計數器的值,最後還需要將這個增加後的值寫回記憶體中。

我們可以試著想一下,如果兩個執行緒同時做上面涉及到的操作,會發生怎樣的偶然。例如,執行緒 A 和 B 都從記憶體中讀取出了計數器的值,假設為 17 ,然後執行緒A將計數器的值加1,並將結果 18 寫回到記憶體中。同時,執行緒B也將計數器的值加 1 ,並將結果 18 寫回到記憶體中。實際上,此時計數器的值已經被破壞掉了,因為計數器的值 17 被加 1 了兩次,而它的值卻是 18

競態條件

這個問題被叫做競態條件,在多執行緒裡面訪問一個共享的資源,如果沒有一種機制來確保線上程 A 結束訪問一個共享資源之前,執行緒 B 就不會開始訪問該共享資源的話,資源競爭的問題就總是會發生。如果你所寫入記憶體的並不是一個簡單的整數,而是一個更復雜的資料結構,可能會發生這樣的現象:當第一個執行緒正在寫入這個資料結構時,第二個執行緒卻嘗試讀取這個資料結構,那麼獲取到的資料可能是新舊參半或者沒有初始化。為了防止出現這樣的問題,多執行緒需要一種互斥的機制來訪問共享資源。

在實際的開發中,情況甚至要比上面介紹的更加複雜,因為現代 CPU 為了優化目的,往往會改變向記憶體讀寫資料的順序(亂序執行)。

互斥鎖

互斥訪問的意思就是同一時刻,只允許一個執行緒訪問某個特定資源。為了保證這一點,每個希望訪問共享資源的執行緒,首先需要獲得一個共享資源的互斥鎖,一旦某個執行緒對資源完成了操作,就釋放掉這個互斥鎖,這樣別的執行緒就有機會訪問該共享資源了。

互斥鎖

除了確保互斥訪問,還需要解決程式碼無序執行所帶來的問題。如果不能確保 CPU 訪問記憶體的順序跟程式設計時的程式碼指令一樣,那麼僅僅依靠互斥訪問是不夠的。為了解決由 CPU 的優化策略引起的副作用,還需要引入記憶體屏障。通過設定記憶體屏障,來確保沒有無序執行的指令能跨過屏障而執行。

當然,互斥鎖自身的實現是需要沒有競爭條件的。這實際上是非常重要的一個保證,並且需要在現代 CPU 上使用特殊的指令。更多關於原子操作(atomic operation)的資訊,請閱讀 Daniel 寫的文章:底層併發技術

從語言層面來說,在 Objective-C 中將屬性以 atomic 的形式來宣告,就能支援互斥鎖了。事實上在預設情況下,屬性就是 atomic 的。將一個屬性宣告為 atomic 表示每次訪問該屬性都會進行隱式的加鎖和解鎖操作。雖然最把穩的做法就是將所有的屬性都宣告為 atomic,但是加解鎖這也會付出一定的代價。

在資源上的加鎖會引發一定的效能代價。獲取鎖和釋放鎖的操作本身也需要沒有競態條件,這在多核系統中是很重要的。另外,在獲取鎖的時候,執行緒有時候需要等待,因為可能其它的執行緒已經獲取過資源的鎖了。這種情況下,執行緒會進入休眠狀態。當其它執行緒釋放掉相關資源的鎖時,休眠的執行緒會得到通知。所有這些相關操作都是非常昂貴且複雜的。

鎖也有不同的型別。當沒有競爭時,有些鎖在沒有鎖競爭的情況下效能很好,但是在有鎖的競爭情況下,效能就會大打折扣。另外一些鎖則在基本層面上就比較耗費資源,但是在競爭情況下,效能的惡化會沒那麼厲害。(鎖的競爭是這樣產生的:當一個或者多個執行緒嘗試獲取一個已經被別的執行緒獲取過了的鎖)。

在這裡有一個東西需要進行權衡:獲取和釋放鎖所是要帶來開銷的,因此你需要確保你不會頻繁地進入和退出臨界區段(比如獲取和釋放鎖)。同時,如果你獲取鎖之後要執行一大段程式碼,這將帶來鎖競爭的風險:其它執行緒可能必須等待獲取資源鎖而無法工作。這並不是一項容易解決的任務。

我們經常能看到本來計劃並行執行的程式碼,但實際上由於共享資源中配置了相關的鎖,所以同一時間只有一個執行緒是處於啟用狀態的。對於你的程式碼會如何在多核上執行的預測往往十分重要,你可以使用 Instrument 的 CPU strategy view 來檢查是否有效的利用了 CPU 的可用核數,進而得出更好的想法,以此來優化程式碼。

死鎖

互斥鎖解決了競態條件的問題,但很不幸同時這也引入了一些其他問題,其中一個就是死鎖。當多個執行緒在相互等待著對方的結束時,就會發生死鎖,這時程式可能會被卡住。

死鎖

看看下面的程式碼,它交換兩個變數的值:

void swap(A, B)
{
    lock(lockA);
    lock(lockB);
    int a = A;
    int b = B;
    A = b;
    B = a;
    unlock(lockB);
    unlock(lockA);
}

大多數時候,這能夠正常執行。但是當兩個執行緒使用相反的值來同時呼叫上面這個方法時:

swap(X, Y); // 執行緒 1
swap(Y, X); // 執行緒 2

此時程式可能會由於死鎖而被終止。執行緒 1 獲得了 X 的一個鎖,執行緒 2 獲得了 Y 的一個鎖。 接著它們會同時等待另外一把鎖,但是永遠都不會獲得。

再說一次,你線上程之間共享的資源越多,你使用的鎖也就越多,同時程式被死鎖的概率也會變大。這也是為什麼我們需要儘量減少執行緒間資源共享,並確保共享的資源儘量簡單的原因之一。建議閱讀一下底層併發程式設計 API 中的全部使用非同步分發一節。

資源飢餓(Starvation)

當你認為已經足夠了解併發程式設計面臨的問題時,又出現了一個新的問題。鎖定的共享資源會引起讀寫問題。大多數情況下,限制資源一次只能有一個執行緒進行讀取訪問其實是非常浪費的。因此,在資源上沒有寫入鎖的時候,持有一個讀取鎖是被允許的。這種情況下,如果一個持有讀取鎖的執行緒在等待獲取寫入鎖的時候,其他希望讀取資源的執行緒則因為無法獲得這個讀取鎖而導致資源飢餓的發生。

為了解決這個問題,我們需要使用一個比簡單的讀/寫鎖更聰明的方法,例如給定一個 writer preference,或者使用 read-copy-update 演算法。Daniel 在底層併發程式設計 API 中有介紹瞭如何用 GCD 實現一個多讀取單寫入的模式,這樣就不會被寫入資源飢餓的問題困擾了。

優先順序反轉

本節開頭介紹了美國宇航局發射的開拓者號火星探測器在火星上遇到的併發問題。現在我們就來看看為什麼開拓者號幾近失敗,以及為什麼有時候我們的程式也會遇到相同的問題,該死的優先順序反轉

優先順序反轉是指程式在執行時低優先順序的任務阻塞了高優先順序的任務,有效的反轉了任務的優先順序。由於 GCD 提供了擁有不同優先順序的後臺佇列,甚至包括一個 I/O 佇列,所以我們最好了解一下優先順序反轉的可能性。

高優先順序和低優先順序的任務之間共享資源時,就可能發生優先順序反轉。當低優先順序的任務獲得了共享資源的鎖時,該任務應該迅速完成,並釋放掉鎖,這樣高優先順序的任務就可以在沒有明顯延時的情況下繼續執行。然而高優先順序任務會在低優先順序的任務持有鎖的期間被阻塞。如果這時候有一箇中優先順序的任務(該任務不需要那個共享資源),那麼它就有可能會搶佔低優先順序任務而被執行,因為此時高優先順序任務是被阻塞的,所以中優先順序任務是目前所有可執行任務中優先順序最高的。此時,中優先順序任務就會阻塞著低優先順序任務,導致低優先順序任務不能釋放掉鎖,這也就會引起高優先順序任務一直在等待鎖的釋放。

優先順序反轉

在你的實際程式碼中,可能不會像發生在火星的事情那樣戲劇性地不停重啟。遇到優先順序反轉時,一般沒那麼嚴重。

解決這個問題的方法,通常就是不要使用不同的優先順序。通常最後你都會以讓高優先順序的程式碼等待低優先順序的程式碼來解決問題。當你使用 GCD 時,總是使用預設的優先順序佇列(直接使用,或者作為目標佇列)。如果你使用不同的優先順序,很可能實際情況會讓事情變得更糟糕。

從中得到的教訓是,使用不同優先順序的多個佇列聽起來雖然不錯,但畢竟是紙上談兵。它將讓本來就複雜的並行程式設計變得更加複雜和不可預見。如果你在程式設計中,遇到高優先順序的任務突然沒理由地卡住了,可能你會想起本文,以及那個美國宇航局的工程師也遇到過的被稱為優先順序反轉的問題。

總結

我們希望通過本文你能夠了解到併發程式設計帶來的複雜性和相關問題。併發程式設計中,無論是看起來多麼簡單的 API ,它們所能產生的問題會變得非常的難以觀測,而且要想除錯這類問題往往也都是非常困難的。

但另一方面,併發實際上是一個非常棒的工具。它充分利用了現代多核 CPU 的強大計算能力。在開發中,關鍵的一點就是儘量讓併發模型保持簡單,這樣可以限制所需要的鎖的數量。

我們建議採納的安全模式是這樣的:從主執行緒中提取出要使用到的資料,並利用一個操作佇列在後臺處理相關的資料,最後回到主佇列中來發送你在後臺佇列中得到的結果。使用這種方式,你不需要自己做任何鎖操作,這也就大大減少了犯錯誤的機率。