轉自:http://ansonzhao.com/blog/2013/11/28/thread-safe-class-design/html
翻譯自Thread-Safe Class Designgit
首先讓咱們來看一下Apple的框架。通常狀況下,除非提早聲明,不然大多數類默認不是線程安全的。一些是咱們所指望的,可是另外一些卻會至關有趣。github
其中甚至有經驗的iOS/Mac開發人員常會犯的錯誤是在後臺線程中訪問部分UIKit/AppKit。最容易犯的錯誤是在後臺線程中對property賦值,好比圖片,由於他們的內容是在後臺從網絡上獲取的。Apple的代碼是性能優化過的,若是你從不一樣線程去改動property,它是不會警告你的。算法
例如圖片這種狀況,一個常見的問題是你的改動會產生延遲。可是若是兩個線程同時設置圖片,極可能你的程序將直接崩潰,由於當前設置的圖片可能會被釋放兩次。因爲這是和時機相關的,所以崩潰一般發生在客戶使用時,而並非在開發過程當中。緩存
雖然沒有官方的工具來發現這樣的錯誤,可是有一些技巧能夠避免這種錯誤發生。The UIKit Main Thread Guard是一小段代碼,能夠修補任何調用UIView的setNeedsLayout和setNeedsDisplay,以及在發送調用以前檢查是否執行在主線程。因爲這兩種方法被許多UIKit的setters方法調用(包括圖片),這將會捕獲許多線程相關的錯誤。雖然這個不使用私有API,可是咱們不建議在產品程序中使用,而是最好在開發過程是使用。安全
UIKit非線程安全是Apple有意的設計決定。從性能方面來講線程安全沒有太多好處,它實際上會使不少事情變慢。而事實上UIKit和主線程捆綁使它很容易編寫併發程序和使用UIKit。你所須要作的就是確保老是在主線程上調用UIKit。性能優化
像UIKit這樣大的框架上確保線程安全是一個重大的任務,會帶來巨大的成本。改變非原子property爲原子property只是所須要改變的一小部分。一般你想要一次改變多個property,而後才能看到更改的結果。對於這一點,Apple不得不暴露一個方法,像CoreData的performBlock:
和同步的方法performBlockAndWait:
。若是你考慮大多數調用UIKit類是有關配置(configuration),使他們線程安全更沒有意義。網絡
然而,即便調用不是關於配置(configuration)來共享內部狀態,所以它們不是線程安全的。若是你已經寫回到黑暗時代iOS3.2及之前的應用程序,你必定經歷過當準備背景圖像時使用NSString
的drawInRect:withFont:
隨時崩潰。值得慶幸的是隨着iOS4的到來,Apple提供了大部分繪圖的方法和類,例如UIColor
和UIFont
在後臺線程中的使用。數據結構
不幸的是,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)的解決方案,它能夠很容易的寫一個。
有沒有想過Apple如何處理原子設置/獲取屬性?如今你可能已經據說過spinlocks, semaphores, locks, @synchronized – 那Apple使用什麼?幸運的是,Objective-C運行是公開的,因此咱們能夠看看幕後發生了什麼。
一個非原子屬性的setter方法可能看起來像這樣:
1 2 3 4 5 6 7 |
- (void)setUserName:(NSString *)userName { if (userName != _userName) { [userName retain]; [_userName release]; _userName = userName; } } |
這是手動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)
。在咱們的例子中,調用參數是這樣的:
1 2 |
objc_setProperty_non_gc(self, _cmd, (ptrdiff_t)(&_userName) - (ptrdiff_t)(self), userName, NO, NO); |
ptrdiff_t
你可能看起來很怪異,但最終它是一個簡單的指針算法,由於一個Objective-C類正是另外一個C結構。
objc_setProperty
調用下面的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) { id oldValue; id *slot = (id*) ((char*)self + offset); if (copy) { newValue = [newValue copyWithZone:NULL]; } else if (mutableCopy) { newValue = [newValue mutableCopyWithZone:NULL]; } else { if (*slot == newValue) return; newValue = objc_retain(newValue); } if (!atomic) { oldValue = *slot; *slot = newValue; } else { spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)]; _spin_lock(slotlock); oldValue = *slot; *slot = newValue; _spin_unlock(slotlock); } objc_release(oldValue); } |
除了至關有趣的名字,這種方法實際上是至關簡單,並使用128個在PropertyLocks可用的spinlocks其中之一。這是一個務實的和快速的解決方案 – 最壞的狀況是,由於一個哈希衝突,一個setter不得不等待一個不相關的setter結束。
雖然這些方法在任何公共頭文件都沒有聲明,但能夠手動調用它們。我並非說這是一個好主意,但若是你想要原子屬性和想要同時實現setter,知道這些是頗有趣的而且可能會至關有用。
1 2 3 4 5 6 7 8 9 10 |
// Manually declare runtime methods. extern void objc_setProperty(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, BOOL shouldCopy); extern id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic); #define PSTAtomicRetainedSet(dest, src) objc_setProperty(self, _cmd, (ptrdiff_t)(&dest) - (ptrdiff_t)(self), src, YES, NO) #define PSTAtomicAutoreleasedGet(src) objc_getProperty(self, _cmd, (ptrdiff_t)(&src) - (ptrdiff_t)(self), YES) |
參考這個gist所有片斷包括處理結構的代碼。可是請記住咱們不建議使用這個。
你可能很好奇爲何Apple不使用一個已有的運行時特性@synchronized(self)
來作屬性鎖。一旦你看了源代碼,你將明白這還有不少事要作。Apple採用最多三個上鎖/解鎖序列,部分緣由是他們還增長了異常展開(exception unwinding)。比起更加快速的spinlock方案,這個會慢一些。因爲設置屬性一般是至關快的,spinlocks是最完美的選擇。當你須要確保沒有代碼死鎖而拋出異常,@synchronized(self)
是個好的選擇。
單獨使用原子屬性不會讓你的類線程安全的。它只會保護你在setter中免受競態條件(race conditions),但不會保護你的應用程序邏輯。請考慮如下代碼片斷:
1 2 3 4 5 |
if (self.contents) { CFAttributedStringRef stringRef = CFAttributedStringCreate(NULL, (__bridge CFStringRef)self.contents, NULL); // draw string } |
我在PSPDFKit早早就犯了這個錯誤。偶爾,當contents
屬性檢查後被設置爲nil
,該應用程序以EXC_BAD_ACCESS崩潰了。對這個問題簡單的解決辦法是捕獲變量:
1 2 3 4 5 6 |
NSString *contents = self.contents; if (contents) { CFAttributedStringRef stringRef = CFAttributedStringCreate(NULL, (__bridge CFStringRef)contents, NULL); // draw string } |
這樣就解決了問題,但在大多數狀況下,它不是那麼簡單的。試想一下,咱們也有一個textColor
屬性,咱們在一個線程中改變兩次屬性。那麼,咱們的渲染線程可能最終會使用有舊顏色值的新內容,咱們獲得一個奇怪的組合。這就是爲何Core Data在一個線程或隊列中綁定模型對象。
對於這個問題沒有一個統一標準的解決方案。使用不可變的模型是一個解決方案,但它有它本身的問題。另外一種方法是限制在主線程或一個特定的隊列更改現有對象,而在工做線程中使用以前生成的副本。我推薦Jonathan Sterling在文章中爲解決這個問題更多的想法。
簡單的解決方法是使用@synchronize
。其餘的是很是,很是有可能讓你陷入困境。更聰明的人一次又一次地在其餘方法上失敗了。
在試圖作線程安全以前,認真考慮是不是必要的。請確保它不是過早的優化。若是它像是一個配置類,考慮線程安全是沒有意義的。更好的方法是拋出一些斷言來確保它的正確使用:
1 2 3 4 5 |
void PSPDFAssertIfNotMainThread(void) { NSAssert(NSThread.isMainThread, @"Error: Method needs to be called on the main thread. %@", [NSThread callStackSymbols]); } |
如今確定有線程安全的代碼,一個很好的例子就是緩存類。一個好的方法是使用一個並行dispatch_queue爲讀/寫鎖,以最大限度地提升性能,並嘗試只鎖定那些真正須要的地方。一旦你開始使用多個隊列用於鎖定不一樣部位,事情將很快變得棘手。
有時候,你也能夠重寫你的代碼,使特殊的鎖不是必需的。考慮這個代碼片斷,是一個多播委託的形式。 (在許多狀況下,使用NSNotifications會更好,但也有有效的多路廣播委託用例。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// header @property (nonatomic, strong) NSMutableSet *delegates; // in init _delegateQueue = dispatch_queue_create("com.PSPDFKit.cacheDelegateQueue", DISPATCH_QUEUE_CONCURRENT); - (void)addDelegate:(id<PSPDFCacheDelegate>)delegate { dispatch_barrier_async(_delegateQueue, ^{ [self.delegates addObject:delegate]; }); } - (void)removeAllDelegates { dispatch_barrier_async(_delegateQueue, ^{ self.delegates removeAllObjects]; }); } - (void)callDelegateForX { dispatch_sync(_delegateQueue, ^{ [self.delegates enumerateObjectsUsingBlock:^(id<PSPDFCacheDelegate> delegate, NSUInteger idx, BOOL *stop) { // Call delegate }]; }); } |
除非addDelegate:
或removeDelegate:
每秒被調用上千次,不然下面是更簡潔的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// header @property (atomic, copy) NSSet *delegates; - (void)addDelegate:(id<PSPDFCacheDelegate>)delegate { @synchronized(self) { self.delegates = [self.delegates setByAddingObject:delegate]; } } - (void)removeAllDelegates { self.delegates = nil; } - (void)callDelegateForX { [self.delegates enumerateObjectsUsingBlock:^(id<PSPDFCacheDelegate> delegate, NSUInteger idx, BOOL *stop) { // Call delegate }]; } |
固然,這個例子有點兒認爲構造的,它能夠簡單的侷限於在主線程更改。但對於許多數據結構,在修改方法中建立不可變的副本是值得的,讓廣大的應用程序邏輯並不須要處理過多的鎖定。注意,咱們仍然要在addDelegate:
申請鎖,不然若是委託對象被來自不一樣的線程同時調用,它可能會迷失。
對於大部分的鎖定需求,GCD是完美的。這很簡單,很快速,而且它的基於塊的API使得它更難偶然作出不平衡鎖。不過,也有很多缺陷,咱們將要在這裏探索其中一些。
GCD是一個隊列來序列化訪問共享資源。這能夠被用於鎖定,但它比@synchronized
大不相同。 GCD隊列是不可重入的 – 這將打破隊列特性。許多人試圖使用dispatch_get_current_queue()
來做爲替代方案,這是一個壞主意。Apple在iOS6中廢棄此方法天然有它的緣由。
1 2 3 4 5 6 7 |
// This is a bad idea. inline void pst_dispatch_sync_reentrant(dispatch_queue_t queue, dispatch_block_t block) { dispatch_get_current_queue() == queue ? block() : dispatch_sync(queue, block); } |
測試當前隊列簡單的解決方案可能起做用,但當你的代碼變得更加複雜的時候,你可能會在同一時間對多個隊列上鎖,它會失敗。一旦你是這種狀況,你幾乎確定會遇到死鎖。固然,人們可使用dispatch_get_specific()
,它會遍歷整個隊列的層次結構來測試特定的隊列。對於您將不得不編寫應用此元數據的自定義隊列的構造函數。不要走那條路,不少使用狀況下,NSRecursiveLock是更好的解決方案。
在UIKit中有一些時序問題?大多數時候,這將是完美的「修復」:
1 2 3 4 5 |
dispatch_async(dispatch_get_main_queue(), ^{ // Some UIKit call that had timing issues but works fine // in the next runloop. [self updatePopoverSize]; }); |
相信我,不要這樣作。這將在之後纏着你由於你的應用程序變得愈來愈大。這是超級難調試,並由於「時序問題」當你須要調度愈來愈多,事情很快會土崩瓦解。看你的代碼,找到適當調用的位置(例如viewWillAppear而不是viewDidLoad中)。在個人代碼庫仍然有一些黑客方式,但大部分都會被適當的記錄而且提交問題。
請記住,這真不是GCD特有的,但它是一個常見的反模式,只是GCD很容易作到。你可使用一樣的才智performSelector:afterDelay:
,其中下一個runloop的延遲是0.f。
那個花了我一段時間才弄清楚。在PSPDFKit中有一個使用LRU列表來跟蹤圖像訪問的緩存類。當你經過頁面滾動,它會被調用不少次。最初的實現中對於可用的訪問使用dispatch_sync,用dispatch_async來更新LRU位置。這致使幀速率遠遠低於每秒60幀的目標。
當你的應用程序中運行的其餘代碼阻止GCD的線程,它可能須要一段時間,直到調度管理器發現一個線程來執行dispatch_async代碼 – 在那以前,你的同步調用將被阻塞。即便,在這個例子中,在異步狀況下執行的順序並不重要,沒有簡單的方法來告訴給GCD 。讀/寫鎖在這裏不會有任何幫助,由於異步流程很是確定須要執行一個寫屏障,在這期間你的全部讀操做都會被鎖定。教訓:若是濫用, dispatch_async能夠是昂貴的。使用它來鎖操做要很是當心。
咱們已經談了不少關於NSOperations ,並且使用更高層的API一般是一個好主意。若是你處理的是內存密集型操做的工做塊,這是尤爲如此。
在舊版本的PSPDFKit中,我用了一個GCD隊列來調度寫緩存JPG圖像到磁盤。當視網膜的iPad出來了,這開始引發麻煩。分辨率加倍,比起渲染圖像,對圖像數據進行編碼須要更長的時間。所以,操做堆積在隊列中,當系統繁忙它可能會由於內存耗盡而崩潰。
沒有辦法來看到有多少操做在排隊裏(除非你手動添加代碼來追蹤這一點) ,並且也沒有內置的方式來取消操做萬一收到內存不足的通知。切換到NSOperations使代碼更加可調試,並容許這一切都無需編寫手動管理代碼。
固然也有一些注意事項,例如你不能在你的NSOperationQueue上設置一個目標隊列(如爲節流的I/O而DISPATCH_QUEUE_PRIORITY_BACKGROUND
) 。可是,這是一個爲可調試性付出的很小的代價,也防止你陷入相似問題,如優先級反轉。我甚至建議使用漂亮的NSBlockOperation API,並建議NSOperation的真正子類,包括描述的實現。這是更多的工做,但後來,有一個方法出奇的有用是打印全部運行/掛起的操做。