多年來,計算機的最高性能在很大程度上受限于計算機內核中單個微處理器的運算速度。然而,隨著單個處理器的運算速度開始達到其實際限制,芯片制造商開始使用多核設計來使計算機有機會同時執行多個任務。雖然OS X無論何時都在利用這些內核執行與系統相關的任務,但我們自己的應用程序也可以通過線程來利用這些內核。
什么是線程?
線程是在應用程序內部實現多個執行路徑的相對輕量級的方式。在系統層面,程序并排運行,系統根據程序的需求和其他程序的需求為每個程序分配執行時間。但是在每個程序中,存在一個或多個可用于同時或以幾乎同時的方式執行不同的任務的執行線程。系統本身實際上管理著這些執行線程,并調度它們到可用內核上運行。同時,還能根據需要提前中斷它們以允許其他線程運行。
從技術角度講,線程是管理代碼執行所需的內核級和應用級數據結構的組合。內核級數據結構協調事件到達線程的調度和在某個可用內核上的線程的搶先調度。應用級數據結構包含用于存儲函數調用的調用堆棧和應用程序需要用于管理和操作線程的屬性和狀態的結構。
在非并發的應用程序中,只有一個執行線程。該線程以應用程序的主例程開始和結束,并逐個分支到不同的方法或函數中,以實現應用程序的整體行為。相比之下,支持并發的應用程序從一個線程開始,并根據需要添加更多線程來創建額外的執行路徑。每個新路徑都有自己的獨立于應用程序主例程中的代碼運行的自定義啟動例程。在應用程序中有多個線程提供了兩個非常重要的潛在優勢:
- 多個線程可以提高應用程序的感知響應能力。
- 多個線程可以提高應用程序在多核系統上的實時性能。
如果應用程序只有一個線程,那么該線程必須做所有的事情。其必須響應事件,更新應用程序的窗口,并執行實現應用程序行為所需的所有計算。只有一個線程的問題是它一次只能做一件事情。如果一個計算需要很長時間才能完成,那么當我們的代碼忙于計算它所需的值時,應用程序會停止響應用戶事件和更新其窗口。如果這種行為持續時間足夠長,用戶可能會認為我們的應用程序被掛起了并試圖強行退出它。但是,如果將自定義計算移至單獨的線程,則應用程序的主線程可以更及時地自由響應用戶交互。
隨著多核計算機的普及,線程提供了一種提高某些類型應用程序性能的方法。執行不同任務的線程可以在不同的處理器內核上同時執行,從而使應用程序可以在給定的時間內執行更多的工作。
當然,線程并不是解決應用程序性能問題的萬能藥物。線程提供的益處也會帶來潛在的問題。在應用程序中執行多個路徑可能會增加代碼的復雜度。每個線程必須與其他線程協調行動,以防止它破壞應用程序的狀態信息。由于單個應用程序中的線程共享相同的內存空間,所以它們可以訪問所有相同的數據結構。如果兩個線程試圖同時操作相同的數據結構,則其中一個線程可能會以破壞數據結構的方式覆蓋另一個線程的更改。即使有適當的保護措施,我們仍然需要對編譯器優化保持注意,因為編譯器優化會在我們的代碼中引入細微的錯誤。
線程術語
在討論線程及其支持技術之前,有必要定義一些基本術語。
如果你熟悉UNIX系統,則可能會發現本文檔中的術語“任務”的使用有所不同。在UNIX系統中,有時使用術語“任務”來指代正在運行的進程。
本文檔采用以下術語:
- 術語“線程”用于指代單獨的代碼執行路徑。
- 術語“進程”用于指代正在運行的可執行文件,它可以包含多個線程。
- 術語“任務”用于指代需要執行的抽象工作概念。
線程的替代方案
自己創建線程的一個問題是它們會給代碼添加不確定性。線程是一種相對較底層且復雜的支持應用程序并發的方式。如果不完全了解設計的含義,則可能會遇到同步或校時問題,其嚴重程度可能會從細微的行為變化到應用程序崩潰以及用戶數據的損壞。
另一個要考慮的因素是是否需要線程或并發。線程解決了如何在同一進程中同時執行多個代碼路徑的具體問題。但是在有些情況下,并不能保證并發執行我們需要的工作。線程會在內存消耗和CPU時間方面為進程帶來了巨大的開銷。我們可能會發現這種開銷對于預期的任務來說太大了,或者其他選項更容易實現。
下表列出了線程的一些替代方案。
Technology | Description |
---|---|
Operation objects | 在OS X v10.5中引入的操作對象是通常在輔助線程上執行的任務的封裝器。這個封裝器隱藏了執行任務的線程管理方面,讓我們可以自由地專注于任務本身。通常將操作對象與一個操作隊列對象結合使用,操作隊列對象實際上管理一個或多個線程上的操作對象的執行。 |
Grand Central Dispatch (GCD) | 在OS X v10.6中引入的Grand Central Dispatch是線程的另一種替代方案,可以讓我們專注于需要執行的任務而不是線程管理。使用GCD,我們可以定義要執行的任務并將其添加到工作隊列中,該工作隊列可以在適當的線程上處理我們的任務計劃。工作隊列會考慮可用內核的數量和當前負載,以便比使用線程更有效地執行任務。 |
Idle-time notifications | 對于相對較短且優先級很低的任務,空閑時間通知讓我們可以在應用程序不太忙時執行任務。Cocoa使用NSNotificationQueue 對象為空閑時間通知提供支持。要請求空閑時間通知,請使用NSPostWhenIdle 選項向默認NSNotificationQueue 對象發布通知。隊列會延遲通知對象的傳遞,直到run loop變為空閑狀態。 |
Asynchronous functions | 系統接口包含許多為我們提供自動并發性的異步功能。這些API可以使用系統守護進程和進程或者創建自定義線程來執行任務并將結果返回給我們。在設計應用程序時,尋找提供異步行為的函數,并考慮使用它們而不是在自定義線程上使用等效的同步函數。 |
Timers | 可以在應用程序的主線程上使用定時器來執行相對于使用線程而言過于微不足道的定期任務,但是需要定期維護。 |
Separate processes | 盡管比線程更加重量級,但在任務僅與應用程序切向相關的情況下,創建單獨的進程可能很有用。如果任務需要大量內存或必須使用root權限執行,則可以使用進程。例如,我們可以使用64位服務器進程來計算大型數據集,而我們的32位應用程序會將結果顯示給用戶。 |
注意:使用
fork
函數啟動單獨的進程時,必須使用與調用exec
函數或類似函數相同的方式調用fork
函數。依賴于Core Foundation,Cocoa或者Core Data框架(顯式或隱式)的應用程序必須對exec
函數進行后續調用,否則這些框架的行為可能會不正確。
線程支持
OS X和iOS系統提供了多種技術來在我們的應用程序中創建線程,并且還為管理和同步需要在這些線程上完成的工作提供支持。以下各節介紹了在OS X和iOS中使用線程時需要注意的一些關鍵技術。
線程組件
盡管線程的底層實現機制是Mach線程,但很少(如果有的話)在Mach層面上使用線程。相反,我們通常使用更方便的POSIX API或其衍生工具之一。Mach實現確實提供了所有線程的基本特征,包括搶先執行模型和調度線程使它們彼此獨立的能力。
下表列出了可以在應用程序中使用的線程技術。
Technology | Description |
---|---|
Cocoa threads | Cocoa使用NSThread 類實現線程。Cocoa也在NSObject 類中提供了方法來生成新線程并在已經運行的線程上執行代碼。 |
POSIX threads | POSIX線程提供了基于C語言的接口來創建線程。如果我們不是在編寫一個Cocoa應用程序,則這是創建線程的最佳選擇。POSIX接口使用起來相對簡單,并為配置線程提供了足夠的靈活性。 |
Multiprocessing Services |
Multiprocessing Services(多處理服務)是傳統的基于C語言的接口,其被從舊版本Mac OS系統中過渡來的應用程序所使用。這項技術僅適用于OS X,應該避免在任何新的開發中使用它。相反,應該使用NSThread 類或者POSIX線程。 |
啟動線程后,線程將以三種主要狀態中的一種來運行:運行中,準備就緒或者阻塞。如果一個線程當前沒有運行,那么它可能處于阻塞狀態并等待輸入,或者它已準備好運行,但尚未安排執行。線程持續在這些狀態之間來回切換,直到它最終退出并切換到終止狀態。
當創建一個新的線程時,必須為該線程指定一個入口函數(或者Cocoa線程的入口方法)。這個入口函數構成了我們想要在線程上運行的代碼。當函數返回時,或者當我們明確終止線程時,該線程會永久停止并被系統回收。由于線程的創建在內存和時間方面相當昂貴,所有建議在入口函數中執行大量工作或者設置run loop以允許執行重復性工作。
Run Loop
run loop(運行循環)是用于管理事件異步到達線程的基礎架構的一部分。run loop通過監聽線程的一個或者多個事件源來工作。當事件到達時,系統會喚醒線程并調度事件到run loop,run loop再調度這些事件給我們指定的處理程序。如果沒有事件存在,也沒有事件準備好被處理,則run loop將線程置于休眠狀態。
不需要在創建任何線程時都使用run loop,但使用run loop可以為用戶提供更好的體驗。run loop使得創建使用最少量資源的長期存活線程成為可能。因為在沒有事件傳入時,run loop會將線程置于休眠狀態。所以它不需要執行浪費CPU周期的輪詢,并能防止處理器本身進入休眠狀態來節省功耗。
要配置run loop,只需要啟動線程,獲取對run loop對象的引用,然后安裝事件處理程序并告知run loop開始運行。OS X提供的基礎架構自動幫我們處理主線程run loop的配置。如果打算創建長期存活的輔助線程,則必須自行為這些線程配置run loop。
同步工具
線程編程的一個風險是多線程之間的資源爭奪。如果多個線程同時試圖使用或修改相同的資源,則可能會出現問題。緩解問題的一種方法是完全避免共享資源,并確保每個線程都操作自己獨特的資源集合。但是當保持完全獨立的資源不能滿足需求時,可以使用鎖,條件,原子操作和其他技術來同步對資源的訪問。
鎖為一次只能由一個線程執行的代碼提供了蠻力形式的保護。最常見的鎖是互斥鎖。當一個線程試圖獲取另一個線程當前擁有的互斥鎖時,該線程會被阻塞,直到另一個線程釋放該互斥鎖。一些系統框架為互斥鎖提供了支持,盡管它們都基于相同的基礎技術。另外,Cocoa提供了互斥鎖的幾種變體來支持不同類型的行為,例如遞歸。
除了鎖之外,系統還為條件(condition)提供支持,以確保在應用程序中對任務進行正確排序。條件充當守門人,阻塞指定的線程,知道它所代表的條件變為ture
。當這種情況發生時,條件釋放線程并運行其繼續運行。POSIX層和Foundation框架都為條件提供了直接支持。(如果使用操作對象,則可以配置操作對象之間的依賴關系來對任務的執行排序,這與條件提供的行為非常相似。)
雖然鎖和條件在并發設計中非常常見,但原子操作是保護和同步數據訪問的另一種方式。當對標量數據類型進行數學或邏輯運算時,原子操作提供了一種輕量級的替代鎖的方案。原子操作使用特殊的硬件指令來確保在其他線程有機會訪問變量之前完成對該變量的修改。
線程間通信
盡管一個好的設計可以最大限度地減少所需的通信次數,但是在某些時候,線程之間的通信是必要的。線程可能需要處理新的工作請求或者將工作進度報告給應用程序的主線程。在這些情況下,我們需要一種從一個線程向另一個線程獲取信息的方法。幸運的是,線程共享相同進程空間的事實意味著我們有很多通信選項。
線程之間的通信方式有許多種,每種方式都有自己的優點和缺點。下表列出了可以在OS X中使用的最常用的通信機制(除了消息隊列和Cocoa分布式對象,其他技術在iOS中也可用。),此表中的技術按照復雜性增加的順序列出。
機制 | 描述 |
---|---|
直接傳遞消息 | Cocoa應用程序支持直接在其他線程上執行方法選擇器的功能。這個能力意味著一個線程實質上可以在任何其他線程上執行一個方法。由于它們是在目標線程的上下文中執行的,所以以這種方式發送的消息會自動在該線程上序列化。 |
全局變量,共享內存和對象 | 在兩個線程之間傳遞信息的另一種簡單方法是使用全局變量,共享對象或共享內存塊。雖然共享變量很快很簡單,但它們比直接傳遞消息更脆弱。共享變量必須用鎖或其他同步機制來小心保護,以確保代碼的正確性。不這樣做可能會導致競爭狀況,數據損壞或者崩潰。 |
條件 | 條件是一個同步工具,可以使用它來控制線程何時執行代碼的特定部分??梢詫l件視為守門員,讓線程只有在符合條件時才能運行。 |
Run loop sources | 自定義run loop source是為了在線程上接收專用消息而設置的。因為它們是事件驅動的,所以當沒有任何事件可以執行時,run loop source會將線程置于休眠狀態,這可以提高線程的效率。 |
Ports and sockets | 基于端口的通信是兩個線程之間通信的更復雜的方式,但它是一種非??煽康募夹g。更重要的是,端口和套接字可用于與外部實體(如其他進程和服務)進行通信。為了提高效率,端口是使用run loop source實現的,所以當沒有數據在端口上等待時,線程會休眠。 |
消息隊列 | 傳統的多處理服務定義了用于管理傳入和傳出數據的先進先出(FIFO)的隊列抽象概念。盡管消息隊列簡單方便,但并不像其他通信技術那樣高效。 |
Cocoa分布式對象 | 分布式對象是一種Cocoa技術,提供基于端口通信的高級實現。盡管有可能使用這種技術進行線程間通信,但是由于其產生的開銷很大,所以并不鼓勵這樣做。分布式對象更適用于與其他進程進行通信,其中進程之間的開銷已經很高。 |
設計技巧
避免明確地創建線程
手動編寫線程創建代碼非常繁瑣而且可能容易出錯,應該盡量避免這樣做。OS X和iOS其他API為并發提供隱式支持??梢钥紤]使用異步API,GCD或操作對象來完成工作,而不是自己創建線程。這些技術在幕后做與線程相關的工作,并保證正確執行。另外,像GCD和操作對象這樣的技術可以根據當前系統負載調整當前活躍線程的數量,從而比我們自己的代碼更高效地管理線程。
合理地保持我們的線程處于忙碌狀態
如果決定手動創建和管理線程,請記住線程會占用寶貴的系統資源。應該盡最大努力確保分配給線程的任何任務是長期存活的和能工作的。同時,不應該害怕終止那些大多數時間處于閑置狀態的線程。線程會占用大量的內存,因此釋放一個空閑線程不僅有助于減少應用程序的內存占用量,還可以釋放更多物理內存供其他系統進程使用。
提示:在開始終止空閑線程之前,應該始終記錄應用程序當前性能的一組基礎測量結果。在嘗試更改之后,請進行其他測量以驗證這些更改是否實際上改善了性能,而不是損害了性能。
避免共享數據結構
避免與線程相關的資源沖突的最簡單和最容易的方法是為程序中的每個線程提供它所需的任何數據的副本。當我們最小化線程間的通信和資源競爭時,并行代碼的工作效果最佳。
創建多線程應用程序非常困難。即使我們非常小心并且在代碼中在所有正確的時刻鎖定了共享的數據結構,我們的代碼仍然可能在語義上是不安全的。例如,如果希望共享數據結構按照特定順序修改,我們的代碼可能會遇到問題。將代碼更改為基于交易的模型以進行補償隨后可能讓具有多個線程的性能優勢消失。首先消除資源爭奪會讓設計更加簡單并且性能優異。
線程和我們的用戶界面
如果應用程序具有圖形用戶界面,則建議從應用程序的主線程接收與用戶相關的事件并啟動界面更新。這種途徑有助于避免與處理用戶事件和繪制窗口內容相關的同步問題。一些框架,例如Cocoa,通常需要這種行為,但即使對于那些不這樣做的行為,在主線程上保持這種行為也有簡化用于管理用戶界面的邏輯的優點。
有一些值得注意的例外是從其他線程執行圖形操作是有利的。例如,可以使用輔助線程來創建和處理圖像并執行其他圖像相關的計算。使用輔助線程進行這些操作可以大大提高性能。如果不確定特定的圖形操作,請在主線程執行此操作。
在退出時知道線程行為
一個進程運行直到所有非分離線程退出。默認情況下,只有應用程序的主線程是非分離的,但是也可以創建其他的非分離線程。當用戶退出應用程序時,通常被認為是適當的行為是立即終止所有分離線程,因為分離線程完成的工作被認為是可選的。然而,如果我們的應用程序使用后臺線程將數據保存到磁盤或者執行其他關鍵工作,則可能需要創建非分離線程,以防止應用程序退出時丟失數據。
創建非分離(也稱為可連接)線程需要額外的工作。由于大多數高級線程技術在默認情況下不會創建可連接線程,所以我們可能必須使用POSIX API來創建線程。另外,我們必須添加代碼到應用程序的主線程,以便主線程最終退出時將其與非分離線程連接起來。
如果我們正在編寫一個Cocoa應用程序,則也可以使用applicationShouldTerminate:
代理方法來延遲應用程序的終止直到以后某個時間或者完全取消延遲。當要延遲應用程序的終止時,應用程序需要等待直到任何臨界線程完成其任務,然后調用replyToApplicationShouldTerminate:
方法。
處理異常
當拋出一個異常時,異常處理機制依賴于當前的調用堆棧來執行任何必要的清理。因為每個線程都有自己的調用堆棧,所以每個線程都負責捕獲它自己的異常。當擁有的進程已經終止,在主線程和輔助線程中都是無法捕獲到異常的。我們不能將一個未捕獲的異常拋出到不同的線程進行處理。
如果需要通知另一個線程(例如主線程)當前線程中的異常情況,則應該捕獲該異常并簡單地向另一個線程發送消息表明發生了什么。取決于我們的模型以及我們試圖執行的操作,捕獲異常的線程可以繼續處理(如果可能的話)、等待指令或者干脆退出。
注意:在Cocoa中,
NSException
對象是一個自包含的對象,一旦它被捕獲,它就可以從一個線程傳遞到另一個線程。
在某些情況下,可能會為我們自動創建異常處理程序。例如,Objective-C中的@synchronized
指令包含一個隱式異常處理程序。
干凈地終止我們的線程
讓線程自然退出的最好方式是讓其到達主入口點工作的末尾。雖然有函數能夠立即終止線程,但這些函數只能作為最后的手段使用。在線程到達其自然終點之前終止它會阻止線程清理自身。如果線程已經分配內存、打開文件或者獲取其他類型的資源,則我們的代碼可能無法回收這些資源,從而導致內存泄露或者其他潛在問題。
庫(Library)中的線程安全
雖然應用程序開發者可以控制應用程序是否使用多個線程執行,但庫開發人員卻不行。開發庫時,我們必須假定調用庫的應用程序是多線程的或者可以隨時切換為多線程的。因此,我們應該始終為代碼的臨界區使用鎖。
對于庫開發人員來說,僅在應用程序變為多線程時才創建鎖是不明智的。如果我們需要在某個時刻鎖定我們的代碼,請在使用庫時盡早創建鎖對象,最好在某個顯示調用中初始化庫。雖然也可以使用靜態庫初始化函數來創建此類鎖,但只有在沒有其他方式時才嘗試這樣做。初始化函數的執行會增加加載庫所需的時間,并可能對性能產生負面影響。
注意:始終記住鎖定和解鎖庫中的互斥鎖的調用要保持平衡,還應該記住要鎖定庫數據結構,而不是依賴調用代碼來提供線程安全的環境。
如果我們正在開發一個Cocoa庫并希望應用程序在變為多線程時能夠收到通知,可以為NSWillBecomeMultiThreadedNotification
通知注冊一個觀察者。但不應該依賴收到此通知,因為在我們的庫代碼被調用之前,可能已經發送了此通知。