這篇文章將專注于實用技巧,設(shè)計模式,以及對于寫出線程安全類和使用 GCD 來說所特別需要注意的一些反面模式。
首先讓我們來看看 Apple 的框架。一般來說除非特別聲明,大多數(shù)的類默認都不是線程安全的。對于其中的一些類來說,這是很合理的,但是對于另外一些來說就很有趣了。
就算是在經(jīng)驗豐富的 iOS/Mac 開發(fā)者,也難免會犯從后臺線程去訪問 UIKit/AppKit 這種錯誤。比如因為圖片的內(nèi)容本身就是從后臺的網(wǎng)絡(luò)請求中獲取的話,順手就在后臺線程中設(shè)置了 image
之類的屬性,這樣的錯誤其實是屢見不鮮的。Apple 的代碼都經(jīng)過了性能的優(yōu)化,所以即使你從別的線程設(shè)置了屬性的時候,也不會產(chǎn)生什么警告。
在設(shè)置圖片這個例子中,癥結(jié)其實是你的改變通常要過一會兒才能生效。但是如果有兩個線程在同時對圖片進行了設(shè)定,那么很可能因為當前的圖片被釋放兩次,而導致應(yīng)用崩潰。這種行為是和時機有關(guān)系的,所以很可能在開發(fā)階段沒有崩潰,但是你的用戶使用時卻不斷 crash。
現(xiàn)在沒有官方的用來尋找類似錯誤的工具,但我們確實有一些技巧來避免這個問題。UIKit Main Thread Guard 是一段用來監(jiān)視每一次對 setNeedsLayout
和 setNeedsDisplay
的調(diào)用代碼,并檢查它們是否是在主線程被調(diào)用的。因為這兩個方法在 UIKit 的 setter (包括 image 屬性)中廣泛使用,所以它可以捕獲到很多線程相關(guān)的錯誤。雖然這個小技巧并不包含任何私有 API, 但我們還是不建議將它是用在發(fā)布產(chǎn)品中,不過在開發(fā)過程中使用的話還是相當贊的。
Apple沒有把 UIKit 設(shè)計為線程安全的類是有意為之的,將其打造為線程安全的話會使很多操作變慢。而事實上 UIKit 是和主線程綁定的,這一特點使得編寫并發(fā)程序以及使用 UIKit 十分容易的,你唯一需要確保的就是對于 UIKit 的調(diào)用總是在主線程中來進行。
對于一個像 UIKit 這樣的大型框架,確保它的線程安全將會帶來巨大的工作量和成本。將 non-atomic 的屬性變?yōu)?atomic 的屬性只不過是需要做的變化里的微不足道的一小部分。通常來說,你需要同時改變?nèi)舾蓚€屬性,才能看到它所帶來的結(jié)果。為了解決這個問題,蘋果可能不得不提供像 Core Data 中的 performBlock:
和 performBlockAndWait:
那樣類似的方法來同步變更。另外你想想看,絕大多數(shù)對 UIKit 類的調(diào)用其實都是以配置為目的的,這使得將 UIKit 改為線程安全這件事情更顯得毫無意義了。
然而即使是那些與配置共享的內(nèi)部狀態(tài)之類事情無關(guān)的調(diào)用,其實也不是線程安全的。如果你做過 iOS 3.2 或之前的黑暗年代的 app 開發(fā)的話,你肯定有過一邊在后臺準備圖像時一邊使用 NSString 的 drawInRect:withFont:
時的隨機崩潰的經(jīng)歷。值得慶幸的事,在 iOS 4 中 蘋果將大部分繪圖的方法和諸如 UIColor
和 UIFont
這樣的類改寫為了后臺線程可用。
但不幸的是 Apple 在線程安全方面的文檔是極度匱乏的。他們推薦只訪問主線程,并且甚至是繪圖方法他們都沒有明確地表示保證線程安全。因此在閱讀文檔的同時,去讀讀 iOS 版本更新說明會是一個很好的選擇。
對于大多數(shù)情況來說,UIKit 類確實只應(yīng)該用在應(yīng)用的主線程中。這對于那些繼承自 UIResponder 的類以及那些操作你的應(yīng)用的用戶界面的類來說,不管如何都是很正確的。
另一個在后臺使用 UIKit 對象的的危險之處在于“內(nèi)存回收問題”。Apple 在技術(shù)筆記 TN2109 中概述了這個問題,并提供了多種解決方案。這個問題其實是要求 UI 對象應(yīng)該在主線程中被回收,因為在它們的 dealloc
方法被調(diào)用回收的時候,可能會去改變 view 的結(jié)構(gòu)關(guān)系,而如我們所知,這種操作應(yīng)該放在主線程來進行。
因為調(diào)用者被其他線程持有是非常常見的(不管是由于 operation 還是 block 所導致的),這也是很容易犯錯并且難以被修正的問題。在 AFNetworking 中也一直長久存在這樣的 bug,但是由于其自身的隱蔽性而鮮為人知,也很難重現(xiàn)其所造成的崩潰。在異步的 block 或者操作中一致使用 __weak
,并且不去直接訪問局部變量會對避開這類問題有所幫助。
Apple 有一個針對 iOS 和 Mac 的很好的總覽性文檔,為大多數(shù)基本的 foundation 類列舉了其線程安全特性??偟膩碚f,比如 NSArry
這樣不可變類是線程安全的。然而它們的可變版本,比如 NSMutableArray
是線程不安全的。事實上,如果是在一個隊列中串行地進行訪問的話,在不同線程中使用它們也是沒有問題的。要記住的是即使你申明了返回類型是不可變的,方法里還是有可能返回的其實是一個可變版本的 collection 類。一個好習慣是寫類似于 return [array copy]
這樣的代碼來確保返回的對象事實上是不可變對象。
與和[Java]()這樣的語言不一樣,F(xiàn)oundation 框架并不提供直接可用的 collection 類,這是有其道理的,因為大多數(shù)情況下,你想要的是在更高層級上的鎖,以避免太多的加解鎖操作。但緩存是一個值得注意的例外,iOS 4 中 Apple 添加的 NSCache
使用一個可變的字典來存儲不可變數(shù)據(jù),它不僅會對訪問加鎖,更甚至在低內(nèi)存情況下會清空自己的內(nèi)容。
也就是說,在你的應(yīng)用中存在可變的且線程安全的字典是可以做到的。借助于 class cluster 的方式,我們也很容易寫出這樣的代碼。
你曾經(jīng)好奇過 Apple 是怎么處理 atomic 的設(shè)置/讀取屬性的么?至今為止,你可能聽說過自旋鎖 (spinlocks),信標 (semaphores),鎖 (locks),@synchronized 等,Apple 用的是什么呢?因為 Objctive-C 的 runtime 是開源的,所以我們可以一探究竟。
一個非原子的 setter 看起來是這個樣子的:
- (void)setUserName:(NSString *)userName {
if (userName != _userName) {
[userName retain];
[_userName release];
_userName = userName;
}
}
這是一個手動 retain/release 的版本,ARC 生成的代碼和這個看起來也是類似的。當我們看這段代碼時,顯而易見要是 setUserName:
被并發(fā)調(diào)用的話會造成麻煩。我們可能會釋放 _userName
兩次,這回使內(nèi)存錯誤,并且導致難以發(fā)現(xiàn)的 bug。
對于任何沒有手動實現(xiàn)的屬性,編譯器都會生成一個 objc_setProperty_non_gc(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, signed char shouldCopy)
的調(diào)用。在我們的例子中,這個調(diào)用的參數(shù)是這樣的:
objc_setProperty_non_gc(self, _cmd,
(ptrdiff_t)(&_userName) - (ptrdiff_t)(self), userName, NO, NO);`
ptrdiff_t
可能會嚇到你,但是實際上這就是一個簡單的指針算術(shù),因為其實 Objective-C 的類僅僅只是 C 結(jié)構(gòu)體而已。
objc_setProperty
調(diào)用的是如下方法:
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);
}
除開方法名字很有趣以外,其實方法實際做的事情非常直接,它使用了在 PropertyLocks
中的 128 個自旋鎖中的 1 個來給操作上鎖。這是一種務(wù)實和快速的方式,最糟糕的情況下,如果遇到了哈希碰撞,那么 setter 需要等待另一個和它無關(guān)的 setter 完成之后再進行工作。
雖然這些方法沒有定義在任何公開的頭文件中,但我們還是可用手動調(diào)用他們。我不是說這是一個好的做法,但是知道這個還是蠻有趣的,而且如果你想要同時實現(xiàn)原子屬性和自定義的 setter 的話,這個技巧就非常有用了。
// 手動聲明運行時的方法
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 來獲取包含處理結(jié)構(gòu)體的完整的代碼,但是我們其實并不推薦使用它。
你也許會想問為什么蘋果不用 @synchronized(self)
這樣一個已經(jīng)存在的運行時特性來鎖定屬??你可以看看這里的源碼,就會發(fā)現(xiàn)其實發(fā)生了很多的事情。Apple 使用了最多三個加/解鎖序列,還有一部分原因是他們也添加了異常開解(exception unwinding)機制。相比于更快的自旋鎖方式,這種實現(xiàn)要慢得多。由于設(shè)置某個屬性一般來說會相當快,因此自旋鎖更適合用來完成這項工作。@synchonized(self)
更適合使用在你
需要確保在發(fā)生錯誤時代碼不會死鎖,而是拋出異常的時候。
單獨使用原子屬性并不會使你的類變成線程安全。它不能保護你應(yīng)用的邏輯,只能保護你免于在 setter 中遭遇到競態(tài)條件的困擾。看看下面的代碼片段:
if (self.contents) {
CFAttributedStringRef stringRef = CFAttributedStringCreate(NULL,
(__bridge CFStringRef)self.contents, NULL);
// 渲染字符串
}
我之前在 PSPDFKit 中就犯了這個錯誤。時不時地應(yīng)用就會因為 contents
屬性在通過檢查之后卻又被設(shè)成了 nil 而導致 EXC_BAD_ACCESS 崩潰。捕獲這個變量就可以簡單修復這個問題;
NSString *contents = self.contents;
if (contents) {
CFAttributedStringRef stringRef = CFAttributedStringCreate(NULL,
(__bridge CFStringRef)contents, NULL);
// 渲染字符串
}
在這里這樣就能解決問題,但是大多數(shù)情況下不會這么簡單。想象一下我們還有一個 textColor
的屬性,我們在一個線程中將兩個屬性都做了改變。我們的渲染線程有可能使用了新的內(nèi)容,但是依舊保持了舊的顏色,于是我們得到了一組奇怪的組合。這其實也是為什么 Core Data 要將 model 對象都綁定在一個線程或者隊列中的原因。
對于這個問題,其實沒有萬用解法。使用 不可變模型是一個可能的方案,但是它也有自己的問題。另一種途徑是限制對存在在主線程或者某個特定隊列中的既存對象的改變,而是先進行一次拷貝之后再在工作線程中使用。對于這個問題的更多對應(yīng)方法,我推薦閱讀 Jonathan Sterling 的關(guān)于 Objective-C 中輕量化不可變對象的文章。
一個簡單的解決辦法是使用 @synchronize
。其他的方式都非常非常可能使你誤入歧途,已經(jīng)有太多聰明人在這種嘗試上一次又一次地以失敗告終。
在嘗試寫一些線程安全的東西之前,應(yīng)該先想清楚是不是真的需要。確保你要做的事情不會是過早優(yōu)化。如果要寫的東西是一個類似配置類 (configuration class) 的話,去考慮線程安全這種事情就毫無意義了。更正確的做法是扔一個斷言上去,以保證它被正確地使用:
void PSPDFAssertIfNotMainThread(void) {
NSAssert(NSThread.isMainThread,
@"Error: Method needs to be called on the main thread. %@",
[NSThread callStackSymbols]);
}
對于那些肯定應(yīng)該線程安全的代碼(一個好例子是負責緩存的類)來說,一個不錯的設(shè)計是使用并發(fā)的 dispatch_queue
作為讀/寫鎖,并且確保只鎖著那些真的需要被鎖住的部分,以此來最大化性能。一旦你使用多個隊列來給不同的部分上鎖的話,整件事情很快就會變得難以控制了。
于是你也可以重新組織你的代碼,這樣某些特定的鎖就不再需要了??纯聪旅孢@段實現(xiàn)了一種多委托的代碼(其實在大多數(shù)情況下,用 NSNotifications 會更好,但是其實也還是有多委托的實用例子)的
// 頭文件
@property (nonatomic, strong) NSMutableSet *delegates;
// 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) {
// 調(diào)用delegate
}];
});
}
除非 addDelegate:
或者 removeDelegate:
每秒要被調(diào)用上千次,否則我們可以使用一個相對簡潔的實現(xiàn)方式:
// 頭文件
@property (atomic, copy) NSSet *delegates;
- (void)addDelegate:(id<PSPDFCacheDelegate>)delegate {
@synchronized(self) {
self.delegates = [self.delegates setByAddingObject:delegate];
}
}
- (void)removeAllDelegates {
@synchronized(self) {
self.delegates = nil;
}
}
- (void)callDelegateForX {
[self.delegates enumerateObjectsUsingBlock:^(id<PSPDFCacheDelegate> delegate, NSUInteger idx, BOOL *stop) {
// 調(diào)用delegate
}];
}
就算這樣,這個例子還是有點理想化,因為其他人可以把變更限制在主線程中。但是對于很多數(shù)據(jù)結(jié)構(gòu),可以在可變更操作的方法中創(chuàng)建不可變的拷貝,這樣整體的代碼邏輯上就不再需要處理過多的鎖了。
對于大多數(shù)上鎖的需求來說,GCD 就足夠好了。它簡單迅速,并且基于 block 的 API 使得粗心大意造成非平衡鎖操作的概率下降了不少。然后,GCD 中還是有不少陷阱,我們在這里探索一下其中的一些。
GCD 是一個對共享資源的訪問進行串行化的隊列。這個特性可以被當作鎖來使用,但實際上它和 @synchronized
有很大區(qū)別。 GCD隊列并非是可重入的,因為這將破壞隊列的特性。很多有試圖使用 dispatch_get_current_queue()
來繞開這個限制,但是這是一個糟糕的做法,Apple 在 iOS6 中將這個方法標記為廢棄,自然也是有自己的理由。
// 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);
}
對當前的隊列進行測試也許在簡單情況下可以行得通,但是一旦你的代碼變得復雜一些,并且你可能有多個隊列在同時被鎖住的情況下,這種方法很快就悲劇了。一旦這種情況發(fā)生,幾乎可以肯定的是你會遇到死鎖。當然,你可以使用 dispatch_get_specific()
,這將截斷整個隊列結(jié)構(gòu),從而對某個特定的隊列進行測試。要這么做的話,你還得為了在隊列中附加標志隊列的元數(shù)據(jù),而去寫自定義的隊列構(gòu)造函數(shù)。嘛,最好別這么做。其實在實用中,使用 NSRecursiveLock
會是一個更好的選擇。
在使用 UIKit 的時候遇到了一些時序上的麻煩?很多時候,這樣進行“修正”看來非常完美:
dispatch_async(dispatch_get_main_queue(), ^{
// Some UIKit call that had timing issues but works fine
// in the next runloop.
[self updatePopoverSize];
});
千萬別這么做!相信我,這種做法將會在之后你的 app 規(guī)模大一些的時候讓你找不著北。這種代碼非常難以調(diào)試,并且你很快就會陷入用更多的 dispatch 來修復所謂的莫名其妙的"時序問題"。審視你的代碼,并且找到合適的地方來進行調(diào)用(比如在 viewWillAppear 里調(diào)用,而不是 viewDidLoad 之類的)才是解決這個問題的正確做法。我在自己的代碼中也還留有一些這樣的 hack,但是我為它們基本都做了正確的文檔工作,并且對應(yīng)的 issue 也被一一記錄過。
記住這不是真正的 GCD 特性,而只是一個在 GCD 下很容易實現(xiàn)的常見反面模式。事實上你可以使用 performSelector:afterDelay:
方法來實現(xiàn)同樣的操作,其中 delay 是在對應(yīng)時間后的 runloop。
這個問題我花了好久來研究。在 PSPDFKit 中有一個使用了 LRU(最久未使用)算法列表的緩存類來記錄對圖片的訪問。當你在頁面中滾動時,這個方法將被調(diào)用非常多次。最初的實現(xiàn)使用了 dispatch_sync
來進行實際有效的訪問,使用 dispatch_async
來更新 LRU 列表的位置。這導致了幀數(shù)遠低于原來的 60 幀的目標。
當你的 app 中的其他運行的代碼阻擋了 GCD 線程的時候,dispatch manager 需要花時間去尋找能夠執(zhí)行 dispatch_async 代碼的線程,這有時候會花費一點時間。在找到合適的執(zhí)行線程之前,你的同步調(diào)用就會被 block 住了。其實在這個例子中,異步情況的執(zhí)行順序并不是很重要,但沒有能將這件事情告訴 GCD 的好辦法。讀/寫鎖這里并不能起到什么作用,因為在異步操作中基本上一定會需要進行順序?qū)懭?,而在此過程中讀操作將被阻塞住。如果誤用了 dispatch_async
代價將會是非常慘重的。在將它用作鎖的時候,一定要非常小心。
我們已經(jīng)談?wù)摿撕芏嚓P(guān)于 NSOperations 的話題了,一般情況下,使用這個更高層級的 API 會是一個好主意。當你要處理一段內(nèi)存敏感的操作的代碼塊時,這個優(yōu)勢尤為突出、
在 PSPDFKit 的老版本中,我用了 GCD 隊列來將已緩存的 JPG 圖片寫到磁盤中。當 retina 的 iPad 問世之后,這個操作出現(xiàn)了問題。?因為分辨率翻倍了,相比渲染這張圖片,將它編碼花費的時間要長得多。所以,操作堆積在了隊列中,當系統(tǒng)繁忙時,甚至有可能因為內(nèi)存耗盡而崩潰。
我們沒有辦法追蹤有多少個操作在隊列中等待運行(除非你手動添加了追蹤這個的代碼),我們也沒有現(xiàn)成的方法來在接收到低內(nèi)存通告的時候來取消操作、這時候,切換到 NSOperations 可以使代碼變得容易調(diào)試得多,并且允許我們在不添加手動管理的代碼的情況下,做到對操作的追蹤和取消。
當然也有一些不好的地方,比如你不能在你的 NSOperationQueue
中設(shè)置目標隊列(就像 DISPATCH_QUEUE_PRIORITY_BACKGROUND
之于 緩速 I/O 那樣)。但這只是為了可調(diào)試性的一點小代價,而事實上這也幫助你避免遇到優(yōu)先級反轉(zhuǎn)的問題。我甚至不推薦直接使用已經(jīng)包裝好的 NSBlockOperation
的 API,而是建議使用一個 NSOperation 的真正的子類,包括實現(xiàn)其 description。誠然,這樣做工作量會大一些,但是能輸出所有運行中/準備運行的操作是及其有用的。