?iOS 提供了幾種不同的 API 來(lái)支持并發(fā)編程。每一個(gè) API 都具有不同的功能和使用限制,這使它們適合不同的任務(wù)。同時(shí),這些 API 處在不同的抽象層級(jí)上。我們有可能用其進(jìn)行非常深入底層的操作,但是這也意味著背負(fù)起將任務(wù)進(jìn)行良好處理的巨大責(zé)任。
實(shí)際上,并發(fā)編程是一個(gè)很有挑戰(zhàn)的主題,它有許多錯(cuò)綜復(fù)雜的問(wèn)題和陷阱。當(dāng)開(kāi)發(fā)者在使用類(lèi)似Grand Central Dispatch(GCD)或NSOperationQueue的 API 時(shí),很容易遺忘這些問(wèn)題和陷阱。本文首先對(duì) OS X 和 iOS 中不同的并發(fā)編程 API 進(jìn)行一些介紹,然后再深入了解并發(fā)編程中獨(dú)立于與你所使用的特定 API 的一些內(nèi)在挑戰(zhàn)。
蘋(píng)果的移動(dòng)和桌面操作系統(tǒng)中提供了相同的并發(fā)編程API。 pthread、NSThread、GCD、NSOperationQueue,以及NSRunLoop。實(shí)際上把 run loop 也列在其中是有點(diǎn)奇怪,因?yàn)樗⒉荒軐?shí)現(xiàn)真正的并行,不過(guò)因?yàn)樗c并發(fā)編程有莫大的關(guān)系,因此值得我們進(jìn)行一些深入了解。
由于高層 API 是基于底層 API 構(gòu)建的,所以我們首先將從底層的 API 開(kāi)始介紹,然后逐步擴(kuò)展到高層 API。不過(guò)在具體編程中,選擇 API 的順序剛好相反:因?yàn)榇蠖鄶?shù)情況下,選擇高層的 API 不僅可以完成底層 API 能完成的任務(wù),而且能夠讓并發(fā)模型變得簡(jiǎn)單。
如果你對(duì)我們?yōu)楹螆?jiān)持推薦使用高抽象層級(jí)以及簡(jiǎn)單的并行代碼有所疑問(wèn)的話(huà),那么你可以看看這篇文章的第二部分并發(fā)編程中面臨的挑戰(zhàn),以及 Peter Steinberger 寫(xiě)的關(guān)于線程安全的文章。
線程
線程(thread)是組成進(jìn)程的子單元,操作系統(tǒng)的調(diào)度器可以對(duì)線程進(jìn)行單獨(dú)的調(diào)度。實(shí)際上,所有的并發(fā)編程 API 都是構(gòu)建于線程之上的 —— 包括 GCD 和操作隊(duì)列(operation queues)。
多線程可以在單核 CPU 上同時(shí)(或者至少看作同時(shí))運(yùn)行。操作系統(tǒng)將小的時(shí)間片分配給每一個(gè)線程,這樣就能夠讓用戶(hù)感覺(jué)到有多個(gè)任務(wù)在同時(shí)進(jìn)行。如果 CPU 是多核的,那么線程就可以真正的以并發(fā)方式被執(zhí)行,從而減少了完成某項(xiàng)操作所需要的總時(shí)間。
你可以使用 Instruments 中的CPU strategy view來(lái)得知你的代碼或者你在使用的框架代碼是如何在多核 CPU 中調(diào)度執(zhí)行的。
需要重點(diǎn)關(guān)注的是,你無(wú)法控制你的代碼在什么地方以及什么時(shí)候被調(diào)度,以及無(wú)法控制執(zhí)行多長(zhǎng)時(shí)間后將被暫停,以便輪換執(zhí)行別的任務(wù)。這種線程調(diào)度是非常強(qiáng)大的一種技術(shù),但是也非常復(fù)雜,我們稍后研究。
先把線程調(diào)度的復(fù)雜情況放一邊,開(kāi)發(fā)者可以使用POSIX 線程API,或者 Objective-C 中提供的對(duì)該 API 的封裝NSThread,來(lái)創(chuàng)建自己的線程。下面這個(gè)小示例利用pthread來(lái)在一百萬(wàn)個(gè)數(shù)字中查找最小值和最大值。其中并發(fā)執(zhí)行了 4 個(gè)線程。從該示例復(fù)雜的代碼中,應(yīng)該可以看出為什么大家不會(huì)希望直接使用 pthread 。
#import? <pthread.h>
struct threadInfo { uint32_t * inputValues; size_t count; };t
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 (intargc,constchar* argv[])
{ size_tconstcount =1000000;?
uint32_t inputValues[count];
// 使用隨機(jī)數(shù)字填充
?inputValuesfor(size_t i =0; i < count; ++i) {
?inputValues[i] = arc4random();
?}
// 開(kāi)始4個(gè)尋找最小值和最大值的線程
size_tconstthreadCount =4;
?pthread_t tid[threadCount];
for(size_t i =0; i < threadCount; ++i) {structthreadInfo *constinfo = (structthreadInfo *) malloc(sizeof(*info)); size_t offset = (count / threadCount) * i; info->inputValues = inputValues + offset; info->count = MIN(count - offset, count / threadCount);interr = pthread_create(tid + i,NULL, &findMinAndMax, info); NSCAssert(err ==0,@"pthread_create() failed: %d", err); }// 等待線程退出structthreadResult * results[threadCount];for(size_t i =0; i < threadCount; ++i) {interr = pthread_join(tid[i], (void**) &(results[i])); NSCAssert(err ==0,@"pthread_join() failed: %d", err); }// 尋找 min 和 maxuint32_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);return0; }
NSThread是 Objective-C 對(duì) pthread 的一個(gè)封裝。通過(guò)封裝,在 Cocoa 環(huán)境中,可以讓代碼看起來(lái)更加親切。例如,開(kāi)發(fā)者可以利用 NSThread 的一個(gè)子類(lèi)來(lái)定義一個(gè)線程,在這個(gè)子類(lèi)的中封裝需要在后臺(tái)線程運(yùn)行的代碼。針對(duì)上面的那個(gè)例子,我們可以定義一個(gè)這樣的NSThread子類(lèi):
@interfaceFindMinMaxThread:NSThread@property(nonatomic) NSUInteger min;@property(nonatomic) NSUInteger max;- (instancetype)initWithNumbers:(NSArray*)numbers;@end@implementationFindMinMaxThread{NSArray*_numbers;}- (instancetype)initWithNumbers:(NSArray*)numbers{self= [superinit];if(self) {? ? ? ? _numbers = numbers;? ? }returnself;}- (void)main{? ? NSUInteger min;? ? NSUInteger max;// 進(jìn)行相關(guān)數(shù)據(jù)的處理self.min= min;self.max= max;}@end
要想啟動(dòng)一個(gè)新的線程,需要?jiǎng)?chuàng)建一個(gè)線程對(duì)象,然后調(diào)用它的start方法:
NSMutableSet*threads = [NSMutableSetset];NSUIntegernumberCount =self.numbers.count;NSUIntegerthreadCount =4;for(NSUIntegeri =0; i < threadCount; i++) {NSUIntegeroffset = (count/ threadCount) * i;NSUIntegercount=MIN(numberCount - offset, numberCount / threadCount);NSRangerange =NSMakeRange(offset,count);NSArray*subset = [self.numbers subarrayWithRange:range];FindMinMaxThread*thread = [[FindMinMaxThreadalloc] initWithNumbers:subset];? ? [threads addObject:thread];? ? [thread start];}
現(xiàn)在,我們可以通過(guò)檢測(cè)到線程的isFinished屬性來(lái)檢測(cè)新生成的線程是否已經(jīng)結(jié)束,并獲取結(jié)果。我們將這個(gè)練習(xí)留給感興趣的讀者,這主要是因?yàn)椴徽撌褂胮thread還是NSThread來(lái)直接對(duì)線程操作,都是相對(duì)糟糕的編程體驗(yàn),這種方式并不適合我們以寫(xiě)出良好代碼為目標(biāo)的編碼精神。
直接使用線程可能會(huì)引發(fā)的一個(gè)問(wèn)題是,如果你的代碼和所基于的框架代碼都創(chuàng)建自己的線程時(shí),那么活動(dòng)的線程數(shù)量有可能以指數(shù)級(jí)增長(zhǎng)。這在大型工程中是一個(gè)常見(jiàn)問(wèn)題。例如,在 8 核 CPU 中,你創(chuàng)建了 8 個(gè)線程來(lái)完全發(fā)揮 CPU 性能。然而在這些線程中你的代碼所調(diào)用的框架代碼也做了同樣事情(因?yàn)樗⒉恢滥阋呀?jīng)創(chuàng)建的這些線程),這樣會(huì)很快產(chǎn)生成成百上千的線程。代碼的每個(gè)部分自身都沒(méi)有問(wèn)題,然而最后卻還是導(dǎo)致了問(wèn)題。使用線程并不是沒(méi)有代價(jià)的,每個(gè)線程都會(huì)消耗一些內(nèi)存和內(nèi)核資源。
接下來(lái),我們將介紹兩個(gè)基于隊(duì)列的并發(fā)編程 API :GCD 和 operation queue 。它們通過(guò)集中管理一個(gè)被大家協(xié)同使用的線程池,來(lái)解決上面遇到的問(wèn)題。
Grand Central Dispatch
為了讓開(kāi)發(fā)者更加容易的使用設(shè)備上的多核CPU,蘋(píng)果在 OS X 10.6 和 iOS 4 中引入了 Grand Central Dispatch(GCD)。在下一篇關(guān)于底層并發(fā) API的文章中,我們將更深入地介紹 GCD。
通過(guò) GCD,開(kāi)發(fā)者不用再直接跟線程打交道了,只需要向隊(duì)列中添加代碼塊即可,GCD 在后端管理著一個(gè)線程池。GCD 不僅決定著你的代碼塊將在哪個(gè)線程被執(zhí)行,它還根據(jù)可用的系統(tǒng)資源對(duì)這些線程進(jìn)行管理。這樣可以將開(kāi)發(fā)者從線程管理的工作中解放出來(lái),通過(guò)集中的管理線程,來(lái)緩解大量線程被創(chuàng)建的問(wèn)題。
GCD 帶來(lái)的另一個(gè)重要改變是,作為開(kāi)發(fā)者可以將工作考慮為一個(gè)隊(duì)列,而不是一堆線程,這種并行的抽象模型更容易掌握和使用。
GCD 公開(kāi)有 5 個(gè)不同的隊(duì)列:運(yùn)行在主線程中的 main queue,3 個(gè)不同優(yōu)先級(jí)的后臺(tái)隊(duì)列,以及一個(gè)優(yōu)先級(jí)更低的后臺(tái)隊(duì)列(用于 I/O)。
另外,開(kāi)發(fā)者可以創(chuàng)建自定義隊(duì)列:串行或者并行隊(duì)列。自定義隊(duì)列非常強(qiáng)大,在自定義隊(duì)列中被調(diào)度的所有 block 最終都將被放入到系統(tǒng)的全局隊(duì)列中和線程池中。
使用不同優(yōu)先級(jí)的若干個(gè)隊(duì)列乍聽(tīng)起來(lái)非常直接,不過(guò),我們強(qiáng)烈建議,在絕大多數(shù)情況下使用默認(rèn)的優(yōu)先級(jí)隊(duì)列就可以了。如果執(zhí)行的任務(wù)需要訪問(wèn)一些共享的資源,那么在不同優(yōu)先級(jí)的隊(duì)列中調(diào)度這些任務(wù)很快就會(huì)造成不可預(yù)期的行為。這樣可能會(huì)引起程序的完全掛起,因?yàn)榈蛢?yōu)先級(jí)的任務(wù)阻塞了高優(yōu)先級(jí)任務(wù),使它不能被執(zhí)行。更多相關(guān)內(nèi)容,在本文的優(yōu)先級(jí)反轉(zhuǎn)部分中會(huì)有介紹。
雖然 GCD 是一個(gè)低層級(jí)的 C API ,但是它使用起來(lái)非常的直接。不過(guò)這也容易使開(kāi)發(fā)者忘記并發(fā)編程中的許多注意事項(xiàng)和陷阱。讀者可以閱讀本文后面的并發(fā)編程中面臨的挑戰(zhàn),這樣可以注意到一些潛在的問(wèn)題。本期的另外一篇優(yōu)秀文章:底層并發(fā) API中,包含了很多深入的解釋和一些有價(jià)值的提示。
Operation Queues
操作隊(duì)列(operation queue)是由 GCD 提供的一個(gè)隊(duì)列模型的 Cocoa 抽象。GCD 提供了更加底層的控制,而操作隊(duì)列則在 GCD 之上實(shí)現(xiàn)了一些方便的功能,這些功能對(duì)于 app 的開(kāi)發(fā)者來(lái)說(shuō)通常是最好最安全的選擇。
NSOperationQueue有兩種不同類(lèi)型的隊(duì)列:主隊(duì)列和自定義隊(duì)列。主隊(duì)列運(yùn)行在主線程之上,而自定義隊(duì)列在后臺(tái)執(zhí)行。在兩種類(lèi)型中,這些隊(duì)列所處理的任務(wù)都使用NSOperation的子類(lèi)來(lái)表述。
你可以通過(guò)重寫(xiě)main或者start方法 來(lái)定義自己的operations。前一種方法非常簡(jiǎn)單,開(kāi)發(fā)者不需要管理一些狀態(tài)屬性(例如isExecuting和isFinished),當(dāng)main方法返回的時(shí)候,這個(gè) operation 就結(jié)束了。這種方式使用起來(lái)非常簡(jiǎn)單,但是靈活性相對(duì)重寫(xiě)start來(lái)說(shuō)要少一些。
@implementationYourOperation- (void)main? ? {// 進(jìn)行處理 ...}@end
如果你希望擁有更多的控制權(quán),以及在一個(gè)操作中可以執(zhí)行異步任務(wù),那么就重寫(xiě)start方法:
@implementationYourOperation- (void)start? ? {self.isExecuting=YES;self.isFinished=NO;// 開(kāi)始處理,在結(jié)束時(shí)應(yīng)該調(diào)用 finished ...}? ? - (void)finished? ? {self.isExecuting=NO;self.isFinished=YES;? ? }@end
注意:這種情況下,你必須手動(dòng)管理操作的狀態(tài)。 為了讓操作隊(duì)列能夠捕獲到操作的改變,需要將狀態(tài)的屬性以配合 KVO 的方式進(jìn)行實(shí)現(xiàn)。如果你不使用它們默認(rèn)的 setter 來(lái)進(jìn)行設(shè)置的話(huà),你就需要在合適的時(shí)候發(fā)送合適的 KVO 消息。
為了能使用操作隊(duì)列所提供的取消功能,你需要在長(zhǎng)時(shí)間操作中時(shí)不時(shí)地檢查isCancelled屬性:
- (void)main{while(notDone && !self.isCancelled) {// 進(jìn)行處理}}
當(dāng)你定義好 operation 類(lèi)之后,就可以很容易的將一個(gè) operation 添加到隊(duì)列中:
NSOperationQueue*queue = [[NSOperationQueuealloc]init];YourOperation*operation = [[YourOperationalloc]init];[queue? addOperation:operation];
另外,你也可以將 block 添加到操作隊(duì)列中。這有時(shí)候會(huì)非常的方便,比如你希望在主隊(duì)列中調(diào)度一個(gè)一次性任務(wù):
[[NSOperationQueuemainQueue]addOperationWithBlock:^{//代碼...}];
雖然通過(guò)這種的方式在隊(duì)列中添加操作會(huì)非常方便,但是定義你自己的 NSOperation 子類(lèi)會(huì)在調(diào)試時(shí)很有幫助。如果你重寫(xiě) operation 的description方法,就可以很容易的標(biāo)示出在某個(gè)隊(duì)列中當(dāng)前被調(diào)度的所有操作 。
除了提供基本的調(diào)度操作或 block 外,操作隊(duì)列還提供了在 GCD 中不太容易處理好的特性的功能。例如,你可以通過(guò)maxConcurrentOperationCount屬性來(lái)控制一個(gè)特定隊(duì)列中可以有多少個(gè)操作參與并發(fā)執(zhí)行。將其設(shè)置為 1 的話(huà),你將得到一個(gè)串行隊(duì)列,這在以隔離為目的的時(shí)候會(huì)很有用。
另外還有一個(gè)方便的功能就是根據(jù)隊(duì)列中operation的優(yōu)先級(jí)對(duì)其進(jìn)行排序,這不同于 GCD 的隊(duì)列優(yōu)先級(jí),它只影響當(dāng)前隊(duì)列中所有被調(diào)度的 operation 的執(zhí)行先后。如果你需要進(jìn)一步在除了 5 個(gè)標(biāo)準(zhǔn)的優(yōu)先級(jí)以外對(duì) operation 的執(zhí)行順序進(jìn)行控制的話(huà),還可以在 operation 之間指定依賴(lài)關(guān)系,如下:
[intermediateOperation addDependency:operation1];
[intermediateOperation addDependency:operation2];
[finishedOperation addDependency:intermediateOperation];
這些簡(jiǎn)單的代碼可以確保operation1和operation2在intermediateOperation之前執(zhí)行,當(dāng)然,也會(huì)在finishOperation之前被執(zhí)行。對(duì)于需要明確的執(zhí)行順序時(shí),操作依賴(lài)是非常強(qiáng)大的一個(gè)機(jī)制。它可以讓你創(chuàng)建一些操作組,并確保這些操作組在依賴(lài)它們的操作被執(zhí)行之前執(zhí)行,或者在并發(fā)隊(duì)列中以串行的方式執(zhí)行操作。
從本質(zhì)上來(lái)看,操作隊(duì)列的性能比 GCD 要低那么一點(diǎn),不過(guò),大多數(shù)情況下這點(diǎn)負(fù)面影響可以忽略不計(jì),操作隊(duì)列是并發(fā)編程的首選工具。
Run Loops
實(shí)際上,Run loop并不像 GCD 或者操作隊(duì)列那樣是一種并發(fā)機(jī)制,因?yàn)樗⒉荒懿⑿袌?zhí)行任務(wù)。不過(guò)在主 dispatch/operation 隊(duì)列中, run loop 將直接配合任務(wù)的執(zhí)行,它提供了一種異步執(zhí)行代碼的機(jī)制。
Run loop 比起操作隊(duì)列或者 GCD 來(lái)說(shuō)容易使用得多,因?yàn)橥ㄟ^(guò) run loop ,你不必處理并發(fā)中的復(fù)雜情況,就能異步地執(zhí)行任務(wù)。
一個(gè) run loop 總是綁定到某個(gè)特定的線程中。main run loop 是與主線程相關(guān)的,在每一個(gè) Cocoa 和 CocoaTouch 程序中,這個(gè) main run loop 都扮演了一個(gè)核心角色,它負(fù)責(zé)處理 UI 事件、計(jì)時(shí)器,以及其它內(nèi)核相關(guān)事件。無(wú)論你什么時(shí)候設(shè)置計(jì)時(shí)器、使用NSURLConnection或者調(diào)用performSelector:withObject:afterDelay:,其實(shí)背后都是 run loop 在處理這些異步任務(wù)。
無(wú)論何時(shí)你使用 run loop 來(lái)執(zhí)行一個(gè)方法的時(shí)候,都需要記住一點(diǎn):run loop 可以運(yùn)行在不同的模式中,每種模式都定義了一組事件,供 run loop 做出響應(yīng)。這在對(duì)應(yīng) main run loop 中暫時(shí)性的將某個(gè)任務(wù)優(yōu)先執(zhí)行這種任務(wù)上是一種聰明的做法。
關(guān)于這點(diǎn),在 iOS 中非常典型的一個(gè)示例就是滾動(dòng)。在進(jìn)行滾動(dòng)時(shí),run loop 并不是運(yùn)行在默認(rèn)模式中的,因此, run loop 此時(shí)并不會(huì)響應(yīng)比如滾動(dòng)前設(shè)置的計(jì)時(shí)器。一旦滾動(dòng)停止了,run loop 會(huì)回到默認(rèn)模式,并執(zhí)行添加到隊(duì)列中的相關(guān)事件。如果在滾動(dòng)時(shí),希望計(jì)時(shí)器能被觸發(fā),需要將其設(shè)為NSRunLoopCommonModes的模式,并添加到 run loop 中。
主線程一般來(lái)說(shuō)都已經(jīng)配置好了 main run loop。然而其他線程默認(rèn)情況下都沒(méi)有設(shè)置 run loop。你也可以自行為其他線程設(shè)置 run loop ,但是一般來(lái)說(shuō)我們很少需要這么做。大多數(shù)時(shí)間使用 main run loop 會(huì)容易得多。如果你需要處理一些很重的工作,但是又不想在主線程里做,你仍然可以在你的代碼在 main run loop 中被調(diào)用后將工作分配給其他隊(duì)列。Chris 在他關(guān)于常見(jiàn)的后臺(tái)實(shí)踐的文章里闡述了一些關(guān)于這種模式的很好的例子。
如果你真需要在別的線程中添加一個(gè) run loop ,那么不要忘記在 run loop 中至少添加一個(gè) input source 。如果 run loop 中沒(méi)有設(shè)置好的 input source,那么每次運(yùn)行這個(gè) run loop ,它都會(huì)立即退出。
并發(fā)編程中面臨的挑戰(zhàn)
使用并發(fā)編程會(huì)帶來(lái)許多陷阱。只要一旦你做的事情超過(guò)了最基本的情況,對(duì)于并發(fā)執(zhí)行的多任務(wù)之間的相互影響的不同狀態(tài)的監(jiān)視就會(huì)變得異常困難。 問(wèn)題往往發(fā)生在一些不確定性(不可預(yù)見(jiàn)性)的地方,這使得在調(diào)試相關(guān)并發(fā)代碼時(shí)更加困難。
關(guān)于并發(fā)編程的不可預(yù)見(jiàn)性有一個(gè)非常有名的例子:在1995年, NASA (美國(guó)宇航局)發(fā)送了開(kāi)拓者號(hào)火星探測(cè)器,但是當(dāng)探測(cè)器成功著陸在我們紅色的鄰居星球后不久,任務(wù)嘎然而止,火星探測(cè)器莫名其妙的不停重啟,在計(jì)算機(jī)領(lǐng)域內(nèi),遇到的這種現(xiàn)象被定為為優(yōu)先級(jí)反轉(zhuǎn),也就是說(shuō)低優(yōu)先級(jí)的線程一直阻塞著高優(yōu)先級(jí)的線程。稍后我們會(huì)看到關(guān)于這個(gè)問(wèn)題的更多細(xì)節(jié)。在這里我們想說(shuō)明的是,即使擁有豐富的資源和大量?jī)?yōu)秀工程師的智慧,并發(fā)也還是會(huì)在不少情況下反咬你你一口。
資源共享
并發(fā)編程中許多問(wèn)題的根源就是在多線程中訪問(wèn)共享資源。資源可以是一個(gè)屬性、一個(gè)對(duì)象,通用的內(nèi)存、網(wǎng)絡(luò)設(shè)備或者一個(gè)文件等等。在多線程中任何一個(gè)共享的資源都可能是一個(gè)潛在的沖突點(diǎn),你必須精心設(shè)計(jì)以防止這種沖突的發(fā)生。
為了演示這類(lèi)問(wèn)題,我們舉一個(gè)關(guān)于資源的簡(jiǎn)單示例:比如僅僅用一個(gè)整型值來(lái)做計(jì)數(shù)器。在程序運(yùn)行過(guò)程中,我們有兩個(gè)并行線程 A 和 B,這兩個(gè)線程都嘗試著同時(shí)增加計(jì)數(shù)器的值。問(wèn)題來(lái)了,你通過(guò) C 語(yǔ)言或 Objective-C 寫(xiě)的代碼大多數(shù)情況下對(duì)于 CPU 來(lái)說(shuō)不會(huì)僅僅是一條機(jī)器指令。要想增加計(jì)數(shù)器的值,當(dāng)前的必須被從內(nèi)存中讀出,然后增加計(jì)數(shù)器的值,最后還需要將這個(gè)增加后的值寫(xiě)回內(nèi)存中。
我們可以試著想一下,如果兩個(gè)線程同時(shí)做上面涉及到的操作,會(huì)發(fā)生怎樣的偶然。例如,線程 A 和 B 都從內(nèi)存中讀取出了計(jì)數(shù)器的值,假設(shè)為17,然后線程A將計(jì)數(shù)器的值加1,并將結(jié)果18寫(xiě)回到內(nèi)存中。同時(shí),線程B也將計(jì)數(shù)器的值加 1 ,并將結(jié)果18寫(xiě)回到內(nèi)存中。實(shí)際上,此時(shí)計(jì)數(shù)器的值已經(jīng)被破壞掉了,因?yàn)橛?jì)數(shù)器的值17被加 1 了兩次,而它的值卻是18。
這個(gè)問(wèn)題被叫做競(jìng)態(tài)條件,在多線程里面訪問(wèn)一個(gè)共享的資源,如果沒(méi)有一種機(jī)制來(lái)確保在線程 A 結(jié)束訪問(wèn)一個(gè)共享資源之前,線程 B 就不會(huì)開(kāi)始訪問(wèn)該共享資源的話(huà),資源競(jìng)爭(zhēng)的問(wèn)題就總是會(huì)發(fā)生。如果你所寫(xiě)入內(nèi)存的并不是一個(gè)簡(jiǎn)單的整數(shù),而是一個(gè)更復(fù)雜的數(shù)據(jù)結(jié)構(gòu),可能會(huì)發(fā)生這樣的現(xiàn)象:當(dāng)?shù)谝粋€(gè)線程正在寫(xiě)入這個(gè)數(shù)據(jù)結(jié)構(gòu)時(shí),第二個(gè)線程卻嘗試讀取這個(gè)數(shù)據(jù)結(jié)構(gòu),那么獲取到的數(shù)據(jù)可能是新舊參半或者沒(méi)有初始化。為了防止出現(xiàn)這樣的問(wèn)題,多線程需要一種互斥的機(jī)制來(lái)訪問(wèn)共享資源。
在實(shí)際的開(kāi)發(fā)中,情況甚至要比上面介紹的更加復(fù)雜,因?yàn)楝F(xiàn)代 CPU 為了優(yōu)化目的,往往會(huì)改變向內(nèi)存讀寫(xiě)數(shù)據(jù)的順序(亂序執(zhí)行)。
互斥鎖
互斥訪問(wèn)的意思就是同一時(shí)刻,只允許一個(gè)線程訪問(wèn)某個(gè)特定資源。為了保證這一點(diǎn),每個(gè)希望訪問(wèn)共享資源的線程,首先需要獲得一個(gè)共享資源的互斥鎖,一旦某個(gè)線程對(duì)資源完成了操作,就釋放掉這個(gè)互斥鎖,這樣別的線程就有機(jī)會(huì)訪問(wèn)該共享資源了。
除了確保互斥訪問(wèn),還需要解決代碼無(wú)序執(zhí)行所帶來(lái)的問(wèn)題。如果不能確保 CPU 訪問(wèn)內(nèi)存的順序跟編程時(shí)的代碼指令一樣,那么僅僅依靠互斥訪問(wèn)是不夠的。為了解決由 CPU 的優(yōu)化策略引起的副作用,還需要引入內(nèi)存屏障。通過(guò)設(shè)置內(nèi)存屏障,來(lái)確保沒(méi)有無(wú)序執(zhí)行的指令能跨過(guò)屏障而執(zhí)行。
當(dāng)然,互斥鎖自身的實(shí)現(xiàn)是需要沒(méi)有競(jìng)爭(zhēng)條件的。這實(shí)際上是非常重要的一個(gè)保證,并且需要在現(xiàn)代 CPU 上使用特殊的指令。更多關(guān)于原子操作(atomic operation)的信息,請(qǐng)閱讀 Daniel 寫(xiě)的文章:底層并發(fā)技術(shù)。
從語(yǔ)言層面來(lái)說(shuō),在 Objective-C 中將屬性以 atomic 的形式來(lái)聲明,就能支持互斥鎖了。事實(shí)上在默認(rèn)情況下,屬性就是 atomic 的。將一個(gè)屬性聲明為 atomic 表示每次訪問(wèn)該屬性都會(huì)進(jìn)行隱式的加鎖和解鎖操作。雖然最把穩(wěn)的做法就是將所有的屬性都聲明為 atomic,但是加解鎖這也會(huì)付出一定的代價(jià)。
在資源上的加鎖會(huì)引發(fā)一定的性能代價(jià)。獲取鎖和釋放鎖的操作本身也需要沒(méi)有競(jìng)態(tài)條件,這在多核系統(tǒng)中是很重要的。另外,在獲取鎖的時(shí)候,線程有時(shí)候需要等待,因?yàn)榭赡芷渌木€程已經(jīng)獲取過(guò)資源的鎖了。這種情況下,線程會(huì)進(jìn)入休眠狀態(tài)。當(dāng)其它線程釋放掉相關(guān)資源的鎖時(shí),休眠的線程會(huì)得到通知。所有這些相關(guān)操作都是非常昂貴且復(fù)雜的。
鎖也有不同的類(lèi)型。當(dāng)沒(méi)有競(jìng)爭(zhēng)時(shí),有些鎖在沒(méi)有鎖競(jìng)爭(zhēng)的情況下性能很好,但是在有鎖的競(jìng)爭(zhēng)情況下,性能就會(huì)大打折扣。另外一些鎖則在基本層面上就比較耗費(fèi)資源,但是在競(jìng)爭(zhēng)情況下,性能的惡化會(huì)沒(méi)那么厲害。(鎖的競(jìng)爭(zhēng)是這樣產(chǎn)生的:當(dāng)一個(gè)或者多個(gè)線程嘗試獲取一個(gè)已經(jīng)被別的線程獲取過(guò)了的鎖)。
在這里有一個(gè)東西需要進(jìn)行權(quán)衡:獲取和釋放鎖所是要帶來(lái)開(kāi)銷(xiāo)的,因此你需要確保你不會(huì)頻繁地進(jìn)入和退出臨界區(qū)段(比如獲取和釋放鎖)。同時(shí),如果你獲取鎖之后要執(zhí)行一大段代碼,這將帶來(lái)鎖競(jìng)爭(zhēng)的風(fēng)險(xiǎn):其它線程可能必須等待獲取資源鎖而無(wú)法工作。這并不是一項(xiàng)容易解決的任務(wù)。
我們經(jīng)常能看到本來(lái)計(jì)劃并行運(yùn)行的代碼,但實(shí)際上由于共享資源中配置了相關(guān)的鎖,所以同一時(shí)間只有一個(gè)線程是處于激活狀態(tài)的。對(duì)于你的代碼會(huì)如何在多核上運(yùn)行的預(yù)測(cè)往往十分重要,你可以使用 Instrument 的CPU strategy view來(lái)檢查是否有效的利用了 CPU 的可用核數(shù),進(jìn)而得出更好的想法,以此來(lái)優(yōu)化代碼。
死鎖
互斥鎖解決了競(jìng)態(tài)條件的問(wèn)題,但很不幸同時(shí)這也引入了一些其他問(wèn)題,其中一個(gè)就是死鎖。當(dāng)多個(gè)線程在相互等待著對(duì)方的結(jié)束時(shí),就會(huì)發(fā)生死鎖,這時(shí)程序可能會(huì)被卡住。
看看下面的代碼,它交換兩個(gè)變量的值:
voidswap(A, B){? ? lock(lockA);? ? lock(lockB);inta = A;intb = B;? ? A = b;? ? B = a;? ? unlock(lockB);? ? unlock(lockA);}
大多數(shù)時(shí)候,這能夠正常運(yùn)行。但是當(dāng)兩個(gè)線程使用相反的值來(lái)同時(shí)調(diào)用上面這個(gè)方法時(shí):
swap(X,Y);// 線程 1swap(Y,X);// 線程 2
此時(shí)程序可能會(huì)由于死鎖而被終止。線程 1 獲得了 X 的一個(gè)鎖,線程 2 獲得了 Y 的一個(gè)鎖。 接著它們會(huì)同時(shí)等待另外一把鎖,但是永遠(yuǎn)都不會(huì)獲得。
再說(shuō)一次,你在線程之間共享的資源越多,你使用的鎖也就越多,同時(shí)程序被死鎖的概率也會(huì)變大。這也是為什么我們需要盡量減少線程間資源共享,并確保共享的資源盡量簡(jiǎn)單的原因之一。建議閱讀一下底層并發(fā)編程 API中的全部使用異步分發(fā)一節(jié)。
資源饑餓(Starvation)
當(dāng)你認(rèn)為已經(jīng)足夠了解并發(fā)編程面臨的問(wèn)題時(shí),又出現(xiàn)了一個(gè)新的問(wèn)題。鎖定的共享資源會(huì)引起讀寫(xiě)問(wèn)題。大多數(shù)情況下,限制資源一次只能有一個(gè)線程進(jìn)行讀取訪問(wèn)其實(shí)是非常浪費(fèi)的。因此,在資源上沒(méi)有寫(xiě)入鎖的時(shí)候,持有一個(gè)讀取鎖是被允許的。這種情況下,如果一個(gè)持有讀取鎖的線程在等待獲取寫(xiě)入鎖的時(shí)候,其他希望讀取資源的線程則因?yàn)闊o(wú)法獲得這個(gè)讀取鎖而導(dǎo)致資源饑餓的發(fā)生。
為了解決這個(gè)問(wèn)題,我們需要使用一個(gè)比簡(jiǎn)單的讀/寫(xiě)鎖更聰明的方法,例如給定一個(gè)writer preference,或者使用read-copy-update算法。Daniel 在底層并發(fā)編程 API中有介紹了如何用 GCD 實(shí)現(xiàn)一個(gè)多讀取單寫(xiě)入的模式,這樣就不會(huì)被寫(xiě)入資源饑餓的問(wèn)題困擾了。
優(yōu)先級(jí)反轉(zhuǎn)
本節(jié)開(kāi)頭介紹了美國(guó)宇航局發(fā)射的開(kāi)拓者號(hào)火星探測(cè)器在火星上遇到的并發(fā)問(wèn)題。現(xiàn)在我們就來(lái)看看為什么開(kāi)拓者號(hào)幾近失敗,以及為什么有時(shí)候我們的程序也會(huì)遇到相同的問(wèn)題,該死的優(yōu)先級(jí)反轉(zhuǎn)。
優(yōu)先級(jí)反轉(zhuǎn)是指程序在運(yùn)行時(shí)低優(yōu)先級(jí)的任務(wù)阻塞了高優(yōu)先級(jí)的任務(wù),有效的反轉(zhuǎn)了任務(wù)的優(yōu)先級(jí)。由于 GCD 提供了擁有不同優(yōu)先級(jí)的后臺(tái)隊(duì)列,甚至包括一個(gè) I/O 隊(duì)列,所以我們最好了解一下優(yōu)先級(jí)反轉(zhuǎn)的可能性。
高優(yōu)先級(jí)和低優(yōu)先級(jí)的任務(wù)之間共享資源時(shí),就可能發(fā)生優(yōu)先級(jí)反轉(zhuǎn)。當(dāng)?shù)蛢?yōu)先級(jí)的任務(wù)獲得了共享資源的鎖時(shí),該任務(wù)應(yīng)該迅速完成,并釋放掉鎖,這樣高優(yōu)先級(jí)的任務(wù)就可以在沒(méi)有明顯延時(shí)的情況下繼續(xù)執(zhí)行。然而高優(yōu)先級(jí)任務(wù)會(huì)在低優(yōu)先級(jí)的任務(wù)持有鎖的期間被阻塞。如果這時(shí)候有一個(gè)中優(yōu)先級(jí)的任務(wù)(該任務(wù)不需要那個(gè)共享資源),那么它就有可能會(huì)搶占低優(yōu)先級(jí)任務(wù)而被執(zhí)行,因?yàn)榇藭r(shí)高優(yōu)先級(jí)任務(wù)是被阻塞的,所以中優(yōu)先級(jí)任務(wù)是目前所有可運(yùn)行任務(wù)中優(yōu)先級(jí)最高的。此時(shí),中優(yōu)先級(jí)任務(wù)就會(huì)阻塞著低優(yōu)先級(jí)任務(wù),導(dǎo)致低優(yōu)先級(jí)任務(wù)不能釋放掉鎖,這也就會(huì)引起高優(yōu)先級(jí)任務(wù)一直在等待鎖的釋放。
在你的實(shí)際代碼中,可能不會(huì)像發(fā)生在火星的事情那樣戲劇性地不停重啟。遇到優(yōu)先級(jí)反轉(zhuǎn)時(shí),一般沒(méi)那么嚴(yán)重。
解決這個(gè)問(wèn)題的方法,通常就是不要使用不同的優(yōu)先級(jí)。通常最后你都會(huì)以讓高優(yōu)先級(jí)的代碼等待低優(yōu)先級(jí)的代碼來(lái)解決問(wèn)題。當(dāng)你使用 GCD 時(shí),總是使用默認(rèn)的優(yōu)先級(jí)隊(duì)列(直接使用,或者作為目標(biāo)隊(duì)列)。如果你使用不同的優(yōu)先級(jí),很可能實(shí)際情況會(huì)讓事情變得更糟糕。
從中得到的教訓(xùn)是,使用不同優(yōu)先級(jí)的多個(gè)隊(duì)列聽(tīng)起來(lái)雖然不錯(cuò),但畢竟是紙上談兵。它將讓本來(lái)就復(fù)雜的并行編程變得更加復(fù)雜和不可預(yù)見(jiàn)。如果你在編程中,遇到高優(yōu)先級(jí)的任務(wù)突然沒(méi)理由地卡住了,可能你會(huì)想起本文,以及那個(gè)美國(guó)宇航局的工程師也遇到過(guò)的被稱(chēng)為優(yōu)先級(jí)反轉(zhuǎn)的問(wèn)題。
總結(jié)
我們希望通過(guò)本文你能夠了解到并發(fā)編程帶來(lái)的復(fù)雜性和相關(guān)問(wèn)題。并發(fā)編程中,無(wú)論是看起來(lái)多么簡(jiǎn)單的 API ,它們所能產(chǎn)生的問(wèn)題會(huì)變得非常的難以觀測(cè),而且要想調(diào)試這類(lèi)問(wèn)題往往也都是非常困難的。
但另一方面,并發(fā)實(shí)際上是一個(gè)非常棒的工具。它充分利用了現(xiàn)代多核 CPU 的強(qiáng)大計(jì)算能力。在開(kāi)發(fā)中,關(guān)鍵的一點(diǎn)就是盡量讓并發(fā)模型保持簡(jiǎn)單,這樣可以限制所需要的鎖的數(shù)量。
我們建議采納的安全模式是這樣的:從主線程中提取出要使用到的數(shù)據(jù),并利用一個(gè)操作隊(duì)列在后臺(tái)處理相關(guān)的數(shù)據(jù),最后回到主隊(duì)列中來(lái)發(fā)送你在后臺(tái)隊(duì)列中得到的結(jié)果。使用這種方式,你不需要自己做任何鎖操作,這也就大大減少了犯錯(cuò)誤的幾率。