41.多用派發隊列,少用同步鎖
在Objective-C中,如果有多個線程要執行同一份代碼,那么有時可能會出問題。這種情況下,通常要使用鎖來實現某種同步機制。在GCD出現之前,有兩種辦法,第一種是采用內置的“同步塊”(synchronization block):
//第一種方式:@synchronized
-(void)synchronizedMethod{
@synchronized (self) {
//Safe
}
}
這種寫法會根據給定的對象,自動創建一個鎖,并等待塊中的代碼執行完畢。執行到這段代碼結尾處,鎖就釋放了。在本例中,同步行為所針對的對象是self。這么寫通常沒錯,因為它可以保證每個對象實例都能不受干擾地運行其synchronizedMethod方法。然而,濫用@synchronized(self)則會降低代碼效率,因為共用同一個鎖的那些同步塊,都必須按順序執行。若是在self對象上頻繁加鎖,那么程序可能要等另一段與此無關的代碼執行完畢,才能繼續執行當前代碼,這樣做其實并沒有必要。
另一個辦法是直接使用NSLock對象:
//第二種方式:NSLock
-(void)synchronizedMethod{
[_lock lock];
//Safe
[_lock unlock];
}
也可以使用NSRecursiveLock這種“遞歸鎖”,線程能夠多次持有該鎖,而不會出現死鎖現象。
這兩種方法都很好,不過也有其缺陷。比方說,在極端情況下,同步塊會導致死鎖,另外,其效率也不見得很高,而如果直接使用鎖對象的話,一旦遇到死鎖,就會非常麻煩。
替代方案就是使用GCD,它能以更簡單、更高效的形式為代碼加鎖。比方說,屬性就是開發者經常需要同步的地方,這種屬性需要做成“原子的”。用atomic特質來修飾屬性,即可實現這一點(第6條)。而開發者如果想自己來編寫訪問方法的話,那么通常會這樣寫:
-(NSString *)someString{
@synchronized (self) {
return _someString;
}
}
-(void)setSomeString:(NSString *)someString{
@synchronized (self) {
_someString = someString;
}
}
濫用@syncronized(self)會很危險,因為所有同步塊都會彼此搶奪同一個鎖。要是有很多屬性都這么寫的話,那么每個屬性的同步塊都要等其他所有同步塊執行完畢才能執行,這也許并不是開發者想要的效果。我們只是想令每個屬性各自獨立地同步。
這么做雖然能提供某種程度的“線程安全”,但卻無法保證訪問該對象時絕對是線程安全的。當然,訪問屬性的操作確實是“原子的”。使用屬性時,必定能從中獲取到有效值,然而在同一個線程上多次調用獲取方法,每次獲取到的結果卻未必相同。在兩次訪問操作之間,其他線程可能會寫入新的屬性值。
有種簡單而高效的辦法可以代替同步塊或鎖對象,那就是使用“串行同步隊列”(serial synchronization queue)。將讀取操作及寫入操作都安排在同一個隊列里,即可保證數據同步。其用法如下:
_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NULL);
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_sync(_syncQueue, ^{
_someString = someString;
});
}
此模式的思路是:把設置操作與獲取操作都安排在序列化的隊列里執行,這樣的話,所有針對屬性的訪問操作就都同步了。為了使塊代碼能夠設置局部變量,獲取方法中用到了__block語法。全部加鎖任務都在GCD中處理,而GCD是在相當深的底層來實現的,于是能夠做許多優化。
還可以進一步優化,設置方法并不一定非得是同步的。設置實例變量所用的塊,并不需要向設置方法返回什么值。也就是說,設置方法的代碼可以改成這樣:
-(void)setSomeString:(NSString *)someString{
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
這次只是把同步派發改成了異步派發,從調用者的角度來看,這個小改動可以提升設置方法的執行速度,而讀取操作與寫入操作依然會按順序執行。但這么改有個壞處:如果你測一下程序性能,那么可能會發現這種寫法比原來慢,因為執行異步派發時,需要拷貝塊。若拷貝塊所用的時間明顯超過執行塊所花的時間,則這種做法比原來慢。由于這里所舉的例子很簡單,所以改完之后很可能會變慢。然而,若是派發給隊列的塊要執行更為繁重的任務,那么仍然可以考慮這種備選方案。
多個獲取方法可以并發執行,而獲取方法與設置方法之間不能并發執行,利用這個特點,還能寫出更快一些的代碼來。此時正可以體現出GCD寫法的好處。用同步塊或鎖對象,是無法輕易實現出下面這種方案的。這次不用串行隊列,而改用并發隊列(concurrent queue):
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
像現在這樣寫代碼,還無法正確實現同步。所有讀取操作與寫入操作都會在同一個隊列上執行,不過由于是并發隊列,所以讀取與寫入操作可以隨時執行。而我們恰恰不想讓這些操作隨意執行。此問題用一個簡單的GCD功能即可解決。它就是柵欄(barrier)。下列函數可以向隊列中派發塊,將其作為柵欄使用:
void dispatch_barrier_async(dispatch_queue_t queue,
dispatch_block_t block);
void dispatch_barrier_sync(dispatch_queue_t queue,
dispatch_block_t block);
在隊列中,柵欄塊必須單獨執行,不能與其他塊并行。這只對并發隊列有意義,因為串行隊列中的塊總是按順序逐個來執行的。并發隊列如果發現接下來要處理的塊是個柵欄塊(barrier block),那么就一直要等當前所有并發塊都執行完畢,才會單獨執行這個柵欄。待柵欄執行過后,再按正常方式繼續向下處理。
在本例中,可以用柵欄塊來實現屬性的設置方法。在設置方法中使用了柵欄塊之后,對屬性的讀取操作依然可以并發執行,但是寫入操作卻必須單獨執行了。下圖演示的這個隊列中,有許多讀取操作,而且還有一個寫入操作:
實現代碼很簡單:
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_barrier_async(_syncQueue, ^{
_someString = someString;
});
}
這種做法肯定比使用串行隊列要快。注意,設置函數也可以改用同步的柵欄塊來實現,那樣做可能會更高效。最好還是測一測每種做法的性能,然后從中選出最適合當前場景的方案。
要點:
- 派發隊列可用來表述同步語義,這種做法要比使用@synchronized塊或NSLock對象更簡單。
- 將同步與異步派發結合起來,可以實現與普通加鎖機制一樣的同步行為,而這么做卻不會阻塞執行異步派發的線程。
- 使用同步隊列及柵欄塊,可以令同步行為更加高效。
42.多用GCD,少用preformSelector系列方法
Objective-C本質上是一門非常動態的語言,NSObject定義了幾個方法,令開發者可以隨意調用任何方法。這幾個方法可以推遲執行方法調用,也可以指定運行方法所用的線程。這些功能原來很有用,但是在出現了大中樞派發及塊這樣的新技術之后,就顯得不那么必要了。雖然有些代碼還是會經常用到它們,但是盡量避開為好。
這其中最簡單的是“performSelector:”。該方法的簽名如下,它接受一個參數,就是要執行的那個選擇子:
- (id)performSelector:(SEL)aSelector;
該方法與直接調用選擇子等效。所以下面兩行代碼的執行效果相同:
[object performSelector:@selector(selectorName)];
[object selectorName];
這種方式看上去似乎多余。如果某個方法只是這么來調用的話,那么此方式確實多余。然而,如果選擇子是在運行期決定的,那么就能體現出此方法的強大之處了。這就等于在動態綁定之上再次使用動態綁定,因而可以實現出下面這種功能:
SEL selecotr;
if(/*some condition*/){
selecotr = @selector(foo);
}else if (/*some other condition*/){
selecotr = @selector(bar);
}else{
selecotr = @selector(baz);
}
[object performSelector:selecotr];
這種編程方式極為靈活,經常可用來簡化復雜的代碼。還有一種用法,就是先把選擇子保存起來,等某個事件發生之后再調用。不管哪種用法,編譯器都不知道要執行的選擇子是什么,這必須到了運行期才能確定。然而,使用此特性的代價是,如果在ARC下編譯代碼,那么編譯器就會發出如下警告信息:
warning:performSelector may cause a leak because its selector
is unknown [-Warc-performSelector-leaks]
原因在于:編譯器并不知道將要調用的選擇子是什么,因此,也就不了解其方法簽名及返回值,甚至連是否有返回值都不清楚。而且,由于編譯器不知道方法名,所以就沒辦法運用ARC的內存管理規則來判定返回值是不是應該釋放。鑒于此,ARC采用了比較謹慎的做法,就是不添加釋放操作。然而這么做可能導致內存泄露,因為方法在返回對象時可能已經將其保留了。
考慮下面這段代碼:
SEL selecotr;
if(/*some condition*/){
selecotr = @selector(newObject);
}else if (/*some other condition*/){
selecotr = @selector(copy);
}else{
selecotr = @selector(someProperty);
}
id ret = [object performSelector:selecotr];
如果調用的是前兩個選擇子之一,那么ret對象應由這段代碼來釋放,而如果是第三個選擇子,則無須釋放。不僅在ARC環境下應該如此,而在在非ARC環境下也應該這么做,這樣才算嚴格遵循了方法的命名規范。如果不使用ARC(此時編譯器就不發警告信息了),那么在前兩種情況下需要手動釋放ret對象,而在后一種情況下則不需要釋放。這個問題很容易忽視,而且就算用靜態分析器,也很難偵測到隨后的內存泄露。performSelector系列的方法之所以要謹慎使用,這就是其中一個原因。
這些方法不甚理想,另一個原因在于:返回值只能是void或對象類型。盡管所要執行的選擇子也可以返回void,但是performSelector方法的返回值類型畢竟是id。如果想返回整數或浮點等類型的值,那么就需要執行一些復雜的轉換操作了,而這種轉換很容易出錯。由于id類型表示指向任意Objective-C對象的指針,所以從技術上來講,只要返回值的大小和指針所占大小相同就行,也就是說:在32位架構的計算機上,可以返回任意32位大小的類型;而在64位架構的計算機上,則可返回任意64位大小的類型。若返回值的類型為C語言結構退,則不可使用performSelector方法。
performSelector還有如下幾個版本,可以在發消息時順便傳遞參數:
- (id)performSelector:(SEL)aSelector withObject:(id)object;
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
比方說,可以用下面這個版本來設置對象中名為value的屬性值:
id object = /* an object with a property called value */;
id newValue = /* new value for the property */;
[object performSelector:@selector(setValue:) withObject:newValue];
這些方法貌似有用,但其實局限頗多。由于參數類型是id,所以傳入的參數必須是對象才行。如果選擇子所接受的參數是整數或浮點數,那就不能采用這些方法了。此外,選擇子最多只能接受兩個參數,也就是調用“performSelector: withObject: withObject:”這個版本。而在參數不止兩個的情況下,則沒有對應的performSelector方法能夠執行此種選擇子。
performSelector系列方法haunted有個功能,就是可以延后執行選擇子,或將其放在另一個線程上執行。下面列出了此方法中一些更為常用的版本:
- (void)performSelector:(SEL)aSelector
withObject:(nullable id)anArgument
afterDelay:(NSTimeInterval)delay;
- (void)performSelector:(SEL)aSelector
onThread:(NSThread *)thr
withObject:(nullable id)arg
waitUntilDone:(BOOL)wait;
- (void)performSelectorOnMainThread:(SEL)aSelector
withObject:(nullable id)arg
waitUntilDone:(BOOL)wait;
然而很快就會發覺,這些方法太過局限了。例如,具備延后執行功能的那些方法都無法處理帶有兩個參數的選擇子。而能夠指定執行線程的那些方法,則與之類似,所以也不是特別通用。如果要用這些方法,就得把許多參數都打包到字典中,然后在受調用的方法里將其提取出來,這樣會增加開銷,而且還可能出bug。
如果改用其他替代方案,那就不受這些限制了。最主要的替代方案就是使用塊。而且,performSelector系列方法所提供的線程功能,都可以通過在大中樞派發機制中使用塊來實現。延后執行可以用dispatch_after來實現,在另一個線程上執行任務則可以通過dispatch_sync及dispatch_async來實現。
例如,要延后執行某項任務,可以有下面兩種實現方式,而我們應該優先考慮第二種:
//Using performSelector:withObject:afterDelay:
[self performSelector:@selector(doSomething) withObject:nil afterDelay:5.0];
//Using dispatch_after
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0*NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
[self doSomething];
});
想把任務放在主線程上執行,也可以有下面兩種方式,而我們還是應該優選后者:
//Using performSelectorOnMainThread: withObject: waitUntilDone:
[self performSelectorOnMainThread:@selector(doSomething) withObject:nil waitUntilDone:NO];
//Using dispatch_async
//(or if waitUntilDone is YES, then dispatch_sync)
dispatch_async(dispatch_get_main_queue(), ^{
[self doSomething];
});
waitUntilDone為NO時相當于使用dispatch_async;waitUntilDone為YES時相當于使用dispatch_sync。
要點:
- performSelector系列方法在內存管理方面容易有疏失。它無法確定將要執行的選擇子具體是什么,因而ARC編譯器也就無法插入適當的內存管理方法。
- performSelector系列方法所能處理的選擇子太過局限了,選擇子的返回值類型及發送給方法的參數個數都受到限制。
- 如果想把任務放在另一個線程上執行,那么最好不要用performSelector系列方法,而是應該把任務封裝到塊里,然后調用大中樞派發機制的相關方法來實現。
43.掌握GCD及操作隊列的使用時機
GCD技術確實很棒,不過有時候采用標準系統庫的組件,效果會更好。一定要了解每項技巧的使用時機,如果選錯了工具,那么編出來的代碼就會難于維護。
很少有其他技術能與GCD的同步機制想媲美。對于那些只需執行一次的代碼來說,也是如此,使用GCD的dispatch_once最為方便。然而,在執行后臺任務時,GCD并不一定是最佳方式。還有一種技術叫做NSOperationQueue,它雖然與GCD不同,但是卻與之相關,開發者可以把操作以NSOperation子類的形式放在隊列中,而這些操作也能夠并發執行。其與GCD派發隊列有相似之處,這并非巧合。“操作隊列”(operation queue)在GCD之前就有了,其中某些設計原理操作隊列而流行,GCD就是基于這些原理構建的。實際上,從iOS4與Mac OS X 10.6開始,操作隊列在底層是用GCD來實現的。
在兩者的諸多差別中,首先要注意:GCD是純C的API,而操作隊列則是Objective-C的對象。在GCD中,任務用塊來表示,而塊是個輕量級數據結構。與之相反,“操作”(operation)則是個更為重量級的Objective-C對象。雖說如此,但GCD并不總是最佳方案。有時候采用對象所帶來的開銷微乎其微,使用完整對象所帶來的好處反而大大超過其缺點。
使用NSOperationQueue類的“addOperationWithBlock:”方法搭配NSBlockOperation類來使用操作隊列,其語法與純GCD方式非常相似。使用NSOperation及NSOperationQueue的好處如下:
- 取消某個操作。如果使用操作隊列,那么想要取消操作是很容易的。運行任務之前,可以在NSOperation對象上調用cancel方法,該方法會設置對象內的標志位,用以表明此任務不需執行,不過,已經啟動的任務無法取消。若是不是使用操作隊列,而是把塊安排到GCD隊列,那就無法取消了。那套架構是“安排好任務之后就不管了”(fire and forget)。開發者可以在應用程序層自己來實現取消功能,不過這樣做需要編寫很多代碼,而那些代碼其實已經由操作隊列實現好了。
- 指定操作間的依賴關系。一個操作可以依賴其他多個操作。開發者能夠指定操作之間的依賴體系,使特定的操作必須在另外一個操作順利執行完畢后方可執行。比方說,從服務器端下載并處理文件的動作,可以用操作來表示,而在處理其他文件之前,必須先下載“清單文件”(manifest file)。后續的下載操作,都要依賴于先下載清單文件這一操作。如果操作隊列允許并發的話,那么后續的多個下載操作就可以同時執行,但前提是它們所依賴的那個清單文件下載操作已經執行完畢。
- 通過鍵值觀測機制監控NSOperation對象的屬性。NSOperation對象有許多屬性都適合通過鍵值觀測機制(KVO)來監聽,比如可以通過isCancelled屬性來判斷任務是否已取消,又比如可以通過isFinished屬性來判斷任務是否已完成。如果想在某個任務變更其狀態時得到通知,或是想用比GCD更為精細的方式來控制所要執行的任務,那么鍵值觀測機制會很有用。
- 指定操作的優先級。操作的優先級表示此操作與隊列中的其他操作之間的優先級關系。優先級高的操作先執行,優先級低的后執行。操作隊列的調度算法雖“不透明”,但必然是經過一番深思熟慮才寫成的。反之,GCD則沒有直接實現此功能的辦法。GCD的隊列確實有優先級,不過那是針對整個隊列來說的,而不是針對每個塊來說的。而令開發者在GCD之上自己來編寫調度算法,又不太合適。因此,在優先級這一點上,操作隊列所提供的功能要比GCD更為便利。NSOperation對象也有“線程優先級”(thread priority),這決定了運行此操作的線程處在何種優先級上。用GCD也可以實現此功能,然而采用操作隊列更簡單,只需設置一個屬性。
- 重用NSOperation對象。系統內置了一些NSOperation的子類(比附NSBlockOperation)供開發者調用,要是不想用這些固有子類的話,那就得自己來創建了。這些類就是普通的Objective-C對象,能夠存放任何信息。對象在執行時可以充分利用存放于其中的信息,而且還可以隨意調用定義在類中的方法。這就比派發隊列中那些簡單的塊要強大許多。這些NSOperation類可以在代碼中多次使用,它們符合軟件開發中的“不重復”(Don’t Repeat Yourself,DRY)原則。
操作隊列有很多地方勝過派發隊列。操作隊列提供了多種執行任務的方式,而且都是寫好了的,直接就能使用。開發者不用再編寫復雜的調度器,也不用自己來實現取消操作或者指定操作優先級的功能,這些事情操作隊列都已經實現好了。
有一個API選用了操作隊列而非派發隊列,這就是NSNotificationCenter,開發者可通過其中的方法來注冊監聽器,以便在發生相關事件時得到通知,而這個方法接受的參數是塊,不是選擇子。方法原型如下:
- (id <NSObject>)addObserverForName:(nullable NSString *)name
object:(nullable id)obj
queue:(nullable NSOperationQueue *)queue
usingBlock:(void (^)(NSNotification *note))block ;
某些功能確實可以用高層的Objective-C方法來做,但這并不等于說它就一定比底層實現方案好。要想確定哪種方案更佳,最好還是測試一下性能。
要點:
- 在解決多線程與任務管理問題時,派發隊列并非唯一方案。
- 操作隊列提供了一套高層的Objective-C API,能實現純GCD所具備的絕大部分功能,而且還能完成一些更為復雜的操作,那些操作若改用GCD來實現,則需另外編寫代碼。
44.通過Dispatch Group機制,根據系統資源狀況來執行任務
dispatch group(派發分組,調度組)是GCD的一項特性,能夠把任務分組。調用者可以等待這組任務執行完畢,也可以在提供回調函數之后繼續往下執行,這組任務完成時,調用者會得到通知。這個功能有許多用途,其中最重要、最值得注意的用法,就是把將要并發執行的多個任務合為一個組,于是調用者就可以知道這些任務何時才能全部執行完畢。比方說,可以把壓縮一系列文件的任務表示成dispatch group。
下面這個函數可以創建dispatch group:
dispatch_group_t
dispatch_group_create(void);
dispatch group就是一個簡單的數據結構,這種數據結構彼此之間沒什么區別,它不像派發隊列,后者還有個用來區別身份的標識符。想把任務編組,有兩種辦法。第一種是用下面這個函數:
void
dispatch_group_async(dispatch_group_t group,
dispatch_queue_t queue,
dispatch_block_t block);
它是普通dispatch_async函數的變體,比原來多一個參數,用于表示待執行的塊所屬的組。還有種辦法能夠指定任務所屬的dispatch group,那就是使用下面這一對函數:
void
dispatch_group_enter(dispatch_group_t group);
void
dispatch_group_leave(dispatch_group_t group);
前者能夠使分組里正要執行的任務數遞增,而后者則使之遞減。由此可知,調用了dispatch_group_enter以后,必須有與之對應的dispatch_group_leave才行。這與引用計數相似,要使用引用計數,就必須令保留操作與釋放操作彼此對應,以防內存泄露。而在使用dispatch_group時,如果調用enter之后,沒有相應的leave操作,那么這一組任務就永遠執行不完。
下面這個函數可用于等待dispatch group執行完畢:
long
dispatch_group_wait(dispatch_group_t group, dispatch_time_t timeout);
此函數接受兩個參數,一個是要等待的group,另一個是代表等待時間的timeout值。timeout參數表示函數在等待dispatch group執行完畢時,應該阻塞多久。如果執行dispatch group所需的時間小于timeout,則返回0,否則返回非0值。此函數也可以取常量DISPATCH_TIME_FOREVER,這表示函數會一直等著dispatch group執行完,而不會超時。
除了可以用上面那個函數等待dispatch group執行完畢之外,也可以換個辦法,使用下列函數:
void
dispatch_group_notify(dispatch_group_t group,
dispatch_queue_t queue,
dispatch_block_t block);
與wait函數略有不同的是:開發者可以向此函數傳入塊,等dispatch group執行完畢之后,塊會在特定的線程上執行。假如當前線程不應阻塞,而開發者又想在那些任務全部完成時得到通知,那么此做法就很有必要了。比方說,在Mac OS X與iOS系統中,都不應阻塞主線程,因為所有UI繪制及事件處理都要在主線程上執行。
如果想令數組中的每個對象都執行某項任務,并且想等待所有任務執行完畢,那么就可以使用這個GCD特性來實現。代碼如下:
dispatch_queue_t queue =
dispatch_queue_create(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t dispatchGroup = dispatch_group_create();
for(id object in collection){
dispatch_group_async(dispatchGroup,
queue, ^{
[object performTask];
});
}
dispatch_group_wait(dispatchGroup, DISPATCH_TIME_FOREVER);
//Continue processing after completing tasks
若當前線程不應阻塞,則可以用notify函數來取代wait:
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup,
notifyQueue, ^{
//Continue processing after completing tasks
});
notify回調時所選用的隊列,完全應該根據具體情況來定。這里使用了主隊列,這是種常見寫法,也可以用自定義的串行隊列或全局并發隊列。
本例中,所有任務都派發到同一個隊列之中。但實際上未必一定要這樣做。也可以把某些任務放在優先級高的線程上執行,同時仍然把所有任務都歸入同一個dispatch group,并在執行完畢時獲得通知:
dispatch_queue_t lowPriorityQueue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
dispatch_queue_t highPriorityQueue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_group_t dispatchGroup = dispatch_group_create();
for(id object in lowPriorityObjects){
dispatch_group_async(dispatchGroup,
lowPriorityQueue,
^{
[object performTask];
});
}
for(id object in highPriorityObjects){
dispatch_group_async(dispatchGroup,
highPriorityQueue,
^{
[object performTask];
});
}
dispatch_queue_t notifyQueue = dispatch_get_main_queue();
dispatch_group_notify(dispatchGroup,
notifyQueue,
^{
//Countinue processing after completing tasks
});
除了像上面這樣把任務提交到并發隊列之外,也可以把任務提交至各個串行隊列中,并用dispatch group跟蹤其執行狀況。然而,如果所有任務都排在同一個串行隊列里面,那么dispatch group就用處不大了。因為此時任務總要逐個執行,所以只需在提交完全部任務之后再提交一個塊即可,這樣做與通過notify函數等待dispatch group執行完畢然后再回調塊是等效的:
dispatch_queue_t queue =
dispatch_queue_create("com.effectiveobjectivec.queue", NULL);
for(id object in collections){
dispatch_async(queue, ^{
[object performTask];
});
}
dispatch_async(queue, ^{
//Continue processing after completing tasks
});
上面這段代碼表明,開發者未必總是需要使用dispatch group。有時候采用單個隊列搭配標準的異步派發,也可以實現同樣效果。
為了執行隊列中的塊,GCD會在適當的時機自動創建新線程或復用舊線程。如果使用并發隊列,那么其中有可能會有多個線程,這也就意味著多個塊可以并發執行。在并發隊列中,執行任務所用的并發線程數量,取決于各種因素,而GCD主要是根據系統資源狀況來判斷這些因素的。加入CPU有多個核心,并發隊列中有大批任務等待執行,那么GCD就可能會給該隊列配置多個線程。通過dispatch group所提供的這種簡便方式,既可以并發執行一系列給定的任務,又能在全部任務結束時得到通知。由于GCD有并發隊列機制,所以能夠根據可用的系統資源狀況來并發執行任務。而開發者則可用專注于業務邏輯代碼,無須再為了處理并發任務而編寫復雜的調度器。
在前面的例子中,我們遍歷某個collection,并在其每個元素上執行任務,而這也可以用另外一個GCD函數來實現:
void
dispatch_apply(size_t iterations, dispatch_queue_t queue,
void (^block)(size_t));
此函數會將塊反復執行一定的次數,每次傳給塊的參數值都會遞增,從0開始,直至”iterations-1“。其用法如下:
dispatch_queue_t queue =
dispatch_queue_create("com.effectiveobjectivec.queue", NULL);
dispatch_apply(10, queue, ^(size_t i) {
//Perform task
});
采用簡單的for循環,從0遞增至9,也能實現同樣的效果:
for(int i=0;i<10;i++){
//Perform task
}
注意:dispatch_apply所用的隊列可以是并發隊列。如果采用并發隊列,那么系統就可以根據資源狀況來并行執行這些塊了,這與使用dispatch group的那段代碼一樣。上面這個for循環要處理的collection若是數組,則可以用dispatch_apply改寫成:
dispatch_queue_t queue =
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_apply(array.count, queue, ^(size_t i) {
id object = array[i];
[object performTask];
});
這個例子再次表明:未必總要使用dispatch_group。然而,dispatch_apply會持續阻塞,直到所有任務都執行完畢未知。由此可見:假如把塊派發給了當前隊列(或者體系中高于當前隊列的某個串行隊列),將導致死鎖。若想在后臺執行任務,則應使用dispatch group。
** 要點:**
- 一系列任務可歸入一個dispatch group之中。開發者可以在這組任務執行完畢時獲得通知。
- 通過dispatch group,可以在并發式派發隊列里同時執行多項任務。此時GCD會根據系統資源狀況來調度這些并發執行的任務。
45.使用dispatch_once來執行只需執行一次的線程安全代碼
單例模式(singleton)對Objective-C開發者來說并不陌生,常見的實現方式為:在類中編寫名為sharedInstance的方法,該方法只會返回全類共用的單例實例,而不會在每次調用時都創建新的實例。假設有個類叫EOCClass,那么這個共享實例的方法一般都會這樣寫:
+(instancetype)sharedInstance{
static EOCClass *sharedInstance = nil;
@synchronized (self) {
if(!sharedInstance){
sharedInstance = [[self alloc]init];
}
}
return sharedInstance;
}
為保證線程安全,上述代碼將創建單例實例的代碼包裹在同步塊里。
不過,GCD引入了一項特性,能使單例實現起來更為容易。所用的函數是:
void
dispatch_once(dispatch_once_t *predicate,
dispatch_block_t block);
此函數接受類型為dispatch_once_t的特殊參數,作者稱其為“標記”(token),此外還接受塊參數。對于給定的標記來說,該函數保證相關的塊必定會執行,其僅執行一次。首次調用該函數時,必然會執行塊中的代碼,最重要的一點在于,此操作完全是線程安全的。請注意,對于只需執行一次的塊來說,每次調用函數時傳入的標記都必須完全相同。因此,開發者通常將標記變量聲明在static或global作用域里。
剛才實現單例模式所用的sharedInstance方法,可以用此函數來改寫:
+(instancetype)sharedInstance{
static EOCClass *sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedInstance = [[self alloc]init];
});
return sharedInstance;
}
使用dispatch_once可以簡化代碼并且徹底保證線程安全,開發者根本無須擔心加鎖或同步。所有問題都由GCD在底層處理。由于每次調用時都必須使用完全相同的標記,所以標記要聲明稱static。把該變量定義在static作用域里,可以保證編譯器在每次執行sharedInstance方法時都會復用這個變量,而不會創建新變量。
此外,dispatch_once更高效。它沒有使用重量級的同步機制,若是那樣的話,每次運行代碼錢都要獲取鎖,相反,此函數采用“原子訪問”(atomic access)來查詢標記,以判斷其所對應的代碼原來是否已經執行過。
要點:
- 經常需要編寫“只需執行一次的線程安全代碼”(thread-safe single-code execution)。通過GCD所提供的dispatch_once函數,很容易就能實現此功能。
- 標記應該聲明在static或global作用域中,這樣的話,在把只需執行一次的塊傳給dispatch_once函數時,傳進去的標記也是相同的。
46.不要使用dispatch_get_current_queue
使用GCD時,經常需要判斷當前代碼正在哪個隊列上執行,向多個隊列派發任務時,更是如此。
dispatch_queue_t
dispatch_get_current_queue(void);
此函數返回當前正在執行代碼的隊列,不過用的時候要小心。從iOS系統6.0版本起,已經將其廢棄了。
該函數有種典型的錯誤用法(antipattern,“反模式”),就是用它檢測當前隊列是不是某個特定的隊列,試圖以此來避免執行同步派發時可能遭遇的死鎖問題。考慮下面這兩個存取方法,其代碼用隊列來保證對實例變量的訪問操作是同步的:
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
-(void)setSomeString:(NSString *)someString{
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
這種寫法的問題在于,獲取方法可能會死鎖,假如調用獲取方法的隊列恰好是同步操作所針對的隊列(本例中是_syncQueue),那么dispatch_sync就一直不會返回,直到塊執行完畢為止。可是,應該執行塊的那個目標隊列卻是當前隊列,而當前隊列的dispatch_sync又一直阻塞著,它在等待目標隊列把這個塊執行完,這樣一來,塊就永遠沒機會執行了。像someString這種方法,就是“不可重入的”。
看了dispatch_get_current_queue的文檔后,你也許會覺得可以用它改寫這個方法,令其變得“可重入”,只需檢測當前隊列是否為同步操作所針對的隊列,如果是,就不派發了,直接執行塊即可:
-(NSString *)someString{
__block NSString *localSomeString;
dispatch_block_t accessorBlock = ^{
localSomeString = _someString;
};
if(dispatch_get_current_queue()==_syncQueue){
accessorBlock();
}else{
dispatch_sync(_syncQueue, accessorBlock);
}
return localSomeString;
}
這種做法可以處理一些簡單情況。不過仍然有死鎖的危險。為說明其原因,考慮下面這段代碼,其中有兩個串行派發隊列:
dispatch_queue_t queueA =
dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB =
dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);
dispatch_sync(queueA, ^{
dispatch_sync(queueB, ^{
dispatch_sync(queueA, ^{
//Deadlock
});
});
});
這段代碼執行到最內層的派發操作時,總會死鎖,因為此操作是針對queueA隊列的,所以必須等最外層的dispatch_sync執行完畢才行,而最外層的那個dispatch_sync又不可能執行完畢,因為它要等最內層的dispatch_sync執行完,于是就死鎖了。現在按照剛才的辦法,使用dispatch_get_current_queue來檢測:
dispatch_sync(queueA, ^{
dispatch_sync(queueB, ^{
dispatch_block_t block = ^{/*...*/};
if(dispatch_get_current_queue()==queueA){
block();
}else{
dispatch_sync(queueA, block);
}
});
});
然而這樣做依然死鎖,因為dispatch_get_current_queue返回的是當前隊列,在本例中就是queueB。這樣的話,針對queueA的同步派發操作依然會執行,于是和剛才一樣,還是死鎖了。
在這種情況下,正確的做法是:不要把存取方法做成可重入的,而是應該確保同步操作所用的隊列絕不會訪問屬性,也就是絕不會調用someString方法。這種隊列只應該用來同步屬性。由于派發隊列是一種極為輕量級的機制,所以,為了確保每項屬性都有專用的同步隊列,我們不妨創建多個隊列。
使用隊列時還要注意另外一個問題,而那個問題會在你意想不到的地方導致死鎖。隊列之間會形成一套層級體系,這意味著排在某條隊列中的塊,會在其上級隊列(parent queue,也叫“父隊列”)里執行。層級里地位較高的那個隊列總是“全局并發隊列”。下圖描述了一套簡單的隊列體系:
排在隊列B或隊列C中的塊,稍后會在隊列A里依次執行。于是,排在隊列A、B、C中的塊總是要彼此錯開執行。然而,安排在隊列D中的塊,則有可能與隊列A里的塊(也包括隊列B與C里的塊)并行,因為A與D的目標隊列是個并發隊列。若有必要,并發隊列可以用多個線程并行執行多個塊,而是否會這樣做,則需根據CPU的核心數量等系統資源狀況來定。
由于隊列見有層級關系,所以“檢查當前隊列是否為執行同步派發所用的隊列”這種辦法,并不總是奏效。比方說,排在隊列C里的塊,會認為當前隊列就是隊列C,而開發者可能會據此認為:在隊列A上能夠安全地執行同步派發操作。但實際上,這么做依然會像前面那樣導致死鎖。
有的API可令開發者指定運行回調塊時所用的隊列,但實際上卻把回調塊安排在內部的串行同步隊列上,而內部隊列的目標隊列又是開發者所提供的那個隊列,在此情況下,也許就要出現剛才說的那種問題了。使用這種API的開發者可能誤以為:在回調塊里調用dispatch_get_current_queue所返回的“當前隊列”,總是其調用API時指定的那個。但實際上返回的卻是API內部的那個同步隊列。
要解決這個問題,最好的辦法就是通過GCD所提供的功能來設定“隊列特有數據”(queue-specific data),此功能可以把任意數據以鍵值對的形式關聯到隊列里。最重要之處在于,假如根據指定的鍵獲取不到關聯數據,那么系統就會沿著層級體系向上查找,直至找到數據或到達根隊列為止。看下面這個例子:
dispatch_queue_t queueA =
dispatch_queue_create("com.effectiveobjectivec.queueA", NULL);
dispatch_queue_t queueB =
dispatch_queue_create("com.effectiveobjectivec.queueB", NULL);
static int kQueueSpecific;
CFStringRef queueSpecificValue = CFSTR("queueA");
dispatch_queue_set_specific(queueA,
&kQueueSpecific,
(void*)queueSpecificValue,
(dispatch_function_t)CFRelease);
dispatch_sync(queueB, ^{
dispatch_block_t block = ^{NSLog(@"No deadlock!");};
CFStringRef retrievedValue =
dispatch_get_specific(&kQueueSpecific);
if(retrievedValue){
block();
}else{
dispatch_sync(queueA, block);
}
});
本例創建了兩個隊列。代碼中將隊列B的目標隊列設為隊列A,而隊列A的目標隊列仍然是默認優先級的全局并發隊列。然后使用下列函數,在隊列A上設置“隊列特定值”:
void
dispatch_queue_set_specific(dispatch_queue_t queue,
const void *key,
void *context,
dispatch_function_t destructor);
此函數的首個參數表示待設置數據的隊列,其后兩個參數是鍵與值。鍵與值都是不透明的void指針。對于鍵來說,有個問題一定要注意:函數是按指針值來比較鍵的,而不是按照其內容。所以,“隊列特定數據”更像是“關聯引用”。值(在函數中原型里叫context)也是不透明的void指針,于是可以在其中存放任意數據。然而,必須管理該對象的內存。這使得在ARC環境下很難使用Objective-C對象作為值。范例代碼使用CoreFoundation字符串作為值,因為ARC并不會自動管理CoreFoundation對象的內存。所以說,這種對象非常適合充當“隊列特定數據”,它們可以根據需要與相關的Objective-C Foundation類無縫銜接。
函數最后一個參數是“析構函數”,對于給定的鍵來說,當隊列所占內存為系統所回收,或者有新的值與鍵相關聯時,原有的值對象就會移除,而析構函數也會與于此時執行。dispatch_function_t類的定義如下:
typedef void (*dispatch_function_t)(void *);
由此可知,析構函數只能帶有一個指針參數且返回值必須為void。范例代碼采用CFRelease做析構函數,此函數符合要求,不過也可以采用開發者自定義的函數,在其中調用CFRelease以清理舊值,并完成其他必要的清理工作。
于是,“隊列特定數據”所提供的這套簡單易用的機制,就避免了使用dispatch_get_current_queue時經常遭遇的一個陷阱。此外,調試程序時也許會經常用到dispatch_get_current_queue。在此情況下,可以放心使用這個已經廢棄的方法,只是別把它編譯到發行版的程序里就行。
要點:
- dispatch_get_current_queue函數的行為常常與開發者所預期的不同。此函數已經廢棄,只應做調試之用。
- 由于派發隊列是按層級來組織的,所以無法單用某個隊列對象來描述“當前隊列”這一概念。
- dispatch_get_current_queue函數用于解決由不可重入的代碼所引發的死鎖,然而能用此函數解決的問題,通常也能改用“隊列特定數據”來解決。
轉載請注明出處:第六章 block與GCD(下)
參考:《Effective Objective-C 2.0》