在本節中,您將深入了解蘋果最流行且易于使用的編寫和管理并發任務的機制——Grand Central Dispatch
。您將學習如何利用隊列和線程來控制應用程序中任務的執行,以及如何將這些任務分組在一起。您還將了解使用并發性的常見陷阱和危險,以及如何避免它們。
第3章:隊列和線程
:這一章教你如何使用一個GCD隊列從主線程卸載工作。你還會學到什么是“線程”。
第4章:組和信號量
:在上一章中,您了解了隊列是如何工作的。在本章中,您將擴展知識,學習如何向隊列提交多個任務,這些任務需要作為一個“組”一起運行,這樣當它們全部完成時,您可以得到通知。您還將了解如何包裝現有API,以便可以異步調用它。
第5章:并發問題
:現在你已經知道了GCD是如何讓你的應用程序更快。如果不小心,本章將向您展示并發的一些危險,以及如何避免它們
第三章 隊列和線程
分派隊列和線程現在已經提到過幾次了,現在您可能想知道它們是什么。在本章中,您將對調度隊列和線程有更深入的理解,以及如何最好地將它們合并到您的開發工作流中。
3.1 線程
你可能聽說過多線程這個詞,對吧?對于執行線程來說,線程確實很短,它是一個正在運行的進程在系統上跨資源分割任務的方式。你的iOS應用程序是一個利用多線程運行多個任務的進程。你可以有許多線程執行一次,因為你有核心在你的設備的CPU。
把應用程序的工作分成多個線程有很多好處:
-
更快的執行
:通過在線程上運行任務,工作可以并發地完成,這將使它比串行地完成所有工作更快。 -
響應性
:如果你只在主UI線程上執行用戶可見的工作,那么用戶不會注意到應用程序會因為可以在其他線程上執行的工作而周期性地變慢或凍結。 -
優化的資源消耗
:操作系統對線程進行了高度優化。
聽起來不錯,對吧?更多的核,更多的線程,更快的應用程序。我打賭你準備學習如何創建一個,對嗎?太糟糕了!實際上,您永遠不會發現自己需要顯式地創建線程。操作系統將使用更高的抽象為您處理所有線程創建。
Apple提供了線程管理所需的api,但如果您嘗試自己直接管理它們,實際上可能會降低而不是提高性能。操作系統跟蹤許多統計信息,以了解何時應該、何時不應該分配或銷毀線程。不要欺騙自己,以為它就像你想要一根線的時候把線繞起來那么簡單。由于這些原因,本書將不涉及直接線程管理。
3.1.1 Dispatch queues
使用線程的方法是創建一個DispatchQueue
。當您創建一個隊列時,OS可能會創建一個或多個線程并將其分配給隊列。如果現有線程可用,它們可以被重用;如果沒有,那么操作系統將根據需要創建它們。
對于您來說,創建調度隊列非常簡單,如下面的示例所示:
let label = "com.raywenderlich.mycoolapp.networking"
let queue = DispatchQueue(label: label)
唷,相當簡單,是吧?通常,您應該將標簽的文本直接放在初始化器中,但是為了簡潔起見,它被分解為單獨的語句。
label參數只需是用于標識目的的任何唯一值。雖然你可以簡單地使用一個UUID來保證唯一性,但是最好使用一個反向dns
風格的名稱,如上面所示(例如com.company.app
),因為標簽是你在調試時看到的,它有助于為它分配有意義的文本。
The main queue
當應用程序啟動時,會自動為您創建一個主分派隊列。它是一個負責UI的串行隊列。由于它被頻繁使用,Apple
將它作為類變量提供,您可以通過DispatchQueue.main
訪問它。除非它與實際的UI
工作相關,否則絕不希望對主隊列同步執行某些操作。否則,您將鎖定UI,這可能會降低應用程序的性能。
如果您還記得上一章,有兩種分派隊列:串行
或并發
。如上面的代碼所示,默認的初始化器將創建一個串行隊列,每個任務必須在其中完成,然后才能開始下一個任務。
為了創建一個并發隊列,只需傳入.concurrent
屬性,如下所示:
let label = "com.raywenderlich.mycoolapp.networking"
let queue = DispatchQueue(label: label, attributes: .concurrent)
并發隊列非常普遍,蘋果公司根據隊列應有的服務質量(QoS)提供了6個不同的全局并發隊列。
Quality of service
當使用一個并發分派隊列時,您需要告訴iOS發送到隊列的任務有多重要,以便它能夠正確地將需要完成的工作與所有其他需要資源的任務區分優先級。記住,高優先級的工作必須更快地執行,可能會比低優先級的工作需要更多的系統資源和更多的能量。
如果你只是需要一個并發隊列,但不想管理自己的,你可以使用DispatchQueue
上的全局類方法來獲得一個預定義的全局隊列:
let queue = DispatchQueue.global(qos: .userInteractive)
如上所述,蘋果提供了六種服務質量等級:
-
.userInteractive
QoS
建議用于用戶直接交互的任務。UI更新計算、動畫或任何需要保持UI響應和快速的東西。如果工作不迅速進行,事情可能會凍結。提交到這個隊列的任務實際上應該立即完成。 -
.userInitiated
當用戶從UI啟動需要立即執行但可以異步完成的任務時,應該使用. userinitiated
隊列。例如,您可能需要打開文檔或從本地數據庫讀取。如果用戶單擊了一個按鈕,這可能就是您想要的隊列。在此隊列中執行的任務應該需要幾秒鐘或更短的時間才能完成。 -
.utility
對于通常包含進度指示器的任務,比如長時間運行的計算、I/O、網絡或連續的數據提要,您將需要使用.utility
分派隊列。該系統試圖平衡響應能力和性能與能源效率。任務在這個隊列中可能需要幾秒鐘到幾分鐘 -
.background
對于用戶不能直接意識到的任務,您應該使用.background
隊列。它們不需要用戶交互,對時間也不敏感。預取、數據庫維護、同步遠程服務器和執行備份都是很好的示例。OS將關注能源效率而不是速度。您將希望將此隊列用于需要大量時間(按分鐘或更長的順序)的工作。 -
.default and .unspecified
還有另外兩種可能的選擇,但是您不應該顯式地使用。在.userinitiated
和.utility
之間有一個.default
選項,它是qos
參數的默認值。它不打算讓您直接使用。第二個選項是.未指定的,它的存在是為了支持遺留api
,這些api
可能會選擇脫離服務質量的線程。知道它們的存在是件好事,但是如果您正在使用它們,那么幾乎可以肯定您正在做一些錯誤的事情。
注意:全局隊列總是并發的,并且先入先出。
推斷QoS
如果你創建自己的并發調度隊列,你可以通過它的初始化器告訴系統QoS是什么:
let queue = DispatchQueue(label: label,
qos: .userInitiated,
attributes: .concurrent)
然而,這就像你和你的配偶/孩子/狗/寵物石爭吵一樣:僅僅因為你說了并不會導致結果!操作系統將關注提交到隊列的任務類型,并根據需要進行更改。
如果您提交的任務具有比隊列更高的服務質量,則隊列的級別將會增加。不僅如此,所有加入隊列的操作的優先級也將提高。
如果當前上下文是主線程,則推斷的QoS為.userinitiated
。您可以自己指定QoS
,但一旦您將添加具有更高QoS
的任務,您的隊列的QoS
服務將增加以匹配它。
向隊列中添加任務
分派隊列提供了同步和異步方法來將任務添加到隊列中。請記住,我所說的任務只是指“需要運行的任何代碼塊”。例如,當應用程序啟動時,您可能需要聯系服務器來更新應用程序的狀態。這不是用戶發起的,不需要立即發生,依賴于網絡I/O,所以你應該把它發送到全局效用隊列:
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self = self else { return }
// Perform your work here
// ...
// Switch back to the main queue to
// update your UI
DispatchQueue.main.async {
self.textLabel.text = "New articles available!"
}
}
您應該從上面的代碼示例中了解到兩個關鍵點。首先,取消閉包規則的DispatchQueue
沒有什么特殊之處。如果您計劃使用閉包捕獲的變量(如self),您仍然需要確保正確地處理它們。
在GCD異步閉包中強捕獲self不會導致一個引用循環(例如一個保留循環),因為一旦它完成,整個閉包將被釋放,但它會延長self的生命周期。例如,如果你從一個視圖控制器發出一個網絡請求,而這個請求已經被駁回,那么閉包仍然會被調用。如果你弱捕獲視圖控制器,它將是nil。然而,如果你強捕獲它,視圖控制器將保持活著,直到閉包完成它的工作。記住這一點,并根據自己的需要,或強或弱地獲取信息。
其次,注意UI的更新是如何被分派到后臺隊列分派中的主隊列的。在他人內部嵌套異步類型調用不僅可以,而且很常見
注意:除了主隊列之外,永遠不要在任何隊列上執行UI更新。如果沒有記錄API回調使用的隊列,就將其分派到主隊列!
在同步地向分派隊列提交任務時要格外小心。如果您發現自己調用的是sync方法,而不是async方法,請考慮一兩次是否真的應該這樣做。如果你同步提交一個任務到當前隊列,它阻塞了當前隊列,并且你的任務試圖訪問當前隊列中的資源,那么你的應用程序將會死鎖,這將在第5章“并發問題”中詳細解釋。類似地,如果你從主隊列調用同步,你會阻塞更新UI的線程,你的應用會凍結。
注意:永遠不要從主線程調用同步,因為這會阻塞主線程,甚至可能導致死鎖
3.2 圖像加載示例
此時,您已經被大量的理論概念淹沒了。現在來看一個實際的例子!
在本書可下載的資料中,您將找到本章的入門項目。打開Concurrency.xcodeproj
項目。你會看到一些圖片慢慢地從網絡加載到UICollectionView
。如果你試圖在圖片加載時滾動屏幕,要么什么也不會發生,要么滾動會非常緩慢和不平滑,這取決于你使用的設備的速度。
打開CollectionViewController.swift
看看發生了什么。當視圖加載時,它只抓取要顯示的圖像url
的靜態列表。當然,在生產應用程序中,您可能會在此時進行網絡調用來生成要顯示的項列表,但是對于本例來說,硬編碼圖像列表更容易。
方法collectionView(_:cellForItemAt:)
是問題發生的地方。可以看到,當單元格準備顯示時,會通過Data
的構造函數之一調用來下載圖像,然后將圖像分配給單元格。代碼看起來非常簡單,這也是大多數iOS開發人員下載圖像時所做的事情,但是您看到了結果:不穩定、性能差的UI體驗!
除非您在前面幾頁的解釋中睡著了,否則您現在已經知道下載映像的工作(這是一個網絡調用)需要在與UI分開的線程上完成。
小挑戰:您認為哪個隊列應該處理映像下載?回顧幾頁,做出你的決定
你選的是 userinteractive
還是。userinitiated ?這樣做很有誘惑力,因為最終結果對用戶是直接可見的,但實際上,如果使用了這種邏輯,就永遠不會使用任何其他隊列。這里正確的選擇是使用.utility
隊列。你無法控制一個網絡通話需要多長時間來完成,你想要操作系統在速度和設備的電池壽命之間取得適當的平衡。
3.2.1 Using a global queue
在CollectionViewController
中創建一個新方法,開始如下:
private func downloadWithGlobalQueue(at indexPath: IndexPath) {
DispatchQueue.global(qos: .utility).async { [weak self] in
}
}
你最終會從collectionView(_:cellForItemAt:)
調用它來執行實際的圖像處理。首先確定應該加載哪個URL。由于url列表是self的一部分,因此需要處理正常的閉包捕獲語義。在async
閉包中添加以下代碼:
guard let self = self else {
return
}
let url = self.urls[indexPath.item]
一旦知道了要加載的URL,就可以使用前面使用的相同數據初始化器。盡管它是一個正在執行的同步操作,但它是在單獨的線程上運行的,因此UI不會受到影響。在閉包的末尾添加以下內容:
guard let data = try? Data(contentsOf: url),
let image = UIImage(data: data) else {
return
}
現在你已經成功下載了URL的內容并將其轉換為UIImage
,是時候將其應用到集合視圖的單元格了。記住,對UI的更新只能在主線程上發生!將這個異步調用添加到閉包的末尾:
DispatchQueue.main.async {
if let cell = self.collectionView.cellForItem(at: indexPath) as? PhotoCell {
cell.display(image: image)
}
}
注意,最少量的代碼被發送回主線程。在分派到主隊列之前,盡可能完成所有工作,以便UI保持盡可能的響應性。單元分配是否讓你感到困惑?為什么不直接將實際的PhotoCell
傳遞給這個方法而不是IndexPath
呢?
考慮一下你在這里所做的事情的性質。您已經將cell的配置轉移到異步進程。當網絡下載發生時,用戶很可能會對你的應用做一些事情,對于UITableView
或UICollectionView
,這可能意味著他們在滾動。在網絡調用結束時,該單元可能已經被另一個映像重用,或者它可能已經被完全丟棄。通過調用cellForItem(at:)
,您在準備更新單元格時獲取它。如果它仍然存在,并且仍然在屏幕上,那么您將更新顯示。如果不是,則返回nil
如果你只是簡單地傳遞一個PhotoCell
并直接與那個對象交互,你會發現隨機的圖像被放置在隨機的單元中,當你滾動的時候你會看到相同的圖像重復多次。
現在你已經有了一個正確的圖像下載和單元格配置方法,update collectionView(_:cellForItemAt:)
來調用它。用以下兩行代碼替換創建和返回單元格之間的所有內容:
cell.display(image: nil)
downloadWithGlobalQueue(at: indexPath)
再次構建并運行你的應用。一旦你的應用程序開始加載圖片,滾動表格視圖。注意,滾動是多么光滑!當然,你可能會注意到一些問題:圖片彈出和消失,加載非常緩慢,當你滾動時不斷重新加載。您需要一種方法來啟動和取消這些請求,并緩存它們的結果,以使體驗完美。這些都是使用操作比使用中央調度要容易得多的事情,您將在后面的章節中介紹。所以繼續閱讀!:]
使用內置的方法
你可以看到,上面的改變是多么簡單,極大地提高了你的應用程序的性能。然而,并不是總是需要自己抓取調度隊列。許多標準的iOS庫可以為你處理這個問題。向CollectionViewController
添加以下方法
private func downloadWithUrlSession(at indexPath: IndexPath) {
URLSession.shared.dataTask(with: urls[indexPath.item]) {
[weak self] data, response, error in
guard let self = self,
let data = data,
let image = UIImage(data: data) else {
return
}
DispatchQueue.main.async {
if let cell = self.collectionView
.cellForItem(at: indexPath) as? PhotoCell {
cell.display(image: image)
}
}
}.resume()
}
注意,這一次,您直接使用URLSession上的dataTask方法,而不是獲取調度隊列。代碼幾乎是相同的,但是它為您處理數據的下載,因此您不必自己完成,也不需要獲取分派隊列。當系統提供的方法可用時,一定要使用系統提供的方法,因為這樣不僅可以使您的代碼更經得起時間的檢驗,而且更容易為其他開發人員閱讀。初級程序員可能不理解調度隊列是什么,但他們理解進行網絡調用。
如果你在collectionView(_:cellForItemAt:)
中調用downloadWithUrlSession(at:)
而不是downloadWithGlobalQueue(at:)
,你應該在再次構建和運行你的應用之后看到完全相同的結果。
3.3 DispatchWorkItem
除了傳遞匿名閉包之外,還有另一種向DispatchQueue
提交工作的方法。DispatchWorkItem
是一個類,它提供一個實際對象來保存希望提交到隊列的代碼。
例如,以下代碼:
let queue = DispatchQueue(label: "xyz")
queue.async {
print("The block of code ran!")
}
就像這段代碼一樣:
let queue = DispatchQueue(label: "xyz")
let workItem = DispatchWorkItem {
print("The block of code ran!")
}
queue.async(execute: workItem)
3.4 Canceling a work item
您可能希望使用顯式DispatchWorkItem
的一個原因是,您需要在執行之前或執行期間取消任務。如果您在工作項上調用cancel()
,將執行兩個操作中的一個:
- 1: 如果任務還沒有在隊列上啟動,它將被刪除。
- 2: 如果任務當前正在執行,則
isCancelled
屬性將被設置為true
。
您需要定期檢查代碼中的isCancelled
屬性,并采取適當的操作來取消任務(如果可能的話)。
Poor man's dependencies
DispatchWorkItem
類還提供了一個notify(queue:execute:)
方法,該方法可用于識別在當前工作項完成后應該執行的另一個DispatchWorkItem
let queue = DispatchQueue(label: "xyz")
let backgroundWorkItem = DispatchWorkItem { }
let updateUIWorkItem = DispatchWorkItem { }
backgroundWorkItem.notify(queue: DispatchQueue.main,
execute: updateUIWorkItem)
queue.async(execute: backgroundWorkItem)
請注意,當指定要執行的后續工作項時,您必須顯式地指定工作項應該針對哪個隊列執行。
如果您發現自己需要取消任務或指定依賴項的能力,我強烈建議您參閱第9章“操作依賴項”和第10章“操作取消操作和第三章 Operations
3.6 Where to go from here?
至此,您應該對調度隊列是什么、它們用于什么以及如何使用它們有了很好的理解。試試上面的代碼示例,確保您理解它們是如何工作的。
考慮將PhotoCell
傳遞到下載方法中,而不是僅僅傳遞IndexPath
來查看實際中常見的錯誤類型
當然,這個示例應用程序有些人為設計,以便輕松地展示DispatchQueue
是如何工作的。示例應用程序還有許多其他性能改進,但這些都需要等到第7章“操作隊列”的時候。
現在您已經看到了并發的好處,下一章將向您介紹在應用程序中實現并發的危險。