設計一個線程安全的類(轉)

轉自:http://ansonzhao.com/blog/2013/11/28/thread-safe-class-design/html

翻譯自Thread-Safe Class Designgit

線程安全

Apple的框架

首先讓咱們來看一下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不是線程安全的?

像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方法可能看起來像這樣:

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所有片斷包括處理結構的代碼。可是請記住咱們不建議使用這個。

@synchronized如何?

你可能很好奇爲何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的陷阱

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

使用GCD做爲遞歸鎖

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是更好的解決方案。

dispatch_async的固定時序問題

在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。

在性能關鍵代碼中使用混合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的真正子類,包括描述的實現。這是更多的工做,但後來,有一個方法出奇的有用是打印全部運行/掛起的操做。

相關文章
相關標籤/搜索