第六章 block與GCD(下)

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》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,565評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,115評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,577評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,514評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,234評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,621評論 1 326
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,641評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,822評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,380評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,128評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,319評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,879評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,548評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,970評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,229評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,048評論 3 397
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,285評論 2 376

推薦閱讀更多精彩內容