[多執行緒]GCD深入理解(二)
歡迎來到GCD深入理解系列教程的第二部分(也是最後一部分)。
在本系列的第一部分中,你已經學到超過你想像的關於併發、執行緒以及GCD 如何工作的知識。通過在初始化時利用 dispatch_once,你建立了一個執行緒安全的 PhotoManager 單例,而且你通過使用 dispatch_barrier_async 和 dispatch_sync 的組合使得對 Photos 陣列的讀取和寫入都變得執行緒安全了。
除了上面這些,你還通過利用 dispatch_after 來延遲顯示提示資訊,以及利用 dispatch_async 將 CPU 密集型任務從 ViewController 的初始化過程中剝離出來非同步執行,達到了增強應用的使用者體驗的目的。
如果你一直跟著第一部分的教程在寫程式碼,那你可以繼續你的工程。但如果你沒有完成第一部分的工作,或者不想重用你的工程,你可以下載第一部分最終的程式碼。
那就讓我們來更深入地探索 GCD 吧!
糾正過早彈出的提示
你可能已經注意到當你嘗試用 Le Internet 選項來新增圖片時,一個 UIAlertView 會在圖片下載完成之前就彈出,如下如所示:
問題的癥結在 PhotoManagers 的 downloadPhotoWithCompletionBlock: 裡,它目前的實現如下:
- - (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
- {
- __block NSError *error;
- for (NSInteger i = 0; i < 3; i++) {
- NSURL *url;
- switch (i) {
- case 0:
- url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
- break;
- case 1:
- url = [NSURL URLWithString:kSuccessKidURLString];
- break;
- case 2:
- url = [NSURL URLWithString:kLotsOfFacesURLString];
- break;
- default:
- break;
- }
- Photo *photo = [[Photo alloc] initwithURL:url
- withCompletionBlock:^(UIImage *image, NSError *_error) {
- if (_error) {
- error = _error;
- }
- }];
- [[PhotoManager sharedManager] addPhoto:photo];
- }
- if (completionBlock) {
- completionBlock(error);
- }
- }
在方法的最後你呼叫了 completionBlock ——因為此時你假設所有的照片都已下載完成。但很不幸,此時並不能保證所有的下載都已完成。
Photo 類的例項方法用某個 URL 開始下載某個檔案並立即返回,但此時下載並未完成。換句話說,當 downloadPhotoWithCompletionBlock: 在其末尾呼叫 completionBlock 時,它就假設了它自己所使用的方法全都是同步的,而且每個方法都完成了它們的工作。
然而,-[Photo initWithURL:withCompletionBlock:] 是非同步執行的,會立即返回——所以這種方式行不通。
因此,只有在所有的影象下載任務都呼叫了它們自己的 Completion Block 之後,downloadPhotoWithCompletionBlock: 才能呼叫它自己的 completionBlock 。問題是:你該如何監控併發的非同步事件?你不知道它們何時完成,而且它們完成的順序完全是不確定的。
或許你可以寫一些比較 Hacky 的程式碼,用多個布林值來記錄每個下載的完成情況,但這樣做就缺失了擴充套件性,而且說實話,程式碼會很難看。
幸運的是, 解決這種對多個非同步任務的完成進行監控的問題,恰好就是設計 dispatch_group 的目的。
Dispatch Groups(排程組)
Dispatch Group 會在整個組的任務都完成時通知你。這些任務可以是同步的,也可以是非同步的,即便在不同的佇列也行。而且在整個組的任務都完成時,Dispatch Group 可以用同步的或者非同步的方式通知你。因為要監控的任務在不同佇列,那就用一個 dispatch_group_t 的例項來記下這些不同的任務。
當組中所有的事件都完成時,GCD 的 API 提供了兩種通知方式。
第一種是 dispatch_group_wait ,它會阻塞當前執行緒,直到組裡面所有的任務都完成或者等到某個超時發生。這恰好是你目前所需要的。
開啟 PhotoManager.m,用下列實現替換 downloadPhotosWithCompletionBlock:
- - (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
- {
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // 1
- __block NSError *error;
- dispatch_group_t downloadGroup = dispatch_group_create(); // 2
- for (NSInteger i = 0; i < 3; i++) {
- NSURL *url;
- switch (i) {
- case 0:
- url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
- break;
- case 1:
- url = [NSURL URLWithString:kSuccessKidURLString];
- break;
- case 2:
- url = [NSURL URLWithString:kLotsOfFacesURLString];
- break;
- default:
- break;
- }
- dispatch_group_enter(downloadGroup); // 3
- Photo *photo = [[Photo alloc] initwithURL:url
- withCompletionBlock:^(UIImage *image, NSError *_error) {
- if (_error) {
- error = _error;
- }
- dispatch_group_leave(downloadGroup); // 4
- }];
- [[PhotoManager sharedManager] addPhoto:photo];
- }
- dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER); // 5
- dispatch_async(dispatch_get_main_queue(), ^{ // 6
- if (completionBlock) { // 7
- completionBlock(error);
- }
- });
- });
- }
按照註釋的順序,你會看到:
1. 因為你在使用的是同步的 dispatch_group_wait ,它會阻塞當前執行緒,所以你要用 dispatch_async 將整個方法放入後臺佇列以避免阻塞主執行緒。
2. 建立一個新的 Dispatch Group,它的作用就像一個用於未完成任務的計數器。
3. dispatch_group_enter 手動通知 Dispatch Group 任務已經開始。你必須保證 dispatch_group_enter 和 dispatch_group_leave 成對出現,否則你可能會遇到詭異的崩潰問題。
4. 手動通知 Group 它的工作已經完成。再次說明,你必須要確保進入 Group 的次數和離開 Group 的次數相等。
5. dispatch_group_wait 會一直等待,直到任務全部完成或者超時。如果在所有任務完成前超時了,該函式會返回一個非零值。你可以對此返回值做條件判斷以確定是否超出等待週期;然而,你在這裡用 DISPATCH_TIME_FOREVER 讓它永遠等待。它的意思,勿庸置疑就是,永-遠-等-待!這樣很好,因為圖片的建立工作總是會完成的。
6. 此時此刻,你已經確保了,要麼所有的圖片任務都已完成,要麼發生了超時。然後,你在主執行緒上執行 completionBlock 回撥。這會將工作放到主執行緒上,並在稍後執行。
7. 最後,檢查 completionBlock 是否為 nil,如果不是,那就執行它。
編譯並執行你的應用,嘗試下載多個圖片,觀察你的應用是在何時執行 completionBlock 的。
注意:如果你是在真機上執行應用,而且網路活動發生得太快以致難以觀察 completionBlock 被呼叫的時刻,那麼你可以在 Settings 應用裡的開發者相關部分裡開啟一些網路設定,以確保程式碼按照我們所期望的那樣工作。只需去往 Network Link Conditioner
區,開啟它,再選擇一個 Profile,“Very Bad Network” 就不錯。 |
如果你是在模擬器裡執行應用,你可以使用 來自 GitHub 的 Network Link Conditioner 來改變網路速度。它會成為你工具箱中的一個好工具,因為它強制你研究你的應用在連線速度並非最佳的情況下會變成什麼樣。
目前為止的解決方案還不錯,但是總體來說,如果可能,最好還是要避免阻塞執行緒。你的下一個任務是重寫一些方法,以便當所有下載任務完成時能非同步通知你。
在我們轉向另外一種使用 Dispatch Group 的方式之前,先看一個簡要的概述,關於何時以及怎樣使用有著不同的佇列型別的 Dispatch Group :
1. 自定義序列佇列:它很適合當一組任務完成時發出通知。
2. 主佇列(序列):它也很適合這樣的情況。但如果你要同步地等待所有工作地完成,那你就不應該使用它,因為你不能阻塞主執行緒。然而,非同步模型是一個很有吸引力的能用於在幾個較長任務(例如網路呼叫)完成後更新 UI 的方式。
3. 併發佇列:它也很適合 Dispatch Group 和完成時通知。
Dispatch Group,第二種方式
上面的一切都很好,但在另一個佇列上非同步排程然後使用 dispatch_group_wait 來阻塞實在顯得有些笨拙。是的,還有另一種方式……
在 PhotoManager.m 中找到 downloadPhotosWithCompletionBlock: 方法,用下面的實現替換它:
- - (void)downloadPhotosWithCompletionBlock:(BatchPhotoDownloadingCompletionBlock)completionBlock
- {
- // 1
- __block NSError *error;
- dispatch_group_t downloadGroup = dispatch_group_create();
- for (NSInteger i = 0; i < 3; i++) {
- NSURL *url;
- switch (i) {
- case 0:
- url = [NSURL URLWithString:kOverlyAttachedGirlfriendURLString];
- break;
- case 1:
- url = [NSURL URLWithString:kSuccessKidURLString];
- break;
- case 2:
- url = [NSURL URLWithString:kLotsOfFacesURLString];
- break;
- default:
- break;
- }
- dispatch_group_enter(downloadGroup); // 2
- Photo *photo = [[Photo alloc] initwithURL:url
- withCompletionBlock:^(UIImage *image, NSError *_error) {
- if (_error) {
- error = _error;
- }
- dispatch_group_leave(downloadGroup); // 3
- }];
- [[PhotoManager sharedManager] addPhoto:photo];
- }
- dispatch_group_notify(downloadGroup, dispatch_get_main_queue(), ^{ // 4
- if (completionBlock) {
- completionBlock(error);
- }
- });
- }
下面解釋新的非同步方法如何工作:
1. 在新的實現裡,因為你沒有阻塞主執行緒,所以你並不需要將方法包裹在 async 呼叫中。
2. 同樣的 enter 方法,沒做任何修改。
3. 同樣的 leave 方法,也沒做任何修改。
4. dispatch_group_notify 以非同步的方式工作。當 Dispatch Group 中沒有任何任務時,它就會執行其程式碼,那麼 completionBlock 便會執行。你還指定了執行 completionBlock 的佇列,此處,主佇列就是你所需要的。
對於這個特定的工作,上面的處理明顯更清晰,而且也不會阻塞任何執行緒。
太多併發帶來的風險
既然你的工具箱裡有了這些新工具,你大概做任何事情都想使用它們,對吧?
看看 PhotoManager 中的 downloadPhotosWithCompletionBlock 方法。