並發所描述的概念就是同時運行多個任務。這些任務可能是以在單核 CPU 上分時(時間共享)的形式同時運行,也可能是在多核 CPU 上以真正的並行方式來運行。
OS X 和 iOS 提供了幾種不同的 API 來支持並發編程。每一個 API 都具有不同的功能和使用限制,這使它們適合不同的任務。同時,這些 API 處在不同的抽象層級上。我們有可能用其進行非常深入底層的操作,但是這也意味著背負起將任務進行良好處理的巨大責任。
實際上,並發編程是一個很有挑戰的主題,它有許多錯綜復雜的問題和陷阱。當開發者在使用類似 Grand Central Dispatch(GCD)或 NSOperationQueue 的 API 時,很容易遺忘這些問題和陷阱。本文首先對 OS X 和 iOS 中不同的並發編程 API 進行一些介紹,然後再深入了解並發編程中獨立於與你所使用的特定 API 的一些內在挑戰。
OS X 和 iOS 中的並發編程
蘋果的移動和桌面操作系統中提供了相同的並發編程API。 本文會介紹 pthread 、 NSThread 、GCD 、NSOperationQueue,以及 NSRunLoop。實際上把 run loop 也列在其中是有點奇怪,因為它並不能實現真正的並行,不過因為它與並發編程有莫大的關系,因此值得我們進行一些深入了解。
由於高層 API 是基於底層 API 構建的,所以我們首先將從底層的 API 開始介紹,然後逐步擴展到高層 API。不過在具體編程中,選擇 API 的順序剛好相反:因為大多數情況下,選擇高層的 API 不僅可以完成底層 API 能完成的任務,而且能夠讓並發模型變得簡單。
線程
線程(thread)是組成進程的子單元,操作系統的調度器可以對線程進行單獨的調度。實際上,所有的並發編程 API 都是構建於線程之上的 —— 包括 GCD 和操作隊列(operation queues)。
多線程可以在單核 CPU 上同時(或者至少看作同時)運行。操作系統將小的時間片分配給每一個線程,這樣就能夠讓用戶感覺到有多個任務在同時進行。如果 CPU 是多核的,那麼線程就可以真正的以並發方式被執行,從而減少了完成某項操作所需要的總時間。
你可以使用 Instruments 中的 CPU strategy view 來得知你的代碼或者你在使用的框架代碼是如何在多核 CPU 中調度執行的。
需要重點關注的是,你無法控制你的代碼在什麼地方以及什麼時候被調度,以及無法控制執行多長時間後將被暫停,以便輪換執行別的任務。這種線程調度是非常強大的一種技術,但是也非常復雜,我們稍後研究。
先把線程調度的復雜情況放一邊,開發者可以使用 POSIX 線程 API,或者 Objective-C 中提供的對該 API 的封裝 NSThread,來創建自己的線程。
直接使用線程可能會引發的一個問題是,如果你的代碼和所基於的框架代碼都創建自己的線程時,那麼活動的線程數量有可能以指數級增長。這在大型工程中是一個常見問題。例如,在 8 核 CPU 中,你創建了 8 個線程來完全發揮 CPU 性能。然而在這些線程中你的代碼所調用的框架代碼也做了同樣事情(因為它並不知道你已經創建的這些線程),這樣會很快產生成成百上千的線程。代碼的每個部分自身都沒有問題,然而最後卻還是導致了問題。使用線程並不是沒有代價的,每個線程都會消耗一些內存和內核資源。
接下來,我們將介紹兩個基於隊列的並發編程 API :GCD 和 operation queue 。它們通過集中管理一個被大家協同使用的線程池,來解決上面遇到的問題。
Grand Central Dispatch
為了讓開發者更加容易的使用設備上的多核CPU,蘋果在 OS X 10.6 和 iOS 4 中引入了 Grand Central Dispatch(GCD)。
通過 GCD,開發者不用再直接跟線程打交道了,只需要向隊列中添加代碼塊即可,GCD 在後端管理著一個線程池。GCD 不僅決定著你的代碼塊將在哪個線程被執行,它還根據可用的系統資源對這些線程進行管理。這樣可以將開發者從線程管理的工作中解放出來,通過集中的管理線程,來緩解大量線程被創建的問題。
GCD 帶來的另一個重要改變是,作為開發者可以將工作考慮為一個隊列,而不是一堆線程,這種並行的抽象模型更容易掌握和使用。
GCD 公開有 5 個不同的隊列:運行在主線程中的 main queue,3 個不同優先級的後台隊列,以及一個優先級更低的後台隊列(用於 I/O)。
另外,開發者可以創建自定義隊列:串行或者並行隊列。自定義隊列非常強大,在自定義隊列中被調度的所有 block 最終都將被放入到系統的全局隊列中和線程池中。
GCD queues
使用不同優先級的若干個隊列乍聽起來非常直接,不過,我們強烈建議,在絕大多數情況下使用默認的優先級隊列就可以了。如果執行的任務需要訪問一些共享的資源,那麼在不同優先級的隊列中調度這些任務很快就會造成不可預期的行為。這樣可能會引起程序的完全掛起,因為低優先級的任務阻塞了高優先級任務,使它不能被執行。更多相關內容,在本文的優先級反轉部分中會有介紹。
雖然 GCD 是一個低層級的 C API ,但是它使用起來非常的直接。不過這也容易使開發者忘記並發編程中的許多注意事項和陷阱。
Operation Queues
操作隊列(operation queue)是由 GCD 提供的一個隊列模型的 Cocoa 抽象。GCD 提供了更加底層的控制,而操作隊列則在 GCD 之上實現了一些方便的功能,這些功能對於 app 的開發者來說通常是最好最安全的選擇。
NSOperationQueue 有兩種不同類型的隊列:主隊列和自定義隊列。主隊列運行在主線程之上,而自定義隊列在後台執行。在兩種類型中,這些隊列所處理的任務都使用 NSOperation 的子類來表述。
你可以通過重寫 main 或者 start 方法 來定義自己的 operations 。前一種方法非常簡單,開發者不需要管理一些狀態屬性(例如 isExecuting 和 isFinished),當 main 方法返回的時候,這個 operation 就結束了。這種方式使用起來非常簡單,但是靈活性相對重寫 start 來說要少一些。雖然通過這種的方式在隊列中添加操作會非常方便,但是定義你自己的 NSOperation 子類會在調試時很有幫助。如果你重寫 operation 的description 方法,就可以很容易的標示出在某個隊列中當前被調度的所有操作 。
除了提供基本的調度操作或 block 外,操作隊列還提供了在 GCD 中不太容易處理好的特性的功能。例如,你可以通過 maxConcurrentOperationCount 屬性來控制一個特定隊列中可以有多少個操作參與並發執行。將其設置為 1 的話,你將得到一個串行隊列,這在以隔離為目的的時候會很有用。
另外還有一個方便的功能就是根據隊列中 operation 的優先級對其進行排序,這不同於 GCD 的隊列優先級,它只影響當前隊列中所有被調度的 operation 的執行先後。如果你需要進一步在除了 5 個標准的優先級以外對 operation 的執行順序進行控制的話,還可以在 operation 之間指定依賴關系,如下:
[intermediateOperation addDependency:operation1];
[intermediateOperation addDependency:operation2];
[finishedOperation addDependency:intermediateOperation];
這些簡單的代碼可以確保 operation1 和 operation2 在 intermediateOperation 之前執行,當然,也會在 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 中被調用後將工作分配給其他隊列。
如果你真需要在別的線程中添加一個 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 上使用特殊的指令。
從語言層面來說,在 Objective-C 中將屬性以 atomic 的形式來聲明,就能支持互斥鎖了。事實上在默認情況下,屬性就是 atomic 的。將一個屬性聲明為 atomic 表示每次訪問該屬性都會進行隱式的加鎖和解鎖操作。雖然最把穩的做法就是將所有的屬性都聲明為 atomic,但是加解鎖這也會付出一定的代價。
在資源上的加鎖會引發一定的性能代價。獲取鎖和釋放鎖的操作本身也需要沒有競態條件,這在多核系統中是很重要的。另外,在獲取鎖的時候,線程有時候需要等待,因為可能其它的線程已經獲取過資源的鎖了。這種情況下,線程會進入休眠狀態。當其它線程釋放掉相關資源的鎖時,休眠的線程會得到通知。所有這些相關操作都是非常昂貴且復雜的。
鎖也有不同的類型。當沒有競爭時,有些鎖在沒有鎖競爭的情況下性能很好,但是在有鎖的競爭情況下,性能就會大打折扣。另外一些鎖則在基本層面上就比較耗費資源,但是在競爭情況下,性能的惡化會沒那麼厲害。(鎖的競爭是這樣產生的:當一個或者多個線程嘗試獲取一個已經被別的線程獲取過了的鎖)。
在這裡有一個東西需要進行權衡:獲取和釋放鎖所是要帶來開銷的,因此你需要確保你不會頻繁地進入和退出臨界區段(比如獲取和釋放鎖)。同時,如果你獲取鎖之後要執行一大段代碼,這將帶來鎖競爭的風險:其它線程可能必須等待獲取資源鎖而無法工作。這並不是一項容易解決的任務。
我們經常能看到本來計劃並行運行的代碼,但實際上由於共享資源中配置了相關的鎖,所以同一時間只有一個線程是處於激活狀態的。對於你的代碼會如何在多核上運行的預測往往十分重要,你可以使用 Instrument 的 CPU strategy view 來檢查是否有效的利用了 CPU 的可用核數,進而得出更好的想法,以此來優化代碼。
死鎖
互斥鎖解決了競態條件的問題,但很不幸同時這也引入了一些其他問題,其中一個就是死鎖。當多個線程在相互等待著對方的結束時,就會發生死鎖,這時程序可能會被卡住。
死鎖
資源饑餓(Starvation)
當你認為已經足夠了解並發編程面臨的問題時,又出現了一個新的問題。鎖定的共享資源會引起讀寫問題。大多數情況下,限制資源一次只能有一個線程進行讀取訪問其實是非常浪費的。因此,在資源上沒有寫入鎖的時候,持有一個讀取鎖是被允許的。這種情況下,如果一個持有讀取鎖的線程在等待獲取寫入鎖的時候,其他希望讀取資源的線程則因為無法獲得這個讀取鎖而導致資源饑餓的發生。
優先級反轉
本節開頭介紹了美國宇航局發射的開拓者號火星探測器在火星上遇到的並發問題。現在我們就來看看為什麼開拓者號幾近失敗,以及為什麼有時候我們的程序也會遇到相同的問題,該死的優先級反轉。
優先級反轉是指程序在運行時低優先級的任務阻塞了高優先級的任務,有效的反轉了任務的優先級。由於 GCD 提供了擁有不同優先級的後台隊列,甚至包括一個 I/O 隊列,所以我們最好了解一下優先級反轉的可能性。
高優先級和低優先級的任務之間共享資源時,就可能發生優先級反轉。當低優先級的任務獲得了共享資源的鎖時,該任務應該迅速完成,並釋放掉鎖,這樣高優先級的任務就可以在沒有明顯延時的情況下繼續執行。然而高優先級任務會在低優先級的任務持有鎖的期間被阻塞。如果這時候有一個中優先級的任務(該任務不需要那個共享資源),那麼它就有可能會搶占低優先級任務而被執行,因為此時高優先級任務是被阻塞的,所以中優先級任務是目前所有可運行任務中優先級最高的。此時,中優先級任務就會阻塞著低優先級任務,導致低優先級任務不能釋放掉鎖,這也就會引起高優先級任務一直在等待鎖的釋放。
優先級反轉
在你的實際代碼中,可能不會像發生在火星的事情那樣戲劇性地不停重啟。遇到優先級反轉時,一般沒那麼嚴重。
解決這個問題的方法,通常就是不要使用不同的優先級。通常最後你都會以讓高優先級的代碼等待低優先級的代碼來解決問題。當你使用 GCD 時,總是使用默認的優先級隊列(直接使用,或者作為目標隊列)。如果你使用不同的優先級,很可能實際情況會讓事情變得更糟糕。
從中得到的教訓是,使用不同優先級的多個隊列聽起來雖然不錯,但畢竟是紙上談兵。它將讓本來就復雜的並行編程變得更加復雜和不可預見。如果你在編程中,遇到高優先級的任務突然沒理由地卡住了,可能你會想起本文,以及那個美國宇航局的工程師也遇到過的被稱為優先級反轉的問題。
總結
我們希望通過本文你能夠了解到並發編程帶來的復雜性和相關問題。並發編程中,無論是看起來多麼簡單的 API ,它們所能產生的問題會變得非常的難以觀測,而且要想調試這類問題往往也都是非常困難的。
但另一方面,並發實際上是一個非常棒的工具。它充分利用了現代多核 CPU 的強大計算能力。在開發中,關鍵的一點就是盡量讓並發模型保持簡單,這樣可以限制所需要的鎖的數量。
我們建議采納的安全模式是這樣的:從主線程中提取出要使用到的數據,並利用一個操作隊列在後台處理相關的數據,最後回到主隊列中來發送你在後台隊列中得到的結果。使用這種方式,你不需要自己做任何鎖操作,這也就大大減少了犯錯誤的幾率。