一、dispatch_group
有時候我們會有這樣的需求:分別異步執行兩個耗時任務,當兩個耗時任務都完成以后回到主線程繼續執行其他任務.這時候就可以用到GCD的隊列組dispatch_group了.
Dispatch Group 會在整個組的任務都完成時通知你。這些任務可以是同步的,也可以是異步的,即便在不同的隊列也行。而且在整個組的任務都完成時,Dispatch Group 可以用同步的或者異步的方式通知你。因為要監控的任務在不同隊列,那就用一個 dispatch_group_t 的實例來記下這些不同的任務。
向隊列組中添加任務有兩種方式:
通過 dispatch_group_async 把任務放到隊列中,然后將隊列放入隊列組中
或者使用隊列組的 dispatch_group_enter、dispatch_group_leave 組合 來實現.
當組中所有的事件都完成時,GCD 的 API 提供了兩種通知方式。
第一種是 dispatch_group_wait 回到當前線程繼續執行,它會阻塞當前線程。
第二種是dispatch_group_notify回到指定線程執行任務.
我們先準備一個圖片下載的異步網絡請求:
- (instancetype)initwithURL:(NSURL *)url withCompletionBlock:(PhotoDownloadCompletionBlock)completionBlock{
static NSURLSession *session;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];
session = [NSURLSession sessionWithConfiguration:configuration];
});
NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
UIImage *image = [UIImage imageWithData:data];
NSLog(@"當前線程 %@",[NSThread currentThread]);
if (completionBlock) {
completionBlock(image, error);
}
}];
[task resume];
return self;
}
新建一個Photo類,實現上述實例方法,這是一個請求圖片的異步耗時任務,圖片下載好后,通過bolck回調回去;
現在,我們需要加載三張圖片,當三張圖片全部加載完成后,通知主線程刷新UI,在控制器中編寫如下代碼:
- (void)downloadPhotosWithCompletionBlock:(DownloadCompletionBlock)completionBlock
{
__block NSError *error;
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *urlArray = @[url1,url2,url3];
for (NSInteger i = 0; i < 3; i++) {
__unused Photo *photo = [[Photo alloc] initwithURL:urlArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
}];
}
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
}
用for循環創建三個下載任務,每下載好一張圖片就存儲到線程安全的單例中,等到全部圖片下載完成后通過completionBlock
通知主線程更新UI.
編譯運行:
2019-05-21 17:29:10.459040+0800 GCDDemo[22771:700230] 更新UI
2019-05-21 17:29:12.484551+0800 GCDDemo[22771:700513] 當前線程 <NSThread: 0x6000038c5980>{number = 3, name = (null)}
2019-05-21 17:29:15.766106+0800 GCDDemo[22771:700379] 當前線程 <NSThread: 0x6000038c6a80>{number = 4, name = (null)}
2019-05-21 17:29:15.766949+0800 GCDDemo[22771:700381] 當前線程 <NSThread: 0x6000038c6e80>{number = 5, name = (null)}
我們發現,更新UI的操作在圖片下載完成前就執行了;問題的癥結在于:你在方法的最后調用了 completionBlock ——因為此時你假設所有的照片都已下載完成。雖然Photo
類的實例方法是立即返回的,但是-[Photo initWithURL:withCompletionBlock:]
是異步執行的。
因此,只有在所有的圖像下載任務都完成以后才能調用completionBlock
。但問題是:你該如何監控并發的異步事件?你不知道它們何時完成,而且它們完成的順序完全是不確定的。
或許你可以寫一些比較 Hacky 的代碼,用多個布爾值來記錄每個下載的完成情況,但這樣做就缺失了擴展性,而且說實話,代碼會很難看。
幸運的是, 解決這種對多個異步任務的完成進行監控的問題,恰好就是設計 dispatch_group 的目的。
對應實現如下:
- (void)downloadPhotosWithCompletionBlock:(DownloadCompletionBlock)completionBlock
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // 1
__block NSError *error;
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *urlArray = @[url1,url2,url3];
dispatch_group_t downloadGroup = dispatch_group_create(); // 2
for (NSInteger i = 0; i < 3; i++) {
dispatch_group_enter(downloadGroup); // 3
__unused Photo *photo = [[Photo alloc] initwithURL:urlArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_group_leave(downloadGroup); // 4
}];
}
dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER); // 5
dispatch_async(dispatch_get_main_queue(), ^{ // 6
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
});
});
}
按照注釋的順序,你會看到:
1.因為你在使用的是同步的 dispatch_group_wait ,它會阻塞當前線程,所以你要用 dispatch_async 將整個方法放入后臺隊列以避免阻塞主線程。
2.創建一個新的 Dispatch Group。
3.dispatch_group_enter 通知 Dispatch Group 有任務添加到隊列組,每執行一次,Group 中未執行完畢任務數就會增加1。你必須保證 dispatch_group_enter 和 dispatch_group_leave 成對出現,否則你可能會遇到詭異的崩潰問題。
- dispatch_group_leave通知Dispatch Group 一個任務已經完成,使隊列組中未執行的任務數減少1,當 group 中未執行完畢任務數為0的時候,會使dispatch_group_wait解除阻塞,轉去執行dispatch_group_notify中的任務.
5.dispatch_group_wait 會一直等待,直到任務全部完成或者超時。如果在所有任務完成前超時了,該函數會返回一個非零值。你可以對此返回值做條件判斷以確定是否超出等待周期;然而,你在這里用 DISPATCH_TIME_FOREVER 讓它永遠等待。它的意思是,永-遠-等-待!這樣很好,因為圖片的創建工作總是會完成的。
6.此時此刻,你已經確保了,要么所有的圖片任務都已完成,要么發生了超時。然后,你在主線程上運行 completionBlock 回調。這會將工作放到主線程上,并在稍后執行。
編譯運行,結果也令人滿意:
2019-05-21 14:18:13.715618+0800 GCDDemo[15558:455095] currentThread == <NSThread: 0x600003881080>{number = 3, name = (null)}
2019-05-21 14:18:13.720905+0800 GCDDemo[15558:455096] currentThread == <NSThread: 0x600003882540>{number = 4, name = (null)}
2019-05-21 14:18:13.722634+0800 GCDDemo[15558:455094] currentThread == <NSThread: 0x600003880e80>{number = 5, name = (null)}
2019-05-21 14:18:13.722882+0800 GCDDemo[15558:455012] 更新UI
目前為止的解決方案還不錯,但在另一個隊列上異步調度然后使用 dispatch_group_wait 來阻塞實在顯得有些笨拙。是的,還有另一種方式……
在我們轉向另外一種使用 Dispatch Group 的方式之前,先看一個簡要的概述,關于何時以及如何組合不同的隊列類型來使用 Dispatch Group :
- 自定義串行隊列:它很適合當一組任務完成時發出通知。
- 主隊列(串行):它也很適合這樣的情況。但如果你要同步地等待所有工作地完成,那你就不應該使用它,因為你不能阻塞主線程。然而,異步模型是一個很有吸引力的能用于在幾個較長任務(例如網絡調用)完成后更新 UI 的方式。
- 并發隊列:它也很適合 Dispatch Group 和完成時通知。
Dispatch Group,第二種方式: dispatch_group_notify
首先要知道dispatch_group_notify是異步工作的:
在 PhotoManager.m 中找到 downloadPhotosWithCompletionBlock: 方法,用下面的實現替換它:
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
__block NSError *error;
dispatch_group_t downloadGroup = dispatch_group_create(); // 1
for (NSInteger i = 0; i < 3; i++) {
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *urlArray = @[url1,url2,url3];
dispatch_group_enter(downloadGroup); // 2
__unused Photo *photo = [[Photo alloc] initwithURL:urlArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_group_leave(downloadGroup); // 3
}];
}
dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ // 4
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
});
}
下面解釋新的異步方法如何工作:
1.在新的實現里,因為你沒有阻塞主線程,所以你并不需要將方法包裹在 async 調用中。
2.同樣的 enter 方法,沒做任何修改。
3.同樣的 leave 方法,也沒做任何修改。
4.dispatch_group_notify 以異步的方式工作。當 Dispatch Group 中沒有任何任務時,它就會執行其代碼,那么 completionBlock 便會運行。你還指定了運行 completionBlock 的隊列,此處,主隊列就是你所需要的。
對于這個特定的工作,上面的處理明顯更清晰,而且也不會阻塞任何線程。
太多并發帶來的風險:
看看 PhotoManager 中的downloadPhotosWithCompletionBlock 方法。你可能已經注意到這里的 for 循環,它迭代三次,下載三個不同的圖片。你的任務是嘗試讓 for 循環并發運行,以提高其速度。
dispatch_apply 剛好可用于這個任務。
dispatch_apply 表現得就像一個 for 循環,但它能并發地執行不同的迭代。這個函數是同步的,所以和普通的 for 循環一樣,它只會在所有工作都完成后才會返回。
當在 Block 內計算任何給定數量的工作的最佳迭代數量時,必須要小心,因為過多的迭代和每個迭代只有少量的工作會導致大量開銷以致它能抵消任何因并發帶來的收益。而被稱為跨越式(striding)的技術可以在此幫到你,即通過在每個迭代里多做幾個不同的工作。
那何時才適合用 dispatch_apply 呢?
- 自定義串行隊列:串行隊列會完全抵消 dispatch_apply 的功能;你還不如直接使用普通的 for 循環。
- 主隊列(串行):與上面一樣,在串行隊列上不適合使用 dispatch_apply 。還是用普通的 for 循環吧。
- 并發隊列:對于并發循環來說是很好選擇,特別是當你需要追蹤任務的進度時。
回到 downloadPhotosWithCompletionBlock: 并用下列實現替換它:
- (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
{
__block NSError *error;
dispatch_group_t downloadGroup = dispatch_group_create(); // 1
dispatch_apply(3, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^(size_t i) { // 2
NSURL *url;
switch (i) {
case 0:
url = [NSURL URLWithString:@"URL1"];
break;
case 1:
url = [NSURL URLWithString:@"URL2"];
break;
case 2:
url = [NSURL URLWithString:@"URL3"];
break;
default:
break;
}
dispatch_group_enter(downloadGroup); // 3
__unused Photo *photo = [[Photo alloc] initwithURL:url
withCompletionBlock:^(UIImage *image, NSError *_error)
{
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_group_leave(downloadGroup); // 4
}];
});
dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ // 5
if (completionBlock) {
completionBlock([[PhotoManager sharedManager] photos]);
}
});
}
你的循環現在是并行運行的了;
在上面的代碼中,在調用 dispatch_apply 時,第一個參數指明了迭代的次數,第二個參數指定了任務運行的隊列,而第三個參數是一個 Block。
要知道雖然你有代碼保證添加相片時線程安全,但圖片的順序卻可能不同,這取決于線程完成的順序。
編譯并運行,
至于要不要將循環寫成并行運行的,我們要考慮的就是新建線程所帶來的開銷是否比執行for循環小得多...
實際上,在這個例子里并不值得。下面是原因:
- 你創建并行運行線程而付出的開銷,很可能比直接使用
for
循環要多。若你要以合適的步長迭代非常大的集合,那才應該考慮使用dispatch_apply
。 - 你用于創建應用的時間是有限的——除非實在太糟糕否則不要浪費時間去提前優化代碼。如果你要優化什么,那去優化那些明顯值得你付出時間的部分。你可以通過在 Instruments 里分析你的應用,找出最長運行時間的方法。
- 通常情況下,優化代碼會讓你的代碼更加復雜,不利于你自己和其他開發者閱讀。請確保添加的復雜性能換來足夠多的好處。
二、GCD信號量 : dispatch_semaphore
GCD中的信號量是指dispatch_semaphore,是一種多線程技術.
信號量讓你控制多個訪問者對有限數量資源的訪問。舉例來說,如果你創建了一個有著兩個資源的信號量,那同時最多只能有兩個線程可以訪問臨界區。其他想使用資源的線程必須在FIFO隊列里等待。
dispatch_semaphore 通過信號量控制著線程對臨界區的訪問,當計數信號量大于等于零時,允許訪問,計數信號量小于零時,通過阻塞禁止訪問.
dispatch_semaphore_create提供三個函數:
- 創建信號:dispatch_semaphore_create(0);
1.使用初始值創建新的計數信號量,初始值不可以小于0。
2.當兩個線程需要協調特定事件的完成時,為值傳遞0是有用的。
3.傳遞大于零的值對于管理有限的資源池非常有用,其中池大小等于該值。
- 發送信號:dispatch_semaphore_signal(semaphore);
增加計數信號量。如果前一個值小于零,這個函數將喚醒當前在dispatch_semaphore_wait中等待的線程。
- 等待信號:dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
減少計數信號量。如果結果值小于零,則此函數將等待信號出現后返回。
注意:dispatch_semaphore_signal調用必須與dispatch_semaphore_wait調用保持平衡。試圖處理計數值小于0的信號會導致EXC_BAD_INSTRUCTION異常。
信號量在實際開發中的用途主要有:
1.線程同步
2.線程加鎖
3.控制線程并發數
在上面的示例中,三張圖片被下載是異步的,自然圖片下載完成的順序也就無法保證.在實際開發中,可能會遇到要按序下載圖片的需求,比如將一張大圖分解為多張小圖下載然后在客戶端重新拼接,這樣就要求我們保證下載的順序.
- (void)testSemaphore{
dispatch_queue_t queque = dispatch_queue_create("com.testSemaphore", DISPATCH_QUEUE_CONCURRENT); // 1
dispatch_async(queque, ^{ // 2
NSURL *url1 = [NSURL URLWithString:URL1];
NSURL *url2 = [NSURL URLWithString:URL2];
NSURL *url3 = [NSURL URLWithString:URL3];
NSArray *imageArray = @[url1,url2,url3];
for (NSInteger i=0;i<imageArray.count;i++) {
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); // 3
__block NSError *error;
__unused Photo *photo = [[Photo alloc] initwithURL:imageArray[i]
withCompletionBlock:^(UIImage *image, NSError *_error) {
if (_error) {
error = _error;
}else{
[[PhotoManager sharedManager] addPhoto:image];
}
dispatch_semaphore_signal(semaphore); // 4
}];
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); // 5
}
dispatch_async(dispatch_get_main_queue(), ^{ // 6
NSLog(@"更新UI");
[self updateUIWithArray:[[PhotoManager sharedManager] photos]];
});
});
}
我們看一下信號量是如何工作的:
1.首先創建一個并發隊列;
2.創建一個異步任務,添加到隊列中;
3.dispatch_semaphore_create
初始化信號為0,第一張圖片開始異步加載;
4.任務執行到dispatch_semaphore_wait
信號量計數減1, 此時信號為-1,線程在此等待;
5.當有圖片下載完成, dispatch_semaphore_signal 使總信號量+1,剛剛被阻塞的線程恢復執行,程序進行下一次循環,開始下載第二張圖片....圖片得以順序下載;
6.圖片全部下載完成或者超時停止,回到主線程更新UI.
如果你的代碼所在的進程中有多條線程在運行,這些線程就有機會同時執行你的這段代碼,如果這段代碼不是線程安全的,那將會引發詭異的異常發生.
就像經典的賣票問題,票源的數量是固定的,我們同時開啟多條線程同時賣票,對于查詢余票的方法,余票的數量是一個全局的變量,就會面臨線程安全問題,因為它可以被多條線程同時訪問.
我們先在viewDidLoad
中開幾條線程,異步訪問此賣票方法,來模擬賣票的情景:
- (void)viewDidLoad {
[super viewDidLoad];
self.tickesCount = 10;
dispatch_queue_t queue1 = dispatch_queue_create("com.TickesQueue1", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue2 = dispatch_queue_create("com.TickesQueue2", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue3 = dispatch_queue_create("com.TickesQueue3", DISPATCH_QUEUE_SERIAL);
__weak __typeof__ (self) weakSelf = self;
dispatch_async(queue1, ^{
for (int i = 0; i < 5; i ++) {
[NSThread sleepForTimeInterval:1];
[weakSelf tickesSell];
}
});
dispatch_async(queue2, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
dispatch_async(queue3, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
}
下面是賣票方法的具體實現:
- (void)tickesSell
{
if (self.tickesCount > 0) { //如果還有票,繼續售賣
[NSThread sleepForTimeInterval:1];//模擬出票
self.tickesCount--;
NSLog(@"剩余票數:%ld 訪問線程%@", (long)self.tickesCount, [NSThread currentThread]);
} else { //如果已賣完,關閉售票窗口
NSLog(@"沒有票啦 !!!");
}
}
運行結果:
發現整個運行是錯亂的,而且出現了余票為負數的錯誤情況.
分析發現,賣票方法不是線程安全的. 我們在多個線程同時調用這個方法,有一個可能性是在某個線程(線程1)上進入 if
語句塊并且此時 tickesCount
恰好為1(既當前僅剩一張余票),接下來執行出票任務,賣出了最后一張票,但還沒來得及修改余票數.此時發生一個上下文切換;然后在另一個線程(線程2)進入 if
條件分支,此時tickesCount
卻依然是1,準備執行出票任務。
接著,系統上下文切換回線程1
,這時線程1
才將余票tickesCount
的值修改為0.
隨后系統上下文再切回線程2
執行出票任務,繼續將余票數量減1,很明顯程序會在此時發生異常,因為此時已經沒票了.這是一個很明顯的資源爭奪問題,在多線程中較為常見.
信號量可以控制線程對于臨界區的訪問,而將過多的線程訪問放在FIFO隊列中進行排隊.剛好可以用來解決這個問題:
semaphore為為線程加鎖
在viewDidLoad
中調用dispatch_semaphore_create(1)初始化semaphore,其他代碼保持不動;
- (void)viewDidLoad {
[super viewDidLoad];
self.tickesCount = 10;
self.semaphore = dispatch_semaphore_create(1);
dispatch_queue_t queue1 = dispatch_queue_create("com.TickesQueue1", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue2 = dispatch_queue_create("com.TickesQueue2", DISPATCH_QUEUE_SERIAL);
dispatch_queue_t queue3 = dispatch_queue_create("com.TickesQueue3", DISPATCH_QUEUE_SERIAL);
__weak __typeof__ (self) weakSelf = self;
dispatch_async(queue1, ^{
for (int i = 0; i < 5; i ++) {
[NSThread sleepForTimeInterval:1];
[weakSelf tickesSell];
}
});
dispatch_async(queue2, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
dispatch_async(queue3, ^{
for (int i = 0; i < 5; i ++) {
[weakSelf tickesSell];
}
});
}
我們替換賣票的方法:
- (void)tickesSell
{
dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER); // 1
if (self.tickesCount > 0) { //如果還有票,繼續售賣 // 2
[NSThread sleepForTimeInterval:1]; // 3
self.tickesCount--; // 4
NSLog(@"剩余票數:%ld 訪問線程%@", (long)self.tickesCount, [NSThread currentThread]);
} else { //如果已賣完,關閉售票窗口
NSLog(@"沒有票啦 !!!");
}
dispatch_semaphore_signal(_semaphore); // 5
}
編譯運行:
對于上面代碼執行的理解:
在viewDidLoad
中初始化semaphore
,初始值信號計數設置為1
;
1.在此處執行dispatch_semaphore_wait
,使信號量減一變為0,阻斷新的線程進入,被阻斷的線程進入隊列中進行等待,等待信號開啟;
2.判斷是否還有余票;
3.sleepForTimeInterval
耗時任務模擬賣票過程;
4.出票以后,將票源數量減一;
5.執行完一次完整賣票任務,激活信號量,使其值增加為1,允許線程進入訪問;
我們通過控制信號量維持在1和0之間,來接受和拒絕線程訪問,進而達到線程同步的目的,通過上面的思路是不是也就說明了我們可以通過信號量來達到在GCD中控制線程數的目的?
答案是肯定的:
- (void)maxConcurrent
{
dispatch_semaphore_t semaphore = dispatch_semaphore_create(10); // 1
dispatch_queue_t queue = dispatch_queue_create("com.maxConcurrent.Test", DISPATCH_QUEUE_CONCURRENT); // 2
for (int i = 0; i < 100; i++) // 3
{
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); // 4
dispatch_async(queue, ^{ // 5
[NSThread sleepForTimeInterval:0.1*i]; // 6
NSLog(@"當前執行任務:%d 當前線程:%@",i+1,[NSThread currentThread]);
dispatch_semaphore_signal(semaphore); // 7
});
}
}
按照注釋的順序,你會看到:
1.初始化一個信號量,信號量初始值為10,這將允許最多10個線程同時訪問;
2.開啟一個并發隊列,用于接收大量的異步任務;
3.創建一個100次的循環;
- dispatch_semaphore_wait使信號量減1;
5.將任務加入隊列;
[參考]:
Objective-C高級編程,iOS和OS X多線程和內存管理;
@nixzhu在Github上的譯文,
https://github.com/nixzhu/dev-blog/blob/master/2014-04-19-grand-central-dispatch-in-depth-part-1.md
原作者:Derek Selander
原文地:[http://www.raywenderlich.com/60749/grand-central-dispatch-in-depth-part-1]