并發(fā)所描述的概念就是同時運行多個任務(wù)宽涌。這些任務(wù)可能是以在單核 CPU 上分時(時間共享)的形式同時運行颠蕴,也可能是在多核 CPU 上以真正的并行方式來運行野舶。
OS X 和 iOS 提供了幾種不同的 API 來支持并發(fā)編程。每一個 API 都具有不同的功能和使用限制凰荚,這使它們適合不同的任務(wù)勋陪。同時,這些 API 處在不同的抽象層級上。我們有可能用其進行非常深入底層的操作着饥,但是這也意味著背負(fù)起將任務(wù)進行良好處理的巨大責(zé)任。
實際上惰赋,并發(fā)編程是一個很有挑戰(zhàn)的主題宰掉,它有許多錯綜復(fù)雜的問題和陷阱。當(dāng)開發(fā)者在使用類似 Grand Central Dispatch(GCD)或 NSOperationQueue 的 API 時赁濒,很容易遺忘這些問題和陷阱轨奄。本文首先對 OS X 和 iOS 中不同的并發(fā)編程 API 進行一些介紹,然后再深入了解并發(fā)編程中獨立于與你所使用的特定 API 的一些內(nèi)在挑戰(zhàn)流部。
OS X 和 iOS 中的并發(fā)編程
蘋果的移動和桌面操作系統(tǒng)中提供了相同的并發(fā)編程API戚绕。 本文會介紹 pthread 、 NSThread 枝冀、GCD 舞丛、NSOperationQueue,以及NSRunLoop果漾。實際上把 run loop 也列在其中是有點奇怪球切,因為它并不能實現(xiàn)真正的并行,不過因為它與并發(fā)編程有莫大的關(guān)系绒障,因此值得我們進行一些深入了解吨凑。
由于高層 API 是基于底層 API 構(gòu)建的,所以我們首先將從底層的 API 開始介紹户辱,然后逐步擴展到高層 API鸵钝。不過在具體編程中,選擇 API 的順序剛好相反:因為大多數(shù)情況下庐镐,選擇高層的 API 不僅可以完成底層 API 能完成的任務(wù)恩商,而且能夠讓并發(fā)模型變得簡單。
如果你對我們?yōu)楹螆猿滞扑]使用高抽象層級以及簡單的并行代碼有所疑問的話必逆,那么你可以看看這篇文章的第二部分并發(fā)編程中面臨的挑戰(zhàn)怠堪,以及 Peter Steinberger 寫的關(guān)于線程安全的文章。
線程
線程(thread)是組成進程的子單元名眉,操作系統(tǒng)的調(diào)度器可以對線程進行單獨的調(diào)度粟矿。實際上,所有的并發(fā)編程 API 都是構(gòu)建于線程之上的 —— 包括 GCD 和操作隊列(operation queues)损拢。
多線程可以在單核 CPU 上同時(或者至少看作同時)運行陌粹。操作系統(tǒng)將小的時間片分配給每一個線程,這樣就能夠讓用戶感覺到有多個任務(wù)在同時進行福压。如果 CPU 是多核的申屹,那么線程就可以真正的以并發(fā)方式被執(zhí)行绘证,從而減少了完成某項操作所需要的總時間。
你可以使用 Instruments 中的 CPU strategy view 來得知你的代碼或者你在使用的框架代碼是如何在多核 CPU 中調(diào)度執(zhí)行的哗讥。
需要重點關(guān)注的是嚷那,你無法控制你的代碼在什么地方以及什么時候被調(diào)度,以及無法控制執(zhí)行多長時間后將被暫停杆煞,以便輪換執(zhí)行別的任務(wù)魏宽。這種線程調(diào)度是非常強大的一種技術(shù),但是也非常復(fù)雜决乎,我們稍后研究队询。
先把線程調(diào)度的復(fù)雜情況放一邊,開發(fā)者可以使用 POSIX 線程 API构诚,或者 Objective-C 中提供的對該 API 的封裝 NSThread蚌斩,來創(chuàng)建自己的線程。下面這個小示例利用 pthread 來在一百萬個數(shù)字中查找最小值和最大值范嘱。其中并發(fā)執(zhí)行了 4 個線程送膳。從該示例復(fù)雜的代碼中,應(yīng)該可以看出為什么你不會希望直接使用 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];
// 使用隨機數(shù)字填充 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 環(huán)境中受裹,可以讓代碼看起來更加親切碌补。例如,開發(fā)者可以利用 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;
// 進行相關(guān)數(shù)據(jù)的處理
self.min = min;
self.max = max;
}
@end
要想啟動一個新的線程照藻,需要創(chuàng)建一個線程對象袜啃,然后調(diào)用它的 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];
}
現(xiàn)在,我們可以通過檢測到線程的 isFinished 屬性來檢測新生成的線程是否已經(jīng)結(jié)束岩梳,并獲取結(jié)果囊骤。我們將這個練習(xí)留給感興趣的讀者晃择,這主要是因為不論使用 pthread 還是 NSThread 來直接對線程操作冀值,都是相對糟糕的編程體驗,這種方式并不適合我們以寫出良好代碼為目標(biāo)的編碼精神宫屠。
直接使用線程可能會引發(fā)的一個問題是列疗,如果你的代碼和所基于的框架代碼都創(chuàng)建自己的線程時,那么活動的線程數(shù)量有可能以指數(shù)級增長浪蹂。這在大型工程中是一個常見問題抵栈。例如告材,在 8 核 CPU 中,你創(chuàng)建了 8 個線程來完全發(fā)揮 CPU 性能古劲。然而在這些線程中你的代碼所調(diào)用的框架代碼也做了同樣事情(因為它并不知道你已經(jīng)創(chuàng)建的這些線程)斥赋,這樣會很快產(chǎn)生成成百上千的線程。代碼的每個部分自身都沒有問題产艾,然而最后卻還是導(dǎo)致了問題疤剑。使用線程并不是沒有代價的,每個線程都會消耗一些內(nèi)存和內(nèi)核資源闷堡。
接下來隘膘,我們將介紹兩個基于隊列的并發(fā)編程 API :GCD 和 operation queue 。它們通過集中管理一個被大家協(xié)同使用的線程池杠览,來解決上面遇到的問題弯菊。
Grand Central Dispatch
為了讓開發(fā)者更加容易的使用設(shè)備上的多核CPU,蘋果在 OS X 10.6 和 iOS 4 中引入了 Grand Central Dispatch(GCD)踱阿。在下一篇關(guān)于底層并發(fā) API 的文章中管钳,我們將更深入地介紹 GCD。
通過 GCD扫茅,開發(fā)者不用再直接跟線程打交道了蹋嵌,只需要向隊列中添加代碼塊即可,GCD 在后端管理著一個線程池葫隙。GCD 不僅決定著你的代碼塊將在哪個線程被執(zhí)行栽烂,它還根據(jù)可用的系統(tǒng)資源對這些線程進行管理。這樣可以將開發(fā)者從線程管理的工作中解放出來恋脚,通過集中的管理線程腺办,來緩解大量線程被創(chuàng)建的問題。
GCD 帶來的另一個重要改變是糟描,作為開發(fā)者可以將工作考慮為一個隊列怀喉,而不是一堆線程,這種并行的抽象模型更容易掌握和使用船响。
GCD 公開有 5 個不同的隊列:運行在主線程中的 main queue躬拢,3 個不同優(yōu)先級的后臺隊列,以及一個優(yōu)先級更低的后臺隊列(用于 I/O)见间。
另外聊闯,開發(fā)者可以創(chuàng)建自定義隊列:串行或者并行隊列。自定義隊列非常強大米诉,在自定義隊列中被調(diào)度的所有 block 最終都將被放入到系統(tǒng)的全局隊列中和線程池中菱蔬。
使用不同優(yōu)先級的若干個隊列乍聽起來非常直接,不過,我們強烈建議拴泌,在絕大多數(shù)情況下使用默認(rèn)的優(yōu)先級隊列就可以了魏身。如果執(zhí)行的任務(wù)需要訪問一些共享的資源,那么在不同優(yōu)先級的隊列中調(diào)度這些任務(wù)很快就會造成不可預(yù)期的行為蚪腐。這樣可能會引起程序的完全掛起箭昵,因為低優(yōu)先級的任務(wù)阻塞了高優(yōu)先級任務(wù),使它不能被執(zhí)行回季。更多相關(guān)內(nèi)容宙枷,在本文的優(yōu)先級反轉(zhuǎn)部分中會有介紹。
雖然 GCD 是一個低層級的 C API 茧跋,但是它使用起來非常的直接慰丛。不過這也容易使開發(fā)者忘記并發(fā)編程中的許多注意事項和陷阱。讀者可以閱讀本文后面的并發(fā)編程中面臨的挑戰(zhàn)瘾杭,這樣可以注意到一些潛在的問題诅病。本期的另外一篇優(yōu)秀文章:底層并發(fā) API 中,包含了很多深入的解釋和一些有價值的提示粥烁。
Operation Queues
操作隊列(operation queue)是由 GCD 提供的一個隊列模型的 Cocoa 抽象贤笆。GCD 提供了更加底層的控制,而操作隊列則在 GCD 之上實現(xiàn)了一些方便的功能讨阻,這些功能對于 app 的開發(fā)者來說通常是最好最安全的選擇芥永。
NSOperationQueue 有兩種不同類型的隊列:主隊列和自定義隊列。主隊列運行在主線程之上钝吮,而自定義隊列在后臺執(zhí)行埋涧。在兩種類型中,這些隊列所處理的任務(wù)都使用 NSOperation 的子類來表述奇瘦。
你可以通過重寫 main 或者 start 方法 來定義自己的 operations 棘催。前一種方法非常簡單,開發(fā)者不需要管理一些狀態(tài)屬性(例如isExecuting 和 isFinished)耳标,當(dāng) main 方法返回的時候醇坝,這個 operation 就結(jié)束了。這種方式使用起來非常簡單次坡,但是靈活性相對重寫 start 來說要少一些呼猪。
@implementation YourOperation
- (void)main
{
// 進行處理 ...
}
@end
如果你希望擁有更多的控制權(quán),以及在一個操作中可以執(zhí)行異步任務(wù)砸琅,那么就重寫 start 方法:
@implementation YourOperation
- (void)start
{
self.isExecuting = YES;
self.isFinished = NO;
// 開始處理宋距,在結(jié)束時應(yīng)該調(diào)用 finished ...
}
- (void)finished
{
self.isExecuting = NO;
self.isFinished = YES;
}
@end
注意:這種情況下,你必須手動管理操作的狀態(tài)明棍。 為了讓操作隊列能夠捕獲到操作的改變乡革,需要將狀態(tài)的屬性以配合 KVO 的方式進行實現(xiàn)寇僧。如果你不使用它們默認(rèn)的 setter 來進行設(shè)置的話摊腋,你就需要在合適的時候發(fā)送合適的 KVO 消息沸版。
為了能使用操作隊列所提供的取消功能,你需要在長時間操作中時不時地檢查 isCancelled 屬性:
- (void)main
{
while (notDone && !self.isCancelled) {
// 進行處理
}
}
當(dāng)你定義好 operation 類之后兴蒸,就可以很容易的將一個 operation 添加到隊列中:
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
YourOperation *operation = [[YourOperation alloc] init];
[queue addOperation:operation];
另外视粮,你也可以將 block 添加到操作隊列中。這有時候會非常的方便橙凳,比如你希望在主隊列中調(diào)度一個一次性任務(wù):
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
// 代碼...
}];
雖然通過這種的方式在隊列中添加操作會非常方便蕾殴,但是定義你自己的 NSOperation 子類會在調(diào)試時很有幫助。如果你重寫 operation 的description 方法岛啸,就可以很容易的標(biāo)示出在某個隊列中當(dāng)前被調(diào)度的所有操作 钓觉。
除了提供基本的調(diào)度操作或 block 外,操作隊列還提供了在 GCD 中不太容易處理好的特性的功能坚踩。例如荡灾,你可以通過maxConcurrentOperationCount 屬性來控制一個特定隊列中可以有多少個操作參與并發(fā)執(zhí)行。將其設(shè)置為 1 的話瞬铸,你將得到一個串行隊列批幌,這在以隔離為目的的時候會很有用。
另外還有一個方便的功能就是根據(jù)隊列中 operation 的優(yōu)先級對其進行排序嗓节,這不同于 GCD 的隊列優(yōu)先級荧缘,它只影響當(dāng)前隊列中所有被調(diào)度的 operation 的執(zhí)行先后。如果你需要進一步在除了 5 個標(biāo)準(zhǔn)的優(yōu)先級以外對 operation 的執(zhí)行順序進行控制的話拦宣,還可以在 operation 之間指定依賴關(guān)系截粗,如下:
[intermediateOperation addDependency:operation1];
[intermediateOperation addDependency:operation2];
[finishedOperation addDependency:intermediateOperation];
這些簡單的代碼可以確保 operation1 和 operation2 在 intermediateOperation 之前執(zhí)行,當(dāng)然鸵隧,也會在 finishOperation 之前被執(zhí)行桐愉。對于需要明確的執(zhí)行順序時,操作依賴是非常強大的一個機制掰派。它可以讓你創(chuàng)建一些操作組从诲,并確保這些操作組在依賴它們的操作被執(zhí)行之前執(zhí)行,或者在并發(fā)隊列中以串行的方式執(zhí)行操作靡羡。
從本質(zhì)上來看系洛,操作隊列的性能比 GCD 要低那么一點,不過略步,大多數(shù)情況下這點負(fù)面影響可以忽略不計描扯,操作隊列是并發(fā)編程的首選工具。
Run Loops
實際上趟薄,Run loop并不像 GCD 或者操作隊列那樣是一種并發(fā)機制绽诚,因為它并不能并行執(zhí)行任務(wù)。不過在主 dispatch/operation 隊列中, run loop 將直接配合任務(wù)的執(zhí)行恩够,它提供了一種異步執(zhí)行代碼的機制卒落。
Run loop 比起操作隊列或者 GCD 來說容易使用得多,因為通過 run loop 蜂桶,你不必處理并發(fā)中的復(fù)雜情況儡毕,就能異步地執(zhí)行任務(wù)。
一個 run loop 總是綁定到某個特定的線程中扑媚。main run loop 是與主線程相關(guān)的腰湾,在每一個 Cocoa 和 CocoaTouch 程序中,這個 main run loop 都扮演了一個核心角色疆股,它負(fù)責(zé)處理 UI 事件费坊、計時器,以及其它內(nèi)核相關(guān)事件旬痹。無論你什么時候設(shè)置計時器葵萎、使用 NSURLConnection 或者調(diào)用 performSelector:withObject:afterDelay:,其實背后都是 run loop 在處理這些異步任務(wù)唱凯。
無論何時你使用 run loop 來執(zhí)行一個方法的時候羡忘,都需要記住一點:run loop 可以運行在不同的模式中,每種模式都定義了一組事件磕昼,供 run loop 做出響應(yīng)卷雕。這在對應(yīng) main run loop 中暫時性的將某個任務(wù)優(yōu)先執(zhí)行這種任務(wù)上是一種聰明的做法。
關(guān)于這點票从,在 iOS 中非常典型的一個示例就是滾動漫雕。在進行滾動時,run loop 并不是運行在默認(rèn)模式中的峰鄙,因此浸间, run loop 此時并不會響應(yīng)比如滾動前設(shè)置的計時器。一旦滾動停止了吟榴,run loop 會回到默認(rèn)模式魁蒜,并執(zhí)行添加到隊列中的相關(guān)事件。如果在滾動時吩翻,希望計時器能被觸發(fā)兜看,需要將其設(shè)為 NSRunLoopCommonModes 的模式,并添加到 run loop 中狭瞎。
主線程一般來說都已經(jīng)配置好了 main run loop细移。然而其他線程默認(rèn)情況下都沒有設(shè)置 run loop。你也可以自行為其他線程設(shè)置 run loop 熊锭,但是一般來說我們很少需要這么做弧轧。大多數(shù)時間使用 main run loop 會容易得多雪侥。如果你需要處理一些很重的工作,但是又不想在主線程里做精绎,你仍然可以在你的代碼在 main run loop 中被調(diào)用后將工作分配給其他隊列速缨。Chris 在他關(guān)于常見的后臺實踐的文章里闡述了一些關(guān)于這種模式的很好的例子。
如果你真需要在別的線程中添加一個 run loop 捺典,那么不要忘記在 run loop 中至少添加一個 input source 。如果 run loop 中沒有設(shè)置好的 input source从祝,那么每次運行這個 run loop 襟己,它都會立即退出。
并發(fā)編程中面臨的挑戰(zhàn)
使用并發(fā)編程會帶來許多陷阱牍陌。只要一旦你做的事情超過了最基本的情況擎浴,對于并發(fā)執(zhí)行的多任務(wù)之間的相互影響的不同狀態(tài)的監(jiān)視就會變得異常困難。 問題往往發(fā)生在一些不確定性(不可預(yù)見性)的地方毒涧,這使得在調(diào)試相關(guān)并發(fā)代碼時更加困難贮预。
關(guān)于并發(fā)編程的不可預(yù)見性有一個非常有名的例子:在1995年, NASA (美國宇航局)發(fā)送了開拓者號火星探測器契讲,但是當(dāng)探測器成功著陸在我們紅色的鄰居星球后不久仿吞,任務(wù)嘎然而止,火星探測器莫名其妙的不停重啟捡偏,在計算機領(lǐng)域內(nèi)唤冈,遇到的這種現(xiàn)象被定為為優(yōu)先級反轉(zhuǎn),也就是說低優(yōu)先級的線程一直阻塞著高優(yōu)先級的線程银伟。稍后我們會看到關(guān)于這個問題的更多細節(jié)你虹。在這里我們想說明的是,即使擁有豐富的資源和大量優(yōu)秀工程師的智慧彤避,并發(fā)也還是會在不少情況下反咬你你一口傅物。
資源共享
并發(fā)編程中許多問題的根源就是在多線程中訪問共享資源。資源可以是一個屬性琉预、一個對象董饰,通用的內(nèi)存、網(wǎng)絡(luò)設(shè)備或者一個文件等等圆米。在多線程中任何一個共享的資源都可能是一個潛在的沖突點尖阔,你必須精心設(shè)計以防止這種沖突的發(fā)生。
為了演示這類問題榨咐,我們舉一個關(guān)于資源的簡單示例:比如僅僅用一個整型值來做計數(shù)器介却。在程序運行過程中,我們有兩個并行線程 A 和 B块茁,這兩個線程都嘗試著同時增加計數(shù)器的值齿坷。問題來了桂肌,你通過 C 語言或 Objective-C 寫的代碼大多數(shù)情況下對于 CPU 來說不會僅僅是一條機器指令。要想增加計數(shù)器的值永淌,當(dāng)前的必須被從內(nèi)存中讀出崎场,然后增加計數(shù)器的值,最后還需要將這個增加后的值寫回內(nèi)存中遂蛀。
我們可以試著想一下谭跨,如果兩個線程同時做上面涉及到的操作,會發(fā)生怎樣的偶然李滴。例如螃宙,線程 A 和 B 都從內(nèi)存中讀取出了計數(shù)器的值,假設(shè)為 17 所坯,然后線程A將計數(shù)器的值加1谆扎,并將結(jié)果 18 寫回到內(nèi)存中。同時芹助,線程B也將計數(shù)器的值加 1 堂湖,并將結(jié)果 18 寫回到內(nèi)存中。實際上状土,此時計數(shù)器的值已經(jīng)被破壞掉了无蜂,因為計數(shù)器的值 17 被加 1 了兩次,而它的值卻是 18蒙谓。
這個問題被叫做競態(tài)條件酱讶,在多線程里面訪問一個共享的資源,如果沒有一種機制來確保在線程 A 結(jié)束訪問一個共享資源之前彼乌,線程 B 就不會開始訪問該共享資源的話泻肯,資源競爭的問題就總是會發(fā)生。如果你所寫入內(nèi)存的并不是一個簡單的整數(shù)慰照,而是一個更復(fù)雜的數(shù)據(jù)結(jié)構(gòu)灶挟,可能會發(fā)生這樣的現(xiàn)象:當(dāng)?shù)谝粋€線程正在寫入這個數(shù)據(jù)結(jié)構(gòu)時,第二個線程卻嘗試讀取這個數(shù)據(jù)結(jié)構(gòu)毒租,那么獲取到的數(shù)據(jù)可能是新舊參半或者沒有初始化稚铣。為了防止出現(xiàn)這樣的問題,多線程需要一種互斥的機制來訪問共享資源墅垮。
在實際的開發(fā)中惕医,情況甚至要比上面介紹的更加復(fù)雜,因為現(xiàn)代 CPU 為了優(yōu)化目的算色,往往會改變向內(nèi)存讀寫數(shù)據(jù)的順序(亂序執(zhí)行)抬伺。
互斥鎖
互斥訪問的意思就是同一時刻,只允許一個線程訪問某個特定資源灾梦。為了保證這一點峡钓,每個希望訪問共享資源的線程妓笙,首先需要獲得一個共享資源的互斥鎖,一旦某個線程對資源完成了操作能岩,就釋放掉這個互斥鎖寞宫,這樣別的線程就有機會訪問該共享資源了。
![
除了確崩椋互斥訪問辈赋,還需要解決代碼無序執(zhí)行所帶來的問題。如果不能確保 CPU 訪問內(nèi)存的順序跟編程時的代碼指令一樣膏燕,那么僅僅依靠互斥訪問是不夠的钥屈。為了解決由 CPU 的優(yōu)化策略引起的副作用,還需要引入內(nèi)存屏障煌寇。通過設(shè)置內(nèi)存屏障焕蹄,來確保沒有無序執(zhí)行的指令能跨過屏障而執(zhí)行逾雄。
當(dāng)然阀溶,互斥鎖自身的實現(xiàn)是需要沒有競爭條件的。這實際上是非常重要的一個保證鸦泳,并且需要在現(xiàn)代 CPU 上使用特殊的指令银锻。更多關(guān)于原子操作(atomic operation)的信息,請閱讀 Daniel 寫的文章:底層并發(fā)技術(shù)做鹰。
從語言層面來說击纬,在 Objective-C 中將屬性以 atomic 的形式來聲明,就能支持互斥鎖了钾麸。事實上在默認(rèn)情況下更振,屬性就是 atomic 的。將一個屬性聲明為 atomic 表示每次訪問該屬性都會進行隱式的加鎖和解鎖操作饭尝。雖然最把穩(wěn)的做法就是將所有的屬性都聲明為 atomic肯腕,但是加解鎖這也會付出一定的代價。
在資源上的加鎖會引發(fā)一定的性能代價钥平。獲取鎖和釋放鎖的操作本身也需要沒有競態(tài)條件实撒,這在多核系統(tǒng)中是很重要的。另外涉瘾,在獲取鎖的時候知态,線程有時候需要等待,因為可能其它的線程已經(jīng)獲取過資源的鎖了立叛。這種情況下负敏,線程會進入休眠狀態(tài)。當(dāng)其它線程釋放掉相關(guān)資源的鎖時秘蛇,休眠的線程會得到通知原在。所有這些相關(guān)操作都是非常昂貴且復(fù)雜的友扰。
鎖也有不同的類型。當(dāng)沒有競爭時庶柿,有些鎖在沒有鎖競爭的情況下性能很好村怪,但是在有鎖的競爭情況下,性能就會大打折扣浮庐。另外一些鎖則在基本層面上就比較耗費資源甚负,但是在競爭情況下,性能的惡化會沒那么厲害审残。(鎖的競爭是這樣產(chǎn)生的:當(dāng)一個或者多個線程嘗試獲取一個已經(jīng)被別的線程獲取過了的鎖)梭域。
在這里有一個東西需要進行權(quán)衡:獲取和釋放鎖所是要帶來開銷的,因此你需要確保你不會頻繁地進入和退出臨界區(qū)段(比如獲取和釋放鎖)搅轿。同時病涨,如果你獲取鎖之后要執(zhí)行一大段代碼,這將帶來鎖競爭的風(fēng)險:其它線程可能必須等待獲取資源鎖而無法工作璧坟。這并不是一項容易解決的任務(wù)既穆。
我們經(jīng)常能看到本來計劃并行運行的代碼,但實際上由于共享資源中配置了相關(guān)的鎖雀鹃,所以同一時間只有一個線程是處于激活狀態(tài)的幻工。對于你的代碼會如何在多核上運行的預(yù)測往往十分重要,你可以使用 Instrument 的 CPU strategy view 來檢查是否有效的利用了 CPU 的可用核數(shù)黎茎,進而得出更好的想法囊颅,以此來優(yōu)化代碼。
死鎖
互斥鎖解決了競態(tài)條件的問題傅瞻,但很不幸同時這也引入了一些其他問題踢代,其中一個就是死鎖。當(dāng)多個線程在相互等待著對方的結(jié)束時嗅骄,就會發(fā)生死鎖胳挎,這時程序可能會被卡住。
看看下面的代碼掸读,它交換兩個變量的值:
void swap(A, B)
{
lock(lockA);
lock(lockB);
int a = A;
int b = B;
A = b;
B = a;
unlock(lockB);
unlock(lockA);
}
大多數(shù)時候串远,這能夠正常運行。但是當(dāng)兩個線程使用相反的值來同時調(diào)用上面這個方法時:
swap(X, Y); // 線程 1
swap(Y, X); // 線程 2
此時程序可能會由于死鎖而被終止儿惫。線程 1 獲得了 X 的一個鎖澡罚,線程 2 獲得了 Y 的一個鎖。 接著它們會同時等待另外一把鎖肾请,但是永遠都不會獲得留搔。
再說一次,你在線程之間共享的資源越多铛铁,你使用的鎖也就越多隔显,同時程序被死鎖的概率也會變大却妨。這也是為什么我們需要盡量減少線程間資源共享,并確保共享的資源盡量簡單的原因之一括眠。建議閱讀一下底層并發(fā)編程 API 中的全部使用異步分發(fā)一節(jié)彪标。
資源饑餓(Starvation)
當(dāng)你認(rèn)為已經(jīng)足夠了解并發(fā)編程面臨的問題時,又出現(xiàn)了一個新的問題掷豺。鎖定的共享資源會引起讀寫問題捞烟。大多數(shù)情況下,限制資源一次只能有一個線程進行讀取訪問其實是非常浪費的当船。因此题画,在資源上沒有寫入鎖的時候,持有一個讀取鎖是被允許的德频。這種情況下苍息,如果一個持有讀取鎖的線程在等待獲取寫入鎖的時候,其他希望讀取資源的線程則因為無法獲得這個讀取鎖而導(dǎo)致資源饑餓的發(fā)生壹置。
為了解決這個問題竞思,我們需要使用一個比簡單的讀/寫鎖更聰明的方法,例如給定一個 writer preference蒸绩,或者使用 read-copy-update 算法衙四。Daniel 在底層并發(fā)編程 API 中有介紹了如何用 GCD 實現(xiàn)一個多讀取單寫入的模式铃肯,這樣就不會被寫入資源饑餓的問題困擾了患亿。
優(yōu)先級反轉(zhuǎn)
本節(jié)開頭介紹了美國宇航局發(fā)射的開拓者號火星探測器在火星上遇到的并發(fā)問題。現(xiàn)在我們就來看看為什么開拓者號幾近失敗押逼,以及為什么有時候我們的程序也會遇到相同的問題步藕,該死的優(yōu)先級反轉(zhuǎn)。
優(yōu)先級反轉(zhuǎn)是指程序在運行時低優(yōu)先級的任務(wù)阻塞了高優(yōu)先級的任務(wù)挑格,有效的反轉(zhuǎn)了任務(wù)的優(yōu)先級咙冗。由于 GCD 提供了擁有不同優(yōu)先級的后臺隊列,甚至包括一個 I/O 隊列漂彤,所以我們最好了解一下優(yōu)先級反轉(zhuǎn)的可能性雾消。
高優(yōu)先級和低優(yōu)先級的任務(wù)之間共享資源時,就可能發(fā)生優(yōu)先級反轉(zhuǎn)挫望。當(dāng)?shù)蛢?yōu)先級的任務(wù)獲得了共享資源的鎖時立润,該任務(wù)應(yīng)該迅速完成,并釋放掉鎖媳板,這樣高優(yōu)先級的任務(wù)就可以在沒有明顯延時的情況下繼續(xù)執(zhí)行桑腮。然而高優(yōu)先級任務(wù)會在低優(yōu)先級的任務(wù)持有鎖的期間被阻塞。如果這時候有一個中優(yōu)先級的任務(wù)(該任務(wù)不需要那個共享資源)蛉幸,那么它就有可能會搶占低優(yōu)先級任務(wù)而被執(zhí)行破讨,因為此時高優(yōu)先級任務(wù)是被阻塞的丛晦,所以中優(yōu)先級任務(wù)是目前所有可運行任務(wù)中優(yōu)先級最高的。此時提陶,中優(yōu)先級任務(wù)就會阻塞著低優(yōu)先級任務(wù)烫沙,導(dǎo)致低優(yōu)先級任務(wù)不能釋放掉鎖,這也就會引起高優(yōu)先級任務(wù)一直在等待鎖的釋放隙笆。
在你的實際代碼中斧吐,可能不會像發(fā)生在火星的事情那樣戲劇性地不停重啟。遇到優(yōu)先級反轉(zhuǎn)時仲器,一般沒那么嚴(yán)重煤率。
解決這個問題的方法,通常就是不要使用不同的優(yōu)先級乏冀。通常最后你都會以讓高優(yōu)先級的代碼等待低優(yōu)先級的代碼來解決問題蝶糯。當(dāng)你使用 GCD 時撰豺,總是使用默認(rèn)的優(yōu)先級隊列(直接使用扑眉,或者作為目標(biāo)隊列)。如果你使用不同的優(yōu)先級琴庵,很可能實際情況會讓事情變得更糟糕肢扯。
從中得到的教訓(xùn)是妒茬,使用不同優(yōu)先級的多個隊列聽起來雖然不錯,但畢竟是紙上談兵蔚晨。它將讓本來就復(fù)雜的并行編程變得更加復(fù)雜和不可預(yù)見乍钻。如果你在編程中,遇到高優(yōu)先級的任務(wù)突然沒理由地卡住了铭腕,可能你會想起本文银择,以及那個美國宇航局的工程師也遇到過的被稱為優(yōu)先級反轉(zhuǎn)的問題。
總結(jié)
我們希望通過本文你能夠了解到并發(fā)編程帶來的復(fù)雜性和相關(guān)問題累舷。并發(fā)編程中浩考,無論是看起來多么簡單的 API ,它們所能產(chǎn)生的問題會變得非常的難以觀測被盈,而且要想調(diào)試這類問題往往也都是非常困難的析孽。
但另一方面,并發(fā)實際上是一個非常棒的工具只怎。它充分利用了現(xiàn)代多核 CPU 的強大計算能力袜瞬。在開發(fā)中,關(guān)鍵的一點就是盡量讓并發(fā)模型保持簡單尝盼,這樣可以限制所需要的鎖的數(shù)量吞滞。
我們建議采納的安全模式是這樣的:從主線程中提取出要使用到的數(shù)據(jù),并利用一個操作隊列在后臺處理相關(guān)的數(shù)據(jù),最后回到主隊列中來發(fā)送你在后臺隊列中得到的結(jié)果裁赠。使用這種方式殿漠,你不需要自己做任何鎖操作,這也就大大減少了犯錯誤的幾率佩捞。