版本記錄
版本號 | 時間 |
---|---|
V1.0 | 2018.08.19 |
前言
信號量機制是多線程通信中的比較重要的一部分,對于
NSOperation
可以設置并發(fā)數(shù),但是對于GCD
就不能設置并發(fā)數(shù)了,那么就只能靠信號量機制了。接下來這幾篇就會詳細的說一下并發(fā)機制。感興趣的可以看這幾篇文章。
1. iOS與多線程(一) —— GCD中的信號量及幾個重要函數(shù)
2. iOS與多線程(二) —— NSOperation實現(xiàn)多并發(fā)之創(chuàng)建任務
3. iOS與多線程(三) —— NSOperation實現(xiàn)多并發(fā)之創(chuàng)建隊列和開啟線程
4. iOS與多線程(四) —— NSOperation的串并行和操作依賴
開始
首先看一下本文寫作環(huán)境。
Swift 4.2, iOS 12, Xcode 10
GCD是 Apple 開發(fā)的一個多核編程的解決方法,簡單易用,效率高,速度快。通過 GCD,開發(fā)者只需要向隊列中添加一段代碼塊(block或C函數(shù)指針),而不需要直接和線程打交道。GCD在后端管理著一個線程池,它不僅決定著你的代碼塊將在哪個線程被執(zhí)行,還根據(jù)可用的系統(tǒng)資源對這些線程進行管理。這樣通過GCD來管理線程,從而解決線程被創(chuàng)建的問題。
Grand Central Dispatch(GCD)是用于管理并發(fā)操作的低級API。它可以通過將計算成本高昂的任務推遲到后臺來幫助您提高應用程序的響應能力。它是處理鎖和線程的一種更簡單的并發(fā)模型。
在本文中,您將了解GCD及其Swifty API的細節(jié)。第一部分將解釋GCD的作用并展示幾個基本的GCD功能。在下一篇中,您將了解GCD提供的一些高級功能。
您將構建一個名為GooglyPuff
的現(xiàn)有應用程序。 GooglyPuff是一個未優(yōu)化的“線程不安全”應用程序,它使用Core Image的面部檢測API覆蓋檢測到的面部上的googly眼睛。您可以選擇圖像以從照片庫中接收此效果,或選擇從互聯(lián)網(wǎng)下載的圖像。
本文的任務是使用GCD優(yōu)化應用程序并確保您可以安全地從不同的線程調用代碼。
在Xcode中打開示例App并運行它以查看您必須使用的內容。
主屏幕最初是空的。 點擊+,然后選擇Le Internet
以從互聯(lián)網(wǎng)下載預定義圖像。 點擊第一張圖片,你會看到臉上添加了googly眼睛。
在本文中,您將主要使用四個類:
-
PhotoCollectionViewController
:初始視圖控制器。它以縮略圖的形式顯示所選照片。 -
PhotoDetailViewController
:從PhotoCollectionViewController
顯示所選照片,并將googly眼睛添加到圖像中。 -
Photo
:此協(xié)議描述了照片的屬性。它提供圖像,縮略圖及其相應的狀態(tài)。該項目包括兩個實現(xiàn)協(xié)議的類:DownloadPhoto
,它實例化來自URL實例的照片,以及AssetPhoto
,它實例化來自PHAsset
實例的照片。 -
PhotoManager
:它管理所有Photo
對象。
在第一部分中,您將進行一些改進,包括優(yōu)化googly-fying
過程并使PhotoManager
線程安全。
GCD Concepts - GCD概念
要理解GCD,您需要熟悉與并發(fā)和線程相關的幾個概念。
1. Concurrency - 并發(fā)
在iOS中,進程或應用程序由一個或多個線程組成。 操作系統(tǒng)調度程序彼此獨立地管理線程。 每個線程可以并發(fā)執(zhí)行,但由系統(tǒng)來決定是否發(fā)生這種情況,何時發(fā)生以及如何發(fā)生。
單核設備通過稱為時間切片time-slicing
的方法實現(xiàn)并發(fā)。 它們運行一個線程,執(zhí)行上下文切換,然后運行另一個線程。
另一方面,多核設備通過parallelism
同時執(zhí)行多個線程。
GCD建立在線程之上。 它負責管理共享線程池。 使用GCD,您可以添加代碼塊或工作項來調度隊列dispatch queues
,GCD決定執(zhí)行它們的線程。
在構建代碼時,您會發(fā)現(xiàn)可以同時運行的代碼塊和不可以同時運行的代碼塊。 然后,這允許您使用GCD來利用并發(fā)執(zhí)行。
請注意,GCD根據(jù)系統(tǒng)和可用的系統(tǒng)資源決定它需要多少并行度。 重要的是要注意parallelism
需要并發(fā)性,但并發(fā)性并不能保證parallelism
。
基本上,并發(fā)是關于結構,而parallelism
是關于執(zhí)行。
2. Queues - 隊列
如前所述,GCD通過一個名為DispatchQueue
的類來操作調度隊列。 您將工作單元提交到此隊列,GCD將以FIFO順序(先進先出)執(zhí)行它們,保證提交的第一個任務是第一個啟動的任務。
DispatchQueue
是線程安全的,這意味著您可以同時從多個線程訪問它們。 當您了解調度隊列如何為您自己的代碼的某些部分提供線程安全時,GCD的好處是顯而易見的。 關鍵是要選擇正確的調度隊列和正確的調度函數(shù),將您的工作提交到隊列。
隊列可以是串行serial
的,也可以是并發(fā)concurrent
的。 串行隊列保證在任何給定時間只運行一個任務。 GCD控制執(zhí)行時間。 你不會知道一個任務結束和下一個任務開始之間的時間量:
并發(fā)隊列允許多個任務同時運行。 隊列保證任務以您添加它們的順序開始。 任務可以按任何順序完成,您不知道下一個任務啟動所需的時間,也不了解在任何給定時間運行的任務數(shù)。
這是設計使然:您的代碼不應該依賴于這些實現(xiàn)細節(jié)。
請參閱下面的示例任務執(zhí)行:
請注意任務1,任務2和任務3如何一個接一個地快速啟動。另一方面,任務1需要一段時間才能在任務0之后啟動。還要注意,當任務3在任務2之后啟動時,它首先完成。
何時開始任務的決定完全取決于GCD。如果一個任務的執(zhí)行時間與另一個任務的執(zhí)行時間重疊,則由GCD決定是否應該在不同的核心上運行,如果只是單核,則執(zhí)行上下文切換以運行不同的任務。
GCD提供三種主要類型的隊列:
- Main queue - 主隊列:在主線程上運行,是一個串行隊列。
-
Global queues - 全局隊列:整個系統(tǒng)共享的并發(fā)隊列。有四個這樣的隊列具有不同的優(yōu)先級:
high, default, low, and background
。后臺優(yōu)先級隊列具有最低優(yōu)先級,并在任何I / O活動中受到限制,以最大限度地減少負面系統(tǒng)影響。 -
Custom queues - 自定義隊列:您創(chuàng)建的可以是串行或并發(fā)的隊列。這些隊列中的請求實際上最終位于其中一個全局隊列
global queues
中。
將任務發(fā)送到全局并發(fā)隊列時,不直接指定優(yōu)先級。而是指定服務質量(QoS)
類屬性。這表明任務的重要性,并指導GCD確定賦予任務的優(yōu)先級。
QoS類是:
- User-interactive - 用戶交互:這表示必須立即完成的任務,以提供良好的用戶體驗。將其用于UI更新,事件處理和需要低延遲的小型工作負載。在執(zhí)行您的應用程序期間,此類中完成的工作總量應該很小。這應該在主線程上運行。
- User-initiated - 用戶啟動:用戶從UI啟動這些異步任務。當用戶等待立即結果以及繼續(xù)用戶交互所需的任務時使用它們。它們在高優(yōu)先級全局隊列中執(zhí)行。
- Utility - 實用程序:這表示長時間運行的任務,通常具有用戶可見的進度指示器。用于計算,I / O,網(wǎng)絡,后續(xù)的數(shù)據(jù)饋送和類似任務。本類旨在提高能源效率。這將被映射到低優(yōu)先級全局隊列。
- Background - 背景:這表示用戶不需要直接了解的任務。用于預取,維護和其他不需要用戶交互且時間不敏感的任務。這將被映射到后臺優(yōu)先級全局隊列。
3. Synchronous vs. Asynchronous - 同步與異步
使用GCD,您可以同步或異步分派任務。
任務完成后,同步函數(shù)會將控制權返回給調用者。 您可以通過調用DispatchQueue.sync(execute :)
來同步調度工作單元。
異步函數(shù)立即返回,命令任務開始但不等待它完成。 因此,異步函數(shù)不會阻塞當前執(zhí)行線程繼續(xù)執(zhí)行下一個函數(shù)。 您可以通過調用DispatchQueue.async(execute :)
來異步調度工作單元。
4. Managing Tasks - 管理任務
你現(xiàn)在已經(jīng)聽說過相當多的任務了。 出于本教程的目的,您可以將任務視為閉包closure.。 閉包是可以存儲和傳遞的自包含,可調用的代碼塊。
您提交給DispatchQueue
的每個任務都是DispatchWorkItem
。 您可以配置DispatchWorkItem
的行為,例如其QoS類或是否生成新的分離線程。
Handling Background Tasks - 處理后臺任務
根據(jù)所有這些GCD知識,是你第一次改進應用程序的時候了!
回到應用程序并添加照片庫中的一些照片或使用Le Internet
選項下載一些。 點按照片。 請注意照片詳細信息視圖顯示所需的時間。 在較慢的設備上查看大圖像時,滯后更明顯。
重載視圖控制器的viewDidLoad()
很容易,導致在視圖出現(xiàn)之前等待很長時間。 如果在加載時不是絕對必要的話,最好將工作移動到后臺。
這聽起來像是DispatchQueue async
的工作!
打開PhotoDetailViewController.swift
。 修改viewDidLoad()
并替換這兩行:
let overlayImage = faceOverlayImageFrom(image)
fadeInNewImage(overlayImage)
接著是下面的代碼
// 1
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else {
return
}
let overlayImage = self.faceOverlayImageFrom(self.image)
// 2
DispatchQueue.main.async { [weak self] in
// 3
self?.fadeInNewImage(overlayImage)
}
}
下面一步一步進行拆解詳述:
- 1)您將工作移動到后臺全局隊列
(background global queue )
并異步運行閉包中的工作。這使得viewDidLoad()
在主線程上更早完成,并使加載感覺更加敏捷。同時,面部檢測處理開始并在稍后的某個時間結束。 - 2)此時,面部檢測處理已完成,您已生成新圖像。由于您要使用此新圖像更新UIImageView,因此您需要向主隊列添加一個新閉包。記住 - 任何修改UI的東西都必須在主線程上運行!
- 3)最后,使用
fadeInNewImage(_ :)
更新UI,執(zhí)行新的googly眼睛圖像的淡入過渡。
在兩個點中,您添加[weak self]
以捕獲每個閉包中對self
的弱引用。如果您不熟悉捕獲列表,應該多補習一些內存管理方面的內容。
Build并運行應用程序。通過Le Internet
選項下載照片。選擇一張照片,您會發(fā)現(xiàn)視圖控制器加載速度明顯加快,并在短暫延遲后添加了googly眼睛:
當googly
眼睛出現(xiàn)時,這為應用程序提供了一個很好的前后效果。即使您嘗試加載一個非常大圖像,您的應用程序也不會在視圖控制器加載時掛起。
通常,當您需要在后臺執(zhí)行基于網(wǎng)絡或CPU密集型的任務而不阻止當前線程時,您希望使用異步(async)
。
以下是如何以及何時使用異步(async)
的各種隊列的快速指南:
- 1)Main Queue - 主隊列:這是在完成并發(fā)隊列上的任務中的工作之后更新UI的常見選擇。為此,您需要在另一個內部編寫一個閉包。定位主隊列并調用異步
(async)
可確保在當前方法完成后的某個時間執(zhí)行此新任務。 - 2)Global Queue - 全局隊列:這是在后臺執(zhí)行非UI工作的常見選擇。
- 3)Custom Serial Queue - 自定義串行隊列:當您想要連續(xù)執(zhí)行后臺工作并跟蹤它時,這是一個很好的選擇。這消除了資源爭用和競爭條件,因為您知道一次只執(zhí)行一個任務。請注意,如果需要方法中的數(shù)據(jù),則必須聲明另一個閉包以檢索它或考慮使用同步
(sync)
。
Delaying Task Execution - 延遲任務執(zhí)行
DispatchQueue
允許您延遲任務執(zhí)行。 不要通過引入延遲等hacks
來解決競爭條件或其他時間錯誤。 相反,當您希望任務在特定時間運行時,請使用此選項。
暫時考慮一下您的應用的用戶體驗。 用戶可能會對第一次打開應用時該怎么做感到困惑 - 是嗎
如果沒有任何照片,最好向用戶顯示提示。 您還應該考慮用戶的眼睛如何在主屏幕上瀏覽。 如果您過快地顯示提示,他們可能會錯過它,因為他們的眼睛停留在視圖的其他部分。 兩秒鐘的延遲應足以引起用戶的注意并引導他們。
打開PhotoCollectionViewController.swift
并填寫showOrHideNavPrompt()
的實現(xiàn):
// 1
let delayInSeconds = 2.0
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + delayInSeconds) { [weak self] in
guard let self = self else {
return
}
if PhotoManager.shared.photos.count > 0 {
self.navigationItem.prompt = nil
} else {
self.navigationItem.prompt = "Add photos with faces to Googlyify them!"
}
// 3
self.navigationController?.viewIfLoaded?.setNeedsLayout()
}
以下是上面發(fā)生的事情:
- 1)您指定延遲的時間量。
- 2)然后等待指定的時間,然后異步運行更新照片計數(shù)的塊并更新提示。
- 3)設置提示后強制導航欄布局以確保它看起來更舒服。
只要您的UICollectionView重新加載,showOrHideNavPrompt()
就會在viewDidLoad()
中執(zhí)行。
Build并運行應用程序。 在看到顯示的提示之前應該有一點延遲:
注意:您可以忽略Xcode控制臺中的自動布局消息。 它們都來自iOS,并不表示您的錯誤。
為什么不使用Timer? 如果您有重復的任務更容易使用Timer調度,您可以考慮使用它。 以下是堅持調度隊列的asyncAfter()
的兩個原因。
一個是可讀性。 要使用Timer,您必須定義一個方法,然后使用選擇器或調用定義的方法創(chuàng)建計時器。 使用DispatchQueue
和asyncAfter()
,您只需添加一個閉包。
Timer
在運行循環(huán)上進行調度,因此您還必須確保在正確的運行循環(huán)上進行調度(在某些情況下,運行循環(huán)模式正確)。 在這方面,使用調度隊列更容易。
Managing Singletons - 管理單例
單例。喜歡他們或恨他們,他們在iOS中像在網(wǎng)絡上的貓照片一樣受歡迎。
單例常常令人擔心的是,他們通常不是線程安全的。考慮到它們的使用,這種擔憂是合理的:單身通常是從多個控制器同時訪問單例實例中使用的。您的PhotoManager
類是單例,因此您需要考慮此問題。
可以從多個線程或并發(fā)任務安全地調用線程安全代碼,而不會導致任何問題,例如數(shù)據(jù)損壞或應用程序崩潰。非線程安全的代碼一次只能在一個上下文中運行。
需要考慮兩種線程安全情況:在單例實例的初始化期間以及對實例的讀寫期間。
由于Swift如何初始化靜態(tài)變量,初始化變的很簡單。它首次訪問時初始化靜態(tài)變量,并保證初始化是原子的(atomic)
。也就是說,Swift將執(zhí)行初始化的代碼視為關鍵部分,并保證在任何其他線程訪問靜態(tài)變量之前完成。
關鍵部分是一段不能同時執(zhí)行的代碼,即一次從兩個線程執(zhí)行。這通常是因為代碼操縱共享資源(如變量),如果它由并發(fā)進程訪問,則該變量可能會損壞。
打開PhotoManager.swift
以查看如何初始化單例:
class PhotoManager {
private init() {}
static let shared = PhotoManager()
}
私有初始化程序確保只有一個shared
分配給共享的PhotoManager
。 這樣,您就不必擔心在不同管理器之間同步對照片庫的更改。
在訪問操作共享內部數(shù)據(jù)的單例中的代碼時,您仍然必須處理線程安全性。 您可以通過同步數(shù)據(jù)訪問等方法來處理此問題。 您將在下一節(jié)中看到一種方法。
Handling the Readers-Writers Problem - 處理讀寫問題
在Swift中,使用let
關鍵字聲明的任何變量都是常量,因此是只讀和線程安全的。 但是,使用var關鍵字聲明變量,除非數(shù)據(jù)類型設計為可變,否則它變?yōu)榭勺兦也皇蔷€程安全的。 聲明可變時,像Array
和Dictionary
這樣的Swift集合類型不是線程安全的。
雖然許多線程可以同時讀取一個可變的Array
實例而沒有問題,但讓一個線程修改數(shù)組而另一個線程正在讀取它是不安全的。 您的單例不會阻止此情況在當前狀態(tài)下發(fā)生。
要解決這個問題,請查看PhotoManager.swift
中的addPhoto(_ :)
,如下所示:
func addPhoto(_ photo: Photo) {
unsafePhotos.append(photo)
DispatchQueue.main.async { [weak self] in
self?.postContentAddedNotification()
}
}
這是一個write
方法,因為它修改了一個可變數(shù)組對象。
現(xiàn)在來看看photos
屬性,如下:
private var unsafePhotos: [Photo] = []
var photos: [Photo] {
return unsafePhotos
}
此屬性的getter
被稱為read
方法,因為它正在讀取可變數(shù)組。 調用者獲取數(shù)組的副本,并防止不適當?shù)馗淖冊紨?shù)組。 但是,這不會對調用write
方法addPhoto(_ :)
的一個線程,而另一個線程同時調用photos
屬性的getter
提供任何保護。
這就是為什么支持變量被命名為unsafePhotos
- 如果它在錯誤的線程上訪問,你可能會得到一些古怪的行為!
注意:在上面的代碼中,為什么調用者會獲得照片數(shù)組的副本? 在Swift中,函數(shù)的參數(shù)和返回類型可以通過引用或值傳遞。按值傳遞會生成對象的副本,對副本的更改不會影響原始對象。 默認情況下,在Swift中,類實例通過引用傳遞,結構體通過值傳遞。 Swift的內置數(shù)據(jù)類型(如
Array
和Dictionary
)實現(xiàn)為結構體(structs)
。在來回傳遞集合時,您的代碼可能進行很多副本的copy。 不要擔心這會對內存使用產(chǎn)生影響。 Swift集合類型經(jīng)過優(yōu)化,僅在必要時才進行復制,例如,當您的應用程序首次修改按值傳遞的數(shù)組時。
這是經(jīng)典的軟件開發(fā)Readers-Writers Problem。 GCD提供了一種使用dispatch barriers
創(chuàng)建read/write lock的優(yōu)雅解決方案。 dispatch barriers
是一組在使用并發(fā)隊列時充當串行式瓶頸的函數(shù)。
當您將DispatchWorkItem
提交到調度隊列時,您可以設置標志以指示它應該是該特定時間在指定隊列上執(zhí)行的唯一項目。 這意味著在dispatch barriers
之前提交到隊列的所有項必須在DispatchWorkItem
執(zhí)行之前完成。
當DispatchWorkItem
開始執(zhí)行時,barrier
執(zhí)行它并確保隊列在此期間不執(zhí)行任何其他任務。 完成后,隊列將返回其默認實現(xiàn)。
下圖說明了barrier
對各種異步任務的影響:
請注意,在正常操作中,隊列的行為與普通并發(fā)隊列的作用相同。 但是當barrier
執(zhí)行時,它基本上就像一個串行隊列。 也就是說,barrier
是唯一執(zhí)行的事情。 在barrier
完成后,隊列將返回到正常的并發(fā)隊列。
在全局后臺并發(fā)隊列中使用障礙時要小心,因為這些隊列是共享資源。 在自定義串行隊列中使用barrier
是多余的,因為它已經(jīng)穿行執(zhí)行。 在自定義并發(fā)隊列中使用barrier
是處理原子或關鍵代碼區(qū)域中的線程安全性的絕佳選擇。
您將使用自定義并發(fā)隊列來處理barrier
函數(shù)并分離讀寫功能。 并發(fā)隊列將允許同時進行多個讀取操作。
打開PhotoManager.swift
并在unsafePhotos
聲明上方添加一個私有屬性:
private let concurrentPhotoQueue =
DispatchQueue(
label: "com.xxxx.GooglyPuff.photoQueue",
attributes: .concurrent)
這會將concurrentPhotoQueue
初始化為并發(fā)隊列。 您可以使用在調試期間有用的描述性名稱設置標簽(label)
。 通常,您使用反向DNS樣式命名約定。
接下來,使用以下代碼替換addPhoto(_ :)
:
func addPhoto(_ photo: Photo) {
concurrentPhotoQueue.async(flags: .barrier) { [weak self] in
// 1
guard let self = self else {
return
}
// 2
self.unsafePhotos.append(photo)
// 3
DispatchQueue.main.async { [weak self] in
self?.postContentAddedNotification()
}
}
}
以下是您的新寫入方法的工作原理:
- 1)您可以使用
barrier
異步調度寫入操作。 執(zhí)行時,它將是隊列中唯一的項目。 - 2)您將對象添加到數(shù)組。
- 3)最后,您發(fā)布了添加照片的通知。 您必須在主線程上發(fā)布此通知,因為它將執(zhí)行UI工作。 因此,您將另一個任務異步調度到主隊列以觸發(fā)通知。
這樣可以處理寫入,但您還需要實現(xiàn)photos
讀取方法。
要確保寫入的線程安全,您需要對concurrentPhotoQueue
隊列執(zhí)行讀取操作。 您需要從函數(shù)調用返回數(shù)據(jù),因此異步調度不會刪除它。 在這種情況下,同步(sync)
將是一個很好的選擇。
使用sync
可以跟蹤調度barrier
,或者在需要等待操作完成之后才能使用閉包處理的數(shù)據(jù)。
你需要小心。 想象一下,如果你調用sync
并定位你正在運行的當前隊列。 這將導致死鎖(deadlock)
情況。
兩個(或有時更多)項目 - 在大多數(shù)情況下,線程 - 如果它們都被卡住等待彼此完成或執(zhí)行另一個操作則會死鎖。 第一個無法完成,因為它正在等待第二個完成。 但第二個無法完成,因為它正在等待第一個完成。
在你的情況下,同步(sync)
調用將等到閉包完成,但閉包無法完成(或啟動!),直到當前正在執(zhí)行的閉包完成,它不能! 這應該會強制您小心您正在調用的隊列 - 以及您傳入的隊列。
以下是使用同步(sync)
的概述:
- Main Queue - 主要隊列:出于與上述相同的原因,要非常小心;這種情況也有可能造成死鎖。 這在主隊列上尤其糟糕,因為整個應用程序將無法響應。
-
Global Queue - 全局隊列:這是通過調度
barrier
同步工作或等待任務完成以便您可以執(zhí)行進一步處理的一個很好的選擇。 -
Custom Serial Queue - 自定義串行隊列:在這種情況下要非常小心;如果你在一個隊列中運行并調用同一個隊列的同步
(sync)
,你肯定會創(chuàng)建一個死鎖。
仍然在PhotoManager.swift
中修改photos
屬性getter
:
var photos: [Photo] {
var photosCopy: [Photo]!
// 1
concurrentPhotoQueue.sync {
// 2
photosCopy = self.unsafePhotos
}
return photosCopy
}
這是進行分步細說:
- 1)同步調度到
concurrentPhotoQueue
以執(zhí)行讀取。 - 2)將照片數(shù)組的副本存儲在
photosCopy
中并將其返回。
Build并運行應用程序。 通過Le Internet
選項下載照片。 它應該像以前一樣。
恭喜 - 您的PhotoManager
單例現(xiàn)在是線程安全的! 無論您在何處或如何讀或寫照片,您都可以確信它會以安全的方式發(fā)生而不會出現(xiàn)任何意外。
如果您計劃優(yōu)化自己的App,那么您真的應該使用Xcode的內置Time Profiler來分析您的工作。
您可能還想看看Rob Pike關于Concurrency vs Parallelism
上的精彩演講 - this excellent talk by Rob Pike 。
參考文章
1. iOS GCD詳解(一)
2. GCD 深入理解:第一部分
3. iOS中GCD的使用小結
后記
本篇主要講述了GCD相關一個簡單應用示例,感興趣的給個贊或者關注~~~