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