objc系列譯文(2.4):執行緒安全類的設計

Anson發表於2013-12-11

本文將側重於編寫執行緒安全類和使用Grand Central Displatch(GCD)時的實用的技巧、設計模式,以及反模式。

執行緒安全

Apple的框架

首先讓我們來看一下Apple的框架。一般情況下,除非提前宣告,否則大多數類預設不是執行緒安全的。一些是我們所期望的,但是另一些卻會相當有趣。

其中甚至有經驗的iOS/Mac開發人員常會犯的錯誤是在後臺執行緒中訪問部分UIKit/AppKit。最容易犯的錯誤是在後臺執行緒中對property賦值,比如圖片,因為他們的內容是在後臺從網路上獲取的。Apple的程式碼是效能優化過的,如果你從不同執行緒去改動property,它是不會警告你的。

例如圖片這種情況,一個常見的問題是你的改動會產生延遲。但是如果兩個執行緒同時設定圖片,很可能你的程式將直接崩潰,因為當前設定的圖片可能會被釋放兩次。由於這是和時機相關的,因此崩潰通常發生在客戶使用時,而並不是在開發過程中。

雖然沒有官方的工具來發現這樣的錯誤,但是有一些技巧可以避免這種錯誤發生。The UIKit Main Thread Guard是一小段程式碼,可以修補任何呼叫UIView的setNeedsLayout和setNeedsDisplay,以及在傳送呼叫之前檢查是否執行在主執行緒。由於這兩種方法被許多UIKit的setters方法呼叫(包括圖片),這將會捕獲許多執行緒相關的錯誤。雖然這個不使用私有API,但是我們不建議在產品程式中使用,而是最好在開發過程是使用。

UIKit非執行緒安全是Apple有意的設計決定。從效能方面來說執行緒安全沒有太多好處,它實際上會使很多事情變慢。而事實上UIKit和主執行緒捆綁使它很容易編寫併發程式和使用UIKit。你所需要做的就是確保總是在主執行緒上呼叫UIKit。

 

為什麼UIKit不是執行緒安全的?

像UIKit這樣大的框架上確保執行緒安全是一個重大的任務,會帶來巨大的成本。改變非原子property為原子property只是所需要改變的一小部分。通常你想要一次改變多個property,然後才能看到更改的結果。對於這一點,Apple不得不暴露一個方法,像CoreData的performBlock:和同步的方法performBlockAndWait:。如果你考慮大多數呼叫UIKit類是有關配置(configuration),使他們執行緒安全更沒有意義。

然而,即使呼叫不是關於配置(configuration)來共享內部狀態,因此它們不是執行緒安全的。如果你已經寫回到黑暗時代iOS3.2及以前的應用程式,你一定經歷過當準備背景影像時使用NSStringdrawInRect:withFont:隨時崩潰。值得慶幸的是隨著iOS4的到來,Apple提供了大部分繪圖的方法和類,例如UIColorUIFont在後臺執行緒中的使用。

不幸的是,Apple的文件目前還缺乏有關執行緒安全的主題。他們建議只在主執行緒訪問,甚至連繪畫方法他們都不能保證執行緒安全。所以閱讀iOS的版本說明總是一個好主意。

在大多數情況下,UIKit類只應該在程式的主執行緒使用。無論是從UIResponder派生的類,還是那些涉及以任何方式操作你的應用程式的使用者介面。

 

解除分配問題

另一個在後臺使用UIKit物件的風險是“解除分配問題”。Apple在TN2109裡概括了這個問題,並提出了多種解決方案。這個問題是UI物件應該在主執行緒中釋放,因為一部分物件有可能在dealloc中對檢視層次結構進行更改。正如我們所知,這種對UIKit的呼叫需要發生在主執行緒上。

由於它常見於次執行緒,操作或塊保留呼叫者,這很容易出錯,並且很難找到並修復。這也是在AFNetworking中長期存在的一個bug,只是因為不是很多人知道這個問題,照例,顯然它很罕見,並且很難重現崩潰。在非同步塊操作裡一貫使用__weak和不訪問ivars會有所幫助。

 

集合類

Apple有一個很好的概述文件,對iOS和Mac上列出執行緒安全最常見的基礎類。一般情況下,不可變類,像NSArray是執行緒安全的,而它們的可變的變體,像NSMutableArray則不是。事實上,當在一個佇列中序列化的訪問時,是可以在不同執行緒中使用它們的。請記住,方法可能返回一個集合物件的可變變體,即使它們生命它們的返回型別是不可變的。好的做法是寫一些像return [array copy]來確保返回的物件實際上是不可變的。

不同於像Java語言,Foundation框架不提供框架外的執行緒安全的集合類。其實這是非常合理的,因為在大多數情況下,你想在更高層使用你的鎖去避免過多的鎖操作。一個值得注意的例外是快取,其中一個可變的字典可能會儲存不變的資料-在這裡Apple在iOS4中增加了NSCache,它不僅能鎖定訪問,還可以在低記憶體情況下清除它的內容。

這就是說,在你的程式中,這也許是有效的情況,其中一個執行緒安全的可變的字典可以很輕便的。而這要歸功於類簇(class cluster)的解決方案,它可以很容易的寫一個。

 

原子屬性(properties)

有沒有想過Apple如何處理原子設定/獲取屬性?現在你可能已經聽說過spinlocks, semaphores, locks, @synchronized – 那Apple使用什麼?幸運的是,Objective-C執行是公開的,所以我們可以看看幕後發生了什麼。

一個非原子屬性的setter方法可能看起來像這樣:

這是手動retain/release變數,然而用ARC生成的程式碼看起來類似。讓我們看看這段程式碼,很顯然當setUserName:被同時呼叫就遇到了麻煩。我們最終可能會釋放_userName兩次,這會破壞記憶體,並且導致難以發現的bug。

對於任意一個非手工實現的property內部發生的是,編譯器生成一個呼叫objc_setProperty_non_gc(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, signed char shouldCopy)。在我們的例子中,呼叫引數是這樣的:

ptrdiff_t你可能看起來很怪異,但最終它是一個簡單的指標演算法,因為一個Objective-C類正是另一個C結構。
objc_setProperty呼叫下面的方法:

除了相當有趣的名字,這種方法其實是相當簡單,並使用128個在PropertyLocks可用的spinlocks其中之一。這是一個務實的和快速的解決方案 – 最壞的情況是,因為一個雜湊衝突,一個setter不得不等待一個不相關的setter結束。
雖然這些方法在任何公共標頭檔案都沒有宣告,但可以手動呼叫它們。我並不是說這是一個好主意,但如果你想要原子屬性和想要同時實現setter,知道這些是很有趣的並且可能會相當有用。

參考這個gist全部片段包括處理結構的程式碼。但是請記住我們不建議使用這個。

 

@synchronized如何?

你可能很好奇為什麼Apple不使用一個已有的執行時特性@synchronized(self)來做屬性鎖。一旦你看了原始碼,你將明白這還有很多事要做。Apple採用最多三個上鎖/解鎖序列,部分原因是他們還增加了異常展開(exception unwinding)。比起更加快速的spinlock方案,這個會慢一些。由於設定屬性通常是相當快的,spinlocks是最完美的選擇。當你需要確保沒有程式碼死鎖而丟擲異常,@synchronized(self)是個好的選擇。

 

你自己的類

單獨使用原子屬性不會讓你的類執行緒安全的。它只會保護你在setter中免受競態條件(race conditions),但不會保護你的應用程式邏輯。請考慮以下程式碼片段:

我在PSPDFKit早早就犯了這個錯誤。偶爾,當contents屬性檢查後被設定為nil,該應用程式以EXC_BAD_ACCESS崩潰了。對這個問題簡單的解決辦法是捕獲變數:

這樣就解決了問題,但在大多數情況下,它不是那麼簡單的。試想一下,我們也有一個textColor屬性,我們在一個執行緒中改變兩次屬性。那麼,我們的渲染執行緒可能最終會使用有舊顏色值的新內容,我們得到一個奇怪的組合。這就是為什麼Core Data在一個執行緒或佇列中繫結模型物件。

對於這個問題沒有一個統一標準的解決方案。使用不可變的模型是一個解決方案,但它有它自己的問題。另一種方法是限制在主執行緒或一個特定的佇列更改現有物件,而在工作執行緒中使用之前生成的副本。我推薦Jonathan Sterling在文章中為解決這個問題更多的想法。

簡單的解決方法是使用@synchronize。其他的是非常,非常有可能讓你陷入困境。更聰明的人一次又一次地在其他方法上失敗了。

 

實用的執行緒安全設計

在試圖做執行緒安全之前,認真考慮是否是必要的。請確保它不是過早的優化。如果它像是一個配置類,考慮執行緒安全是沒有意義的。更好的方法是丟擲一些斷言來確保它的正確使用:

現在肯定有執行緒安全的程式碼,一個很好的例子就是快取類。一個好的方法是使用一個並行dispatch_queue為讀/寫鎖,以最大限度地提高效能,並嘗試只鎖定那些真正需要的地方。一旦你開始使用多個佇列用於鎖定不同部位,事情將很快變得棘手。
有時候,你也可以重寫你的程式碼,使特殊的鎖不是必需的。考慮這個程式碼片段,是一個多播委託的形式。 (在許多情況下,使用NSNotifications會更好,但也有有效的多路廣播委託用例。)

除非addDelegate:或removeDelegate:每秒被呼叫上千次,否則下面是更簡潔的方法:

當然,這個例子有點兒認為構造的,它可以簡單的侷限於在主執行緒更改。但對於許多資料結構,在修改方法中建立不可變的副本是值得的,讓廣大的應用程式邏輯並不需要處理過多的鎖定。注意,我們仍然要在addDelegate:申請鎖,否則如果委託物件被來自不同的執行緒同時呼叫,它可能會迷失。

 

GCD的陷阱

對於大部分的鎖定需求,GCD是完美的。這很簡單,很快速,並且它的基於塊的API使得它更難偶然做出不平衡鎖。不過,也有不少缺陷,我們將要在這裡探索其中一些。

 

使用GCD作為遞迴鎖

GCD是一個佇列來序列化訪問共享資源。這可以被用於鎖定,但它比@synchronized大不相同。 GCD佇列是不可重入的 – 這將打破佇列特性。許多人試圖使用dispatch_get_current_queue()來作為替代方案,這是一個壞主意。Apple在iOS6中廢棄此方法自然有它的原因。

測試當前佇列簡單的解決方案可能起作用,但當你的程式碼變得更加複雜的時候,你可能會在同一時間對多個佇列上鎖,它會失敗。一旦你是這種情況,你幾乎肯定會遇到死鎖。當然,人們可以使用dispatch_get_specific(),它會遍歷整個佇列的層次結構來測試特定的佇列。對於您將不得不編寫應用此後設資料的自定義佇列的建構函式。不要走那條路,很多使用情況下,NSRecursiveLock是更好的解決方案。

 

dispatch_async的固定時序問題

在UIKit中有一些時序問題?大多數時候,這將是完美的“修復”:

相信我,不要這樣做。這將在以後纏著你因為你的應用程式變得越來越大。這是超級難除錯,並因為“時序問題”當你需要排程越來越多,事情很快會土崩瓦解。看你的程式碼,找到適當呼叫的位置(例如viewWillAppear而不是viewDidLoad中)。在我的程式碼庫仍然有一些黑客方式,但大部分都會被適當的記錄並且提交問題。

請記住,這真不是GCD特有的,但它是一個常見的反模式,只是GCD很容易做到。你可以使用同樣的才智performSelector:afterDelay:,其中下一個runloop的延遲是0.f。

 

在效能關鍵程式碼中使用混合dispatch_sync和dispatch_async

那個花了我一段時間才弄清楚。在PSPDFKit中有一個使用LRU列表來跟蹤影像訪問的快取類。當你通過頁面滾動,它會被呼叫很多次。最初的實現中對於可用的訪問使用dispatch_sync,用dispatch_async來更新LRU位置。這導致幀速率遠遠低於每秒60幀的目標。

當你的應用程式中執行的其他程式碼阻止GCD的執行緒,它可能需要一段時間,直到排程管理器發現一個執行緒來執行dispatch_async程式碼 – 在那之前,你的同步呼叫將被阻塞。即使,在這個例子中,在非同步情況下執行的順序並不重要,沒有簡單的方法來告訴給GCD 。讀/寫鎖在這裡不會有任何幫助,因為非同步流程非常肯定需要執行一個寫屏障,在這期間你的所有讀操作都會被鎖定。教訓:如果濫用, dispatch_async可以是昂貴的。使用它來鎖操作要非常小心。

 

使用dispatch_async來排程記憶體密集型操作

我們已經談了很多關於NSOperations ,而且使用更高層的API通常是一個好主意。如果你處理的是記憶體密集型操作的工作塊,這是尤其如此。

在舊版本的PSPDFKit中,我用了一個GCD佇列來排程寫快取JPG影像到磁碟。當視網膜的iPad出來了,這開始引起麻煩。解析度加倍,比起渲染影像,對影像資料進行編碼需要更長的時間。因此,操作堆積在佇列中,當系統繁忙它可能會因為記憶體耗盡而崩潰。

沒有辦法來看到有多少操作在排隊裡(除非你手動新增程式碼來追蹤這一點) ,而且也沒有內建的方式來取消操作萬一收到記憶體不足的通知。切換到NSOperations使程式碼更加可除錯,並允許這一切都無需編寫手動管理程式碼。

當然也有一些注意事項,例如你不能在你的NSOperationQueue上設定一個目標佇列(如為節流的I/O 而 DISPATCH_QUEUE_PRIORITY_BACKGROUND ) 。但是,這是一個為可除錯性付出的很小的代價,也防止你陷入類似問題,如優先順序反轉。我甚至建議使用漂亮的NSBlockOperation API,並建議NSOperation的真正子類,包括描述的實現。這是更多的工作,但後來,有一個方法出奇的有用,是列印所有執行/掛起的操作。

相關文章