深入理解 GCD(二)

發表於2015-01-08

歡迎來到GCD深入理解系列教程的第二部分(也是最後一部分)。

在本系列的第一部分中,你已經學到超過你想像的關於併發、執行緒以及GCD 如何工作的知識。通過在初始化時利用dispatch_once,你建立了一個執行緒安全的 PhotoManager 單例,而且你通過使用 dispatch_barrier_async 和dispatch_sync 的組合使得對 Photos 陣列的讀取和寫入都變得執行緒安全了。

除了上面這些,你還通過利用 dispatch_after 來延遲顯示提示資訊,以及利用 dispatch_async 將 CPU 密集型任務從 ViewController 的初始化過程中剝離出來非同步執行,達到了增強應用的使用者體驗的目的。

如果你一直跟著第一部分的教程在寫程式碼,那你可以繼續你的工程。但如果你沒有完成第一部分的工作,或者不想重用你的工程,你可以下載第一部分最終的程式碼

那就讓我們來更深入地探索 GCD 吧!

糾正過早彈出的提示

你可能已經注意到當你嘗試用 Le Internet 選項來新增圖片時,一個 UIAlertView 會在圖片下載完成之前就彈出,如下如所示:

問題的癥結在 PhotoManagers 的 downloadPhotoWithCompletionBlock: 裡,它目前的實現如下:

在方法的最後你呼叫了 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:

按照註釋的順序,你會看到:

  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 :

  • 自定義序列佇列:它很適合當一組任務完成時發出通知。
  • 主佇列(序列):它也很適合這樣的情況。但如果你要同步地等待所有工作地完成,那你就不應該使用它,因為你不能阻塞主執行緒。然而,非同步模型是一個很有吸引力的能用於在幾個較長任務(例如網路呼叫)完成後更新 UI 的方式。
  • 併發佇列:它也很適合 Dispatch Group 和完成時通知。

Dispatch Group,第二種方式

上面的一切都很好,但在另一個佇列上非同步排程然後使用 dispatch_group_wait 來阻塞實在顯得有些笨拙。是的,還有另一種方式……

在 PhotoManager.m 中找到 downloadPhotosWithCompletionBlock: 方法,用下面的實現替換它:

下面解釋新的非同步方法如何工作:

  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: 並用下列實現替換它:

你的迴圈現在是並行執行的了;在上面的程式碼中,在呼叫 dispatch_apply 時,你用第一次引數指明瞭迭代的次數,用第二個引數指定了任務執行的佇列,而第三個引數是一個 Block。

要知道雖然你有程式碼保證新增相片時執行緒安全,但圖片的順序卻可能不同,這取決於執行緒完成的順序。

編譯並執行,然後從 “Le Internet” 新增一些照片。注意到區別了嗎?

在真機上執行新程式碼會稍微更快的得到結果。但我們所做的這些提速工作真的值得嗎?

實際上,在這個例子裡並不值得。下面是原因:

  • 你建立並行執行執行緒而付出的開銷,很可能比直接使用 for 迴圈要多。若你要以合適的步長迭代非常大的集合,那才應該考慮使用 dispatch_apply
  • 你用於建立應用的時間是有限的——除非實在太糟糕否則不要浪費時間去提前優化程式碼。如果你要優化什麼,那去優化那些明顯值得你付出時間的部分。你可以通過在 Instruments 裡分析你的應用,找出最長執行時間的方法。看看 如何在 Xcode 中使用 Instruments 可以學到更多相關知識。
  • 通常情況下,優化程式碼會讓你的程式碼更加複雜,不利於你自己和其他開發者閱讀。請確保新增的複雜效能換來足夠多的好處。

記住,不要在優化上太瘋狂。你只會讓你自己和後來者更難以讀懂你的程式碼。

GCD 的其他趣味

等一下!還有更多!有一些額外的函式在不同的道路上走得更遠。雖然你不會太頻繁地使用這些工具,但在對的情況下,它們可以提供極大的幫助。

阻塞——正確的方式

這可能聽起來像是個瘋狂的想法,但你知道 Xcode 已有了測試功能嗎?:] 我知道,雖然有時候我喜歡假裝它不存在,但在程式碼裡構建複雜關係時編寫和執行測試非常重要。

Xcode 裡的測試在 XCTestCase 的子類上執行,並執行任何方法簽名以 test 開頭的方法。測試在主執行緒執行,所以你可以假設所有測試都是序列發生的。

當一個給定的測試方法執行完成,XCTest 方法將考慮此測試已結束,並進入下一個測試。這意味著任何來自前一個測試的非同步程式碼會在下一個測試執行時繼續執行。

網路程式碼通常是非同步的,因此你不能在執行網路獲取時阻塞主執行緒。也就是說,整個測試會在測試方法完成之後結束,這會讓對網路程式碼的測試變得很困難。也就是,除非你在測試方法內部阻塞主執行緒直到網路程式碼完成。

注意:有一些人會說,這種型別的測試不屬於整合測試的首選集(Preferred Set)。一些人會贊同,一些人不會。但如果你想做,那就去做。

導航到 GooglyPuffTests.m 並檢視 downloadImageURLWithString:,如下:

這是一種測試非同步網路程式碼的幼稚方式。 While 迴圈在函式的最後一直等待,直到 isFinishedDownloading 布林值變成 True,它只會在 Completion Block 裡發生。讓我們看看這樣做有什麼影響。

通過在 Xcode 中點選 Product / Test 執行你的測試,如果你使用預設的鍵繫結,也可以使用快捷鍵 ⌘+U 來執行你的測試。

在測試執行時,注意 Xcode debug 導航欄裡的 CPU 使用率。這個設計不當的實現就是一個基本的 自旋鎖 。它很不實用,因為你在 While 迴圈裡浪費了珍貴的 CPU 週期;而且它也幾乎沒有擴充套件性。

譯者注:所謂自旋鎖,就是某個執行緒一直搶佔著 CPU 不斷檢查以等到它需要的情況出現。因為現代作業系統都是可以併發執行多個執行緒的,所以它所等待的那個執行緒也有機會被排程執行,這樣它所需要的情況早晚會出現。

你可能需要使用前面提到的 Network Link Conditioner ,已便清楚地看到這個問題。如果你的網路太快,那麼自旋只會在很短的時間裡發生,難以觀察。

譯者注:作者反覆提到網速太快,而我們還需要對付 GFW,簡直淚流滿面!

你需要一個更優雅、可擴充套件的解決方案來阻塞執行緒直到資源可用。歡迎來到訊號量。

訊號量

訊號量是一種老式的執行緒概念,由非常謙卑的 Edsger W. Dijkstra 介紹給世界。訊號量之所以比較複雜是因為它建立在作業系統的複雜性之上。

如果你想學到更多關於訊號量的知識,看看這個連結它更細緻地討論了訊號量理論。如果你是學術型,那可以看一個軟體開發中經典的哲學家進餐問題,它需要使用訊號量來解決。

訊號量讓你控制多個消費者對有限數量資源的訪問。舉例來說,如果你建立了一個有著兩個資源的訊號量,那同時最多隻能有兩個執行緒可以訪問臨界區。其他想使用資源的執行緒必須在一個…你猜到了嗎?…FIFO佇列裡等待。

讓我們來使用訊號量吧!

開啟 GooglyPuffTests.m 並用下列實現替換 downloadImageURLWithString:

下面來說明你程式碼中的訊號量是如何工作的:

  1. 建立一個訊號量。引數指定訊號量的起始值。這個數字是你可以訪問的訊號量,不需要有人先去增加它的數量。(注意到增加訊號量也被叫做發射訊號量)。譯者注:這裡初始化為0,也就是說,有人想使用訊號量必然會被阻塞,直到有人增加訊號量。
  2. 在 Completion Block 裡你告訴訊號量你不再需要資源了。這就會增加訊號量的計數並告知其他想使用此資源的執行緒。
  3. 這會在超時之前等待訊號量。這個呼叫阻塞了當前執行緒直到訊號量被髮射。這個函式的一個非零返回值表示到達超時了。在這個例子裡,測試將會失敗因為它以為網路請求不會超過 10 秒鐘就會返回——一個平衡點!

再次執行測試。只要你有一個正常工作的網路連線,這個測試就會馬上成功。請特別注意 CPU 的使用率,與之前使用自旋鎖的實現作個對比。

關閉你的網路連結再執行測試;如果你在真機上執行,就開啟飛航模式。如果你的在模擬器裡執行,你可以直接斷開 Mac 的網路連結。測試會在 10 秒後失敗。這很棒,它真的能按照預想的那樣工作!

還有一些瑣碎的測試,但如果你與一個伺服器組協同工作,那麼這些基本的測試能夠防止其他人就最新的網路問題對你說三道四。

使用 Dispatch Source

GCD 的一個特別有趣的特性是 Dispatch Source,它基本上就是一個低階函式的 grab-bag ,能幫助你去響應或監測 Unix 訊號、檔案描述符、Mach 埠、VFS 節點,以及其它晦澀的東西。所有這些都超出了本教程討論的範圍,但你可以通過實現一個 Dispatch Source 物件並以一個相當奇特的方式來使用它來品嚐那些晦澀的東西。

第一次使用 Dispatch Source 可能會迷失在如何使用一個源,所以你需要知曉的第一件事是 dispatch_source_create 如何工作。下面是建立一個源的函式原型:

第一個引數是 dispatch_source_type_t 。這是最重要的引數,因為它決定了 handle 和 mask 引數將會是什麼。你可以檢視 Xcode 文件 得到哪些選項可用於每個 dispatch_source_type_t 引數。

下面你將監控 DISPATCH_SOURCE_TYPE_SIGNAL 。如文件所顯示的

一個監控當前程式訊號的 Dispatch Source。 handle 是訊號編號,mask 未使用(傳 0 即可)。

這些 Unix 訊號組成的列表可在標頭檔案 signal.h 中找到。在其頂部有一堆 #define 語句。你將監控此訊號列表中的SIGSTOP 訊號。這個訊號將會在程式接收到一個無法迴避的暫停指令時被髮出。在你用 LLDB 偵錯程式除錯應用時你使用的也是這個訊號。

去往 PhotoCollectionViewController.m 並新增如下程式碼到 viewDidLoad 的頂部,就在 [super viewDidLoad] 下面:

這些程式碼有點兒複雜,所以跟著註釋一步步走,看看到底發生了什麼:

  1. 最好是在 DEBUG 模式下編譯這些程式碼,因為這會給“有關方面(Interested Parties)”很多關於你應用的洞察。 :]
  2. Just to mix things up,你建立了一個 dispatch_queue_t 例項變數而不是在引數上直接使用函式。當程式碼變長,分拆有助於可讀性。
  3. 你需要 source 在方法範圍之外也可被訪問,所以你使用了一個 static 變數。
  4. 使用 weakSelf 以確保不會出現保留環(Retain Cycle)。這對 PhotoCollectionViewController 來說不是完全必要的,因為它會在應用的整個生命期裡保持活躍。然而,如果你有任何其它會消失的類,這就能確保不會出現保留環而造成記憶體洩漏。
  5. 使用 dispatch_once 確保只會執行一次 Dispatch Source 的設定。
  6. 初始化 source 變數。你指明瞭你對訊號監控感興趣並提供了 SIGSTOP 訊號作為第二個引數。進一步,你使用主佇列處理接收到的事件——很快你就好發現為何要這樣做。
  7. 如果你提供的引數不合格,那麼 Dispatch Source 物件不會被建立。也就是說,在你開始在其上工作之前,你需要確保已有了一個有效的 Dispatch Source 。
  8. 當你收到你所監控的訊號時,dispatch_source_set_event_handler 就會執行。之後你可以在其 Block 裡設定合適的邏輯處理器(Logic Handler)。
  9. 一個基本的 NSLog 語句,它將物件列印到控制檯。
  10. 預設的,所有源都初始為暫停狀態。如果你要開始監控事件,你必須告訴源物件恢復活躍狀態。

編譯並執行應用;在偵錯程式裡暫停並立即恢復應用,檢視控制檯,你會看到這個來自黑暗藝術的函式確實可以工作。你看到的大概如下:

你的應用現在具有除錯感知了!這真是超級棒,但在真實世界裡該如何使用它呢?

你可以用它去除錯一個物件並在任何你想恢復應用的時候顯示資料;你同樣能給你的應用加上自定義的安全邏輯以便在惡意攻擊者將一個偵錯程式連線到你的應用上時保護它自己(或使用者的資料)。

譯者注:好像挺有用!

一個有趣的主意是,使用此方式的作為一個堆疊追蹤工具去找到你想在偵錯程式裡操縱的物件。

稍微想想這個情況。當你意外地停止偵錯程式,你幾乎從來都不會在所需的棧幀上。現在你可以在任何時候停止偵錯程式並在你所需的地方執行程式碼。如果你想在你的應用的某一點執行的程式碼非常難以從偵錯程式訪問的話,這會非常有用。有機會試試吧!

將一個斷點放在你剛新增在 viewDidLoad 裡的事件處理器的 NSLog 語句上。在偵錯程式裡暫停,然後再次開始;應用會到達你新增的斷點。現在你深入到你的 PhotoCollectionViewController 方法深處。你可以訪問 PhotoCollectionViewController 的例項得到你關心的內容。非常方便!

注意:如果你還沒有注意到在偵錯程式裡的是哪個執行緒,那現在就看看它們。主執行緒總是第一個被 libdispatch 跟隨,它是 GCD 的座標,作為第二個執行緒。之後,執行緒計數和剩餘執行緒取決於硬體在應用到達斷點時正在做的事情。

在偵錯程式裡,鍵入命令:po [[weakSelf navigationItem] setPrompt:@"WOOT!"]

然後恢復應用的執行。你會看到如下內容:

使用這個方法,你可以更新 UI、查詢類的屬性,甚至是執行方法——所有這一切都不需要重啟應用併到達某個特定的工作狀態。相當優美吧!

譯者注:發揮這一點,是可以做出一些除錯庫的吧?

之後又該往何處去?

你可以在此下載最終的專案

我討厭再次提及此主題,但你真的要看看 如何使用 Instruments 教程。如果你計劃優化你的應用,那你一定要學會使用它。請注意 Instruments 擅長於分析相對執行:比較哪些區域的程式碼相對於其它區域的程式碼花費了更長的時間。如果你嘗試計算出某個方法實際的執行時間,那你可能需要拿出更多的自釀的解決方案(Home-brewed Solution)。

同樣請看看 如何使用 NSOperations 和 NSOperationQueues 吧,它們是建立在 GCD 之上的併發技術。大體來說,如果你在寫簡單的用過就忘的任務,那它們就是使用 GCD 的最佳實踐,。NSOperations 提供更好的控制、處理大量併發操作的實現,以及一個以速度為代價的更加物件導向的範例。

記住,除非你有特別的原因要往下流走(譯者的玩笑:即使用低階別 API),否則永遠應嘗試並堅持使用高階的 API。如果你想學到更多或想做某些非常非常“有趣”的事情,那你就應該冒險進入 Apple 的黑暗藝術。

祝你好運,玩得開心!有任何問題或反饋請在下方的討論區貼出!

相關文章