objc系列譯文(2.1):Objective-C併發程式設計:API和挑戰

溫小嘉發表於2013-12-10

併發指的是在同一時間執行多個任務。在單核CPU的情況下,它通過分時的方式實現,如果有多個CPU可用,則是真正意義上的多個任務“並行”執行了。

OS X和iOS提供了多個API支援併發程式設計。每個API都有自己特殊的功能和限制,適用於完成不同的任務。它們也分佈在不同的抽象層次,我們可以通過底層API去做些非常接近硬體的底層操作,但這樣的話,我們也需要做更多的事去保證一切執行正常。

併發程式設計是件非常棘手的事,有著許多複雜的問題和陷阱,而且在使用像GCD或NSOperationQueue這樣的API時我們常常忘了這點。這篇文章將首先總體介紹OS X和iOS中不同的併發程式設計API,然後更深入地研究併發程式設計本身所固有的、與具體API無關的挑戰。

 

OS X和iOS上的併發API

Apple的移動和桌面作業系統提供了相同的併發程式設計API。這篇文章中,我們來看一下pthread和NSThread、GCD、NSOperationQueue,以及NSRunLoop。從技術上講,runloops被列在這裡有點奇怪,因為他們並不支援真正的併發執行。之所以放在這裡將,是因為他們和這個話題關係非常密切,值得我們瞭解一下。

我們將按從底層到上層的順序來介紹這些API。之所以這樣做是因為上層API都是建立在底層API基礎上的。但當你實際使用時,你應該按照相反的順序來選擇:在能實現你的要求的前提下,儘量選上層API,這樣能讓你的併發模型更簡潔。

如果你想知道為什麼我們一直建議採用上層抽象和簡潔的併發程式設計程式碼,你可以閱讀這篇文章的第二部分:《併發程式設計所面臨的挑戰》,以及Peter Steinberger的這篇關於執行緒安全的文章

 

執行緒

執行緒是程式的子單元,可以單獨被作業系統的排程器排程。事實上所有併發API都建立線上程基礎上,包括GCD和操作佇列。

多執行緒可以在單核CPU上同時執行(或至少看起來是在同時執行)。作業系統為每條執行緒分配運算時間片,這樣使用者感覺就好像多個任務同時被執行一樣。如果CPU是多核的,多條執行緒就可以真正地被同時執行,完成某個操作的總體時間也因此能被縮短。

你可以通過Instruments的CPU 檢視來了解你的程式碼或你所使用的框架程式碼在多核CPU上是怎樣被排程的。

有一點需要牢記的是:你無法控制你的程式碼在何時何地被排程,以及它何時被暫停、暫停多久以供其他任務執行。執行緒的這種排程機制是個非常強大的技術,但同時也非常複雜,我們將在稍後對其進行說明。

暫時先拋開這種複雜性不談,你可以使用POSIX執行緒API,或者Objective-C對此API進行的封裝——NSThread,來建立你的執行緒。這裡有個小例子,演示使用pthread來實現在一百萬個數字中尋找最小值和最大值。它產生了4個併發執行的執行緒。從這個例子複雜的程式碼中可以很明顯地看出為什麼你不會希望直接使用pthread函式程式設計。

NSThread是Objective-C對pthread的封裝,用於將操作簡化。通過這種封裝,程式碼在Cocoa環境中看起來就友好多了。比如,你可以將執行緒定義成NSThread的子類,將你想在後臺執行的程式碼封裝起來。針對上面那個例子,我們可以這樣來定義NSThread的子類:

要想啟動一條新執行緒,需要建立一個新的執行緒物件,並呼叫它的start函式:

現在我們可以通過檢測執行緒的 isFinished屬性來偵測何時我們建立的執行緒都結束了。有興趣的同學可以去試一試,然而我最想說的是,直接操作執行緒,不管是用pthread還是NSThread,都相當痛苦。

直接使用執行緒會引發的一個問題就是:如果你的程式碼和底層框架程式碼都生成自己的執行緒,啟用的執行緒的數量將以指數級別增長。這在大型專案中是普遍存在的問題,舉個例子,比如你建立了8條執行緒去使用8核的CPU,而你在這些執行緒中所呼叫的框架程式碼也這樣做(因為它不知道你當前建立了多少執行緒),你很快就會有幾十個甚至上百個執行緒。雖然涉及到的程式碼各司其職,然而,總體執行效果是會出問題的。執行緒不是免費的午餐,每條執行緒都會消耗記憶體及核心資源。

接下來,我們將介紹兩組基於佇列的併發API:GCD和操作佇列。他們通過集中管理一個公用執行緒池的方式來緩解問題。

 

Grand Central Dispatch(GCD)

Grand Central Dispatch(GCD)是在 OS X 10.6 和 iOS4 中引入的,旨在讓開發者能更好地利用使用者終端上的多核CPU。我們將在我們關於底層併發API的文章中更深入的介紹GCD的細節。

通過GCD,你不再直接和執行緒打交道,而是在佇列中新增程式碼塊,GCD在後臺管理這一個執行緒池。GCD會決定你的程式碼塊將在哪個執行緒被執行,而且它會根據可用的系統資源來管理這些執行緒。這樣就緩解了建立過多執行緒的問題,因為執行緒現在是集中管理從而把開發者解放了出來。

GCD帶來的另一個重要改變是:開發者以佇列的,而不是執行緒的思維去處理多項操作,這種新的併發程式設計的思維方式也更容易實施。

GCD公開了5個不同的佇列:1個執行在主執行緒中的main queue;3個具有不同優先順序的後臺佇列;以及一個具有更低優先順序的後臺佇列,用於直接控制I/O。另外,你還可以建立自定義佇列,這些佇列可以是序列的,也可以是並行的。自定義佇列很強大,你在其中排程的所有程式碼塊都將被放入到系統的一個全域性佇列和它的執行緒池中。

gcd-queues@2x

使用優先順序不同的多個執行緒,乍一聽很簡單,然而,我們強烈建議你在幾乎任何情況下都使用預設優先順序。在不同優先順序的佇列中排程任務,當這些任務訪問共享資源的時候,很容易引起不希望的結果。這最終將導致你整個程式崩潰停止,因為某些低優先順序的任務阻塞了高優先順序任務的執行。在下面講的優先順序反轉中你將看到更多這類現象。

雖然GCD是稍低層次的C語言API,但它用起來很簡單。這也容易讓人忘記在使用GCD時所有併發程式設計的注意事項和陷阱仍然是存在的。請一定要閱讀下文中的併發程式設計的挑戰,以瞭解潛在的問題。我們在本期話題中有另一篇文章對GCD的API進行了更深入的講解,以及有價值的提示。

 

操作佇列Operation Queue

操作佇列是Cocoa中與GCD佇列模型對應的一個概念。GCD提供更底層一些的控制,相對而言,操作佇列在他之上實現了許多更便捷易用的特性,所以通常對應用開發者來說操作佇列是最好也是最安全的選擇。

NSOperationQueue類有兩種型別的佇列:主佇列和自定義佇列。主佇列在主執行緒中執行,自定義佇列在後臺執行。不管是哪種佇列,其中處理的任務都以NSOperation的子類來表示。

你可以通過兩種方式來定義你的操作:重寫main方法,或重寫start方法。前者非常容易操作,但靈活性差一些。在這種方式中,你不需要去管理像isExecuting和isFinished這樣的狀態屬性,可以簡單認為當main返回時操作就完成了。

如果你想有更多控制權以及想在操作中執行非同步任務,你可以重寫start方法:

注意在這種方式中,你必須手動管理操作的狀態。為了讓操作佇列能捕捉到這些狀態的變化,狀態屬性要定義成與KVO相容。所以,確保傳送正確的KVO訊息,而不是通過預設的訪問方法去設定他們的值。

操作佇列支援取消機制,故你進行操作時需要例行檢查isCancelled屬性以判斷是否繼續執行。

定義好了你的操作類(NSOperation操作類的子類)後,往佇列裡新增操作就非常簡單了:

另外,你也可以往操作佇列中新增程式碼塊。這非常簡單,例如:你想在主佇列中排程一個一次性任務:

雖然程式碼塊的方式可以很放方便地在佇列中安排任務,但定義NSOperation操作類的子類的方式非常有助於除錯。如果你重寫了操作類的description方法,你可以很容易地識別出安排在某個佇列中的全部操作。

除了用佇列安排operation或程式碼塊這些基本操作外,operation queue還提供了一些GCD難以做好的功能。例如,你可以通過設定maxConcurrentOperationCount屬性輕鬆地控制一個queue中有多少個operation能被同時執行,設為1的時候變成一個序列佇列,可用於實現隔離的目的。

另一個方便的功能是根據佇列中operation的優先順序對其進行排序,這不同於GCD的佇列優先順序,它只會影響到一個佇列中所有operation的執行順序。如果你需要對超過那五個標準優先順序的操作有更多的控制權,你可以像這樣指定他們之間的依賴關係:

這段簡單的程式碼保證了operation1和operation2都將在intermediateOperation之前執行。操作間的依賴關係是非常強大的機制,可用來指定一種良好的執行順序。你能建立一個操作組,讓它們在依賴它們的操作前被執行,或者在併發佇列中順序執行操作。

從本質上說,operation queue的效能稍遜於GCD,但在大多數情況下這可以忽略不計,operation queue是併發程式設計的首選。

 

Run Loop

嚴格來說,run loop並不是一種(像GCD或operation queue那樣的)併發機制,因為它們不能併發執行任務。然而,run loop在主dispatch/operation queue 中直接配合任務的執行,並且他們提供一種非同步執行程式碼的機制。

Run loop比operation queue或GCD易用得多,因為你既不需要處理併發的複雜性又能讓任務非同步執行。

一個run loop總是和一個具體的執行緒繫結。與主執行緒相關聯的主run loop在Cocoa和CocoaTouch應用中起著關鍵作用,因為它負責處理UI 事件、定時器,以及其他核心事件。每當使用定時器、使用NSURLConnection或呼叫performSelector:withObject:afterDelay:時,run loop就在後臺執行以執行非同步任務。

無論何時使用基於run loop的函式,都要牢記住run loop可以在不同模式下執行。每種模式都定義了一組run loop將響應的事件。在主run loop中臨時地將某些任務的優先順序提到其他任務之上是一種聰明的做法。

一個典型的例子是iOS中的滾動(scrolling)。當你滾動時,run loop不執行在預設模式下,因此,它將不響應一些事件,例如你之前定義的定時器。一旦滾動停止,run loop 又回到預設模式,在這種模式中的事件又能被響應了。如果你希望定時器在滾動時也能被觸發,你需要將它加到NSRunLoopCommonModes模式下的run loop中。

主執行緒總會有一個主run loop,但其它執行緒就沒有預設的run loop了。你也可以為其它執行緒設定一個run loop,但常常不需要這樣做。大多數時候用主run loop更簡單些。如果你想執行一些繁重的任務,又不想在主執行緒執行,你可以在你的程式碼被主run loop 呼叫後將它分派到另一個queue上。相關內容,可以看看Chris寫的《common background practices》這篇文章。

如果你真的需要在非主執行緒上建立run loop,別忘了為他新增一個input source。如果run loop 沒有配置input source,任何執行它的嘗試都會馬上退出。

 

併發程式設計的挑戰

併發程式設計伴隨著很多陷阱。除了最基本的操作外,一旦你嘗試做其它複雜些的事情,就難以看清併發執行的多個任務之間互動協作的各種狀態。問題往往以不確定的方式出現,加大了併發程式設計的除錯難度。

關於併發程式設計的難以預見的行為,有個著名的例子:1995年,NASA(美國國家宇航局)向火星發射“探險者號”探測器。然而才剛剛成功登入我們這顆相鄰的紅色星球不久,任務就戛然而止。火星探測器莫名其妙地不斷重啟——這是一種叫做“優先順序反轉”的現象導致的,即:低優先順序的執行緒阻塞了高優先順序執行緒。稍後我們將會對這件事有更多的介紹,這裡舉這個例子主要是為了說明即使擁有豐富的資源和大量的天才,併發性仍然可能處處為難你。

 

資源共享

併發程式設計許多問題的根源是通過多執行緒對共享資源進行訪問。資源可以是:某個屬性或物件、記憶體、網路裝置、檔案,等等。在多執行緒間共享的任何東西都有可能引起衝突,你需要採取安全措施來避免這類衝突發生。

為了說明這個問題,我們來看個簡單的例子:將一個整數資源用作計數器。假設有2條併發執行的執行緒,A和B,兩條執行緒同時想增加這個計數器的值。問題在於:你用C或Objective-C寫的一條語句對於CPU來說並非只有一條機器指令,這裡要增加這個計數器,首先需要從記憶體讀取它當前的值,然後增加1,最後寫回記憶體。

讓我們來想象一下當兩條執行緒同時試圖執行這個操作時可能引發的問題。例如,執行緒A和執行緒B都想從記憶體讀取這個計數器的值;假設這個值是17。然後執行緒A將計數器加1並將結果18寫回記憶體。同時,執行緒B緊接著也將計數器加1並將結果18寫回記憶體。這時這個計數器資料就衝突了:從17加了兩次1,值卻是18.

race-condition@2x

這個問題叫做“資源競爭”,當有多條執行緒去訪問共享資源時,如果不能保證在一條執行緒完成對共享資源的操作後另一條執行緒才去訪問該資源,就會發生這種問題。如果你不僅僅是往記憶體中寫一個簡單的整數,而是更復雜的資料結構,甚至有可能出現這種情況:在你寫到一半的時候,另一條執行緒試圖從記憶體中讀取它的值,然後就讀到了半新半舊或未初始化的資料。為防止這種情況發生,多執行緒需要通過互斥的方式去訪問共享資源。

事實上,情況會比這個還要複雜得多,因為現代CPU為了優化效能會改變對記憶體資料的讀寫順序(亂序操作)。

 

互斥

互斥訪問指的是:一次只有一個執行緒能對某個資源進行訪問。為做到這點,想訪問某個資源的每一條執行緒都需要請求一個該資源的互斥鎖,完成操作後,釋放這個鎖,然後其它執行緒才能訪問該資源。

locking@2x

除了保證對資源的互斥訪問,鎖還必須處理由亂序操作引起的問題。如果你不能保證CPU訪問記憶體的順序與你程式指令的順序一致,那僅僅是保證互斥訪問還不夠。為了解決這個由CPU優化策略引起的副作用,需要用到記憶體屏障。設定記憶體屏障保證了沒有亂序操作可以越過屏障。

當然,互斥鎖本身的實現不能存在資源競爭。這可不是簡單的小事,在現代CPU上需要使用特殊指令。你可以在Daniel的文章《low-level concurrency techniques》中瞭解到更多關於原子操作的知識。

Objective-C在語言級別上就提供了對屬性的鎖支援,只要將它們宣告為atomic(原子的)即可。事實上,有些屬性甚至預設就是原子的。將屬性宣告為原子的意味著每次訪問他們都會有隱含的加鎖/解鎖操作。看來,將所有的屬性都宣告為原子的似乎是萬無一失的做法,但,鎖操作是要付出代價的。

得到資源鎖總是伴隨著效能上的代價。得到和釋放鎖的操作本身不能存在資源競爭,這在多核系統上不是件小事。並且,一個執行緒在請求獲取一個鎖時,可能需要等待,因為其它執行緒可能已經佔用這個鎖。在這種情況下,該執行緒將進入休眠狀態,並在其它執行緒釋放該鎖時需要被喚醒。所有這些操作都是要消耗資源並且複雜的。

鎖有不同型別。一些鎖在沒有“鎖競爭”時不耗資源,但在競爭情況下則表現不佳。另外一些鎖在正常情況下顯得比較昂貴(耗資源),但在“鎖競爭”情況下,卻能有效控制資源的消耗(鎖競爭指的是這樣的情況:有一條或多條執行緒試圖獲取一個已被佔用的鎖)。

這裡需要做下權衡:獲取和釋放鎖會帶來代價(鎖開銷),因此你需要確保不會不斷地進入和離開臨界區(例如:請求和釋放鎖);與此同時,如果你請求的鎖管了太大塊的程式碼(譯者注:這樣臨界區程式碼執行完就需要較長時間),又會增加鎖競爭的風險而使得其它執行緒常常因為等待鎖而不能繼續工作。這不是件容易取捨的事。

我們常常能看到這樣一種現象:本應併發執行的程式碼,卻由於共享資源鎖設定方式的原因,只有一條執行緒在執行。在多核CPU上,預見你的程式碼將以怎樣的方式被排程常常有重要意義。你可以使用Instrument的CPU 策略檢視來了解你是否有效利用了可用的CPU資源。

 

死鎖

互斥鎖解決了資源競爭的問題,但不幸的是同時又(在現有問題中)引入了一個新的問題:死鎖。死鎖發生在多條執行緒相互等待從而被阻塞的情況下。

dead-lock@2x

請思考下面這段交換兩個變數的值的程式碼:

大多數情況下,這段程式碼執行良好。但當兩條執行緒同時呼叫它們並且傳入的形參剛好是順序相反的兩個變數時:

程式就會因為死鎖而結束。執行緒1獲取了X的鎖,執行緒2獲取了Y的鎖。現在他們都要等到另外一個鎖,但又將永遠都無法獲取到。

同樣,你線上程間共享越多資源、使用越多的鎖,你陷入死鎖的風險就越大。這也是要儘量使操作簡單且線上程間共享盡量少的資源的另一個原因。建議閱讀《low-level concurrency APIs》這篇文章的“doing things asynchronously” 這一段。

 

飢渴(譯註:“寫飢渴”)

就在你認為要考慮的問題已經夠多的時候,又有一個新問題半路冒出來。鎖住共享資源會導致讀-寫問題。大多數情況下,把對資源的讀訪問限制為一次只允許一條執行緒訪問太過浪費。因此,只要對某個資源沒有加“寫”鎖,都允許多條執行緒可以共用一個“讀”鎖(譯者注:即在有“讀”鎖未釋放時又加上新的“讀”鎖)。(然而,)這種情況下,如果有一條執行緒等待獲得“寫”鎖,而在等待過程中“讀”鎖不斷增加(譯者注:即不斷有執行緒申請讀該資源從而在該資源上加上“讀”鎖;而“讀”鎖不釋放,“寫”鎖就無法加上去),那麼該執行緒就會陷入“寫飢渴”。

為解決這個問題,需要比簡單的讀/寫鎖更好的解決方案,例如:賦予寫操作優先權,或使用“讀-拷貝-更新”演算法。Daniel在他的《low-level concurrency techniques》這篇文章中介紹了怎樣利用GCD實現一種“多讀/單寫”的模式,這種模式可避免寫飢渴問題。

 

優先順序反轉

我們以NASA的探索者號火箭探測器遭受併發問題的例子來作為本節的開頭。現在我們來更深入地瞭解下,為什麼探索者號會失敗,為什麼你的應用程式也會遭受同樣的問題,這個問題叫做:“優先順序反轉”。

優先順序反轉是指這樣一種情況:優先順序較低的任務阻止了優先順序較高的任務的執行,造成了事實上的任務優先順序倒置。由於GCD公開了具有不同優先順序的後臺佇列,其中甚至包括I/O佇列,很適合用來了解優先順序反轉的可能性。

問題發生在你讓高優先順序和低優先順序的任務共享資源的時候。當低優先順序的任務獲取了某個公共資源的鎖,它本應該很快完成任務然後釋放鎖,好讓高優先順序的任務可以獲取該資源繼續執行,且沒有明顯延時。然而,因為當低優先順序的任務佔有鎖時高優先順序任務會被阻塞,這就給了中等優先順序(優先順序介於此處描述的“低優先順序”和“高優先順序”之間)的任務執行的機會,由於中優先順序的任務此時是所有可執行的任務中優先順序最高的,它會搶佔低優先順序任務的資源。這樣一來,中優先順序的任務妨礙了低優先順序的任務釋放鎖,從而其優先順序在事實上超過了還在等待的高優先順序的任務。

priority-inversion@2x

在你的程式碼中,事情可能不會像火星探測器不斷重啟那樣富有戲劇性,因為優先順序反轉通常以不太嚴重的形式發生。

一般情況下,不要使用不同的優先順序。程式常常都是因為高優先順序的程式碼要等待低優先順序程式碼結束以被執行而意外終止。當你使用GCD時,請總是使用預設優先順序的佇列(直接使用,或作為目標佇列)。如果你使用了其它優先順序,十有八九,事情只會變得更糟。

通過這個得到的教訓是:使用具有不同優先順序的多個佇列理論上看起來很容易,但它給併發程式設計增加了複雜性和不可預測性。你以後如果遇到諸如高優先順序的任務莫名其妙被阻斷的奇怪問題,可能就會想起這篇文章以及優先順序反轉的問題,這可是連NASA的工程師都遭遇過的問題。

 

總結

本文希望說明併發程式設計的複雜性和它存在的問題,無論相關API看起來有多麼簡單,由此產生的問題都會變得難以觀測,除錯這類問題也常常非常困難。

另一方面,併發程式設計又是有效利用現代多核CPU計算能力的強有力的工具。關鍵就在於要讓你的併發模型儘可能簡單,這樣你就能限制需要的鎖的數量。

我們推薦一個安全的模式:在主執行緒上提取所有你想要操作的資料,然後用一個operation queue在後臺執行實際的工作,最後回到主queue去處理從後臺操作獲取的結果。這樣,你就不需要自己去加任何的鎖,大大降低了出錯的概率。

相關文章