深入淺出 iOS 併發程式設計
本文是我在上海 T 沙龍4月7日分享內容的文字版總結和拓展。相關視訊和文件請見連結:深入淺出 iOS 併發程式設計
其中主要內容包括:GCD與Operation的用法、併發程式設計中常見的問題、使用Operation進行流程化開發示範。
什麼是併發程式設計
在大多數場景下,我們所寫的程式碼是逐行順序執行——在固定的時段內,程式只執行一個任務。而所謂併發程式設計,就是指在固定的時段內,程式執行多個任務。舉個例子,當我們在微博 App 的首頁滑動瀏覽時,微博也在從網路端預載入新的內容或者圖片。併發程式設計可以充分利用硬體效能,合理分配軟體資源,帶來優秀的使用者體驗。在 iOS 開發中,我們主要依靠 GCD 和 Operation 來操作執行緒切換、非同步操作,從而實現併發程式設計。
新聞類App首頁經常需要同時處理 UI 顯示、內容載入、快取等多個任務
在 iOS 併發程式設計中,我們要知道這幾個基本概念:
- 序列(Serial):在固定時間內只能執行單個任務。例如主執行緒,只負責 UI 顯示。
- 併發(Concurrent):在固定時間內可以執行多個任務。注意,它和並行(Parallel)的區別在於,併發不會同時執行多個任務,而是通過在任務間不斷切換去完成所有工作。
- 同步(Sync):會把當前的任務加入到佇列中,除非該任務執行完成,執行緒才會返回繼續執行,也就是說同步會阻塞執行緒。任務在執行和結束一定遵循先後順序,即先執行的任務一定先結束。
- 非同步(Async):會把當前的任務加入到佇列中,但它會立刻返回,無需等任務執行完成,也就是說非同步不會阻塞執行緒。任務在執行和結束不遵循先後順序。可能先執行的任務先結束,也可能後執行的任務先結束。
為了進一步說明說明序列/併發與同步/非同步之間的關係,我們來看下面這段程式碼會打印出什麼內容:
// serial, sync serialQueue.sync { print(1) } print(2) serialQueue.sync { print(3) } print(4) // serial, async serialQueue.async { print(1) } print(2) serialQueue.async { print(3) } print(4) // serial, sync in async print(1) serialQueue.async { print(2) serialQueue.sync { print(3) } print(4) } print(5) // serial, async in sync print(1) serialQueue.sync { print(2) serialQueue.async { print(3) } print(4) } print(5)
首先,在序列佇列上進行同步操作,所有任務將順序發生,所以第一段的列印結果一定是 1234;
其次,在序列佇列上進行非同步操作,此時任務完成的順序並不保證。所以可能會打印出這幾種結果:1234 ,2134,1243,2413,2143。注意 1 一定在 3 之前打印出來,因為前者在後者之前派發,序列佇列一次只能執行一個任務,所以一旦派發完成就執行。同理 2 一定在 4 之前列印,2 一定在 3 之前列印。
接著,對同一個序列佇列中進行非同步、同步巢狀。這裡會構成死鎖(具體原因參見下文),所以只會打印出 125 或者 152。
最後,在序列佇列中進行同步、非同步巢狀,不會構成死鎖。這裡會打印出 3 個結果:12345,12435,12453。這裡1一定在最前,2 一定在 4 前,4 一定在 5 前。
現在我們把序列佇列改為併發佇列:
// concurrent, sync
concurrentQueue.sync {
print(1)
}
print(2)
concurrentQueue.sync {
print(3)
}
print(4)
// concurrent, async
concurrentQueue.async {
print(1)
}
print(2)
concurrentQueue.async {
print(3)
}
print(4)
// concurrent, sync in async
print(1)
concurrentQueue.async {
print(2)
concurrentQueue.sync {
print(3)
}
print(4)
}
print(5)
// concurrent, async in sync
print(1)
concurrentQueue.sync {
print(2)
concurrentQueue.async {
print(3)
}
print(4)
}
print(5)
首先,在併發佇列上進行同步操作,所有任務將順序執行、順序完成,所以第一段的列印結果一定是 1234;
其次,在併發佇列上進行非同步操作,因為並行對列有多個執行緒 。所以這裡只能保證 24 順序執行,13 亂序,可能插在任意位置:2413 ,2431,2143,2341,2134,2314。
接著,對同一個併發佇列中進行非同步、同步巢狀。這裡不會構成死鎖,因為同步操作只會阻塞一個執行緒,而併發佇列對應多個執行緒。這裡會打印出 4 個結果:12345,12534,12354,15234。注意同步操作保證了 3 一定會在 4 之前打印出來。
最後,在併發佇列中進行同步、非同步巢狀,不會構成死鎖。而且由於是併發佇列,所以在執行非同步操作時也同時會執行其他操作。這裡會打印出 3 個結果:12345,12435,12453。這裡同步操作保證了 2 和 4 一定在 3 和 5 之前打印出來。
在實際開發中,我們還需要知道主執行緒的特性、GCD 和 Operation 的 API、如發現並除錯併發程式設計中的技巧。
GCD vs. Operation
在 iOS 開發中,我們一般用 GCD 和 Operation 來處理併發程式設計問題。我們先來看看 GCD 的基本用法:
// serial queue
let serialQueue = DispatchQueue(label: "serial")
// global queue, gcd defined concurrent queue
let globalQueue = DispatchQueue.global(qos: .default)
// custom concurrent queue
let concurrentQueue = DispatchQueue(label: "concurrent", attributes: .concurrent)
其中,全域性佇列的優先順序由 QoS (Quality of Service)決定。如果不指定優先順序,就是預設(default)優先順序。另外還有 background,utility,user-Initiated,unspecified,user-Interactive。下面按照優先順序順序從低到高來排列:
- Background:用來處理特別耗時的後臺操作,例如同步、資料持久化。
- Utility:用來處理需要一點時間而又不需要立刻返回結果的操作。特別適用於網路載入、計算、輸入輸出等。
- Default:預設優先順序。一般來說開發者應該指定優先順序。屬於特殊情況。
- User-Initiated:用來處理使用者觸發的、需要立刻返回結果的操作。比如開啟使用者點選的檔案、載入圖片等。
- User-Interactive:用來處理使用者互動的操作。一般用於主執行緒,如果不及時響應就可能阻塞主執行緒的操作。
- Unspecified:未確定優先順序,由系統根據不同環境推斷。比如使用過時的 API 不支援優先順序,此時就可以設定為未確定優先順序。屬於特殊情況。
在日常開發中,GCD 的常見應用有處理後臺任務、延時、單例(Objective-C)、執行緒組等操作,這裡不作贅述。下面我們來看看 Operation 的基本操作:
// serial queue
let serialQueue = OperationQueue()
serialQueue.maxConcurrentOperationCount = 1
// concurrent queue
let concurrentQueue = OperationQueue()
Operation 作為 NSObject 的子類,一般被用於單獨的任務。我們將其繼承重寫之後加入到 OperationQueue 中去執行。iOS 亦提供 BlockOperation 這個子類去方便地執行多個程式碼片段。相比於 GCD,Operation 最主要的特點在於其擁有暫停、繼續、終止等多個可控狀態,從而可以更加靈活得適應併發程式設計的場景。
基於 Operation 和 GCD API 的特點,我們可以得出以下結論:GCD 適用於處理並行開發中的簡單小任務,總體寫法輕便快捷;Operation 適合於封裝模組化的任務,支援多工之間相互依賴的場景。兩者之間的區別同 UIAnimation 和 CALayor Animation 差別異曲同工——由此可見蘋果在設計 API 時一以貫之的思路:提供一個簡單快捷的 API 滿足80%的場景,在提供一套更全面的 API 應對剩下20%更復雜的場景。
併發程式設計中常見問題
在併發程式設計中,一般會面對這樣的三個問題:競態條件、優先倒置、死鎖問題。針對 iOS 開發,它們的具體定義為:
- 競態條件(Race Condition)。指兩個或兩個以上執行緒對共享的資料進行讀寫操作時,最終的資料結果不確定的情況。例如以下程式碼:
var num = 0
DispatchQueue.global().async {
for _ in 1…10000 {
num += 1
}
}
for _ in 1…10000 {
num += 1
}
最後的計算結果 num 很有可能小於 20000,因為其操作為非原子操作。在上述兩個執行緒對num進行讀寫時其值會隨著程序執行順序的不同而產生不同結果。
競態條件一般發生在多個執行緒對同一個資源進行讀寫時。解決方法有兩個,第一是序列佇列加同步操作,無論讀寫,指定時間只能優先做當前唯一操作,這樣就保證了讀寫的安全。其缺點是速度慢,尤其在大量讀寫操作發生時,每次只能做單個讀或寫操作的效率實在太低。另一個方法是,用併發佇列和 barrier flag,這樣保證此時所有併發佇列只進行當前唯一的寫操作(類似將併發佇列暫時轉為序列佇列),而無視其他操作。
- 優先倒置(Priority Inverstion)。指低優先順序的任務會因為各種原因先於高優先順序任務執行。例如以下程式碼:
var highPriorityQueue = DispatchQueue.global(qos: .userInitiated)
var lowPriorityQueue = DispatchQueue.global(qos: .utility)
let semaphore = DispatchSemaphore(value: 1)
lowPriorityQueue.async {
semaphore.wait()
for i in 0...10 {
print(i)
}
semaphore.signal()
}
highPriorityQueue.async {
semaphore.wait()
for i in 11...20 {
print(i)
}
semaphore.signal()
}
上述程式碼如果沒有 semaphore,高優先權的 highPriorityQueue 會優先執行,所以程式會優先列印完 11 到 20。而加了 semaphore 之後,低優先權的 lowPriorityQueue 會先掛起 semaphore,高優先權的highPriorityQueue 就只有等 semaphore 被釋放才能再執行列印。
也就是說,低優先權的執行緒可以鎖上某種高優先權執行緒需要的資源,從而優於迫使高優先權的執行緒等待低優先權的執行緒,這就叫做優先倒置。其對應的解決方法是,對同一個資源不同佇列的操作,我們應該用同一個QoS指定其優先順序。
- 死鎖問題(Dead Lock)。指兩個或兩個以上的執行緒,它們之間互相等待彼此停止執行,以獲得某種資源,但是沒有一方會提前退出的情況。iOS 中有個經典的例子就是兩個 Operation 互相依賴:
let operationA = Operation()
let operationB = Operation()
operationA.addDependency(operationB)
operationB.addDependency(operationA)
還有一種經典的情況,就是在對同一個序列佇列中進行非同步、同步巢狀:
serialQueue.async {
serialQueue.sync {
}
}
因為序列佇列一次只能執行一個任務,所以首先它會把非同步 block 中的任務派發執行,當進入到 block 中時,同步操作意味著阻塞當前佇列 。而此時外部 block 正在等待內部 block 操作完成,而內部block 又阻塞其操作完成,即內部 block 在等待外部 block 操作完成。所以序列佇列自己等待自己釋放資源,構成死鎖。
對於死鎖問題的解決方法是,注意Operation的依賴新增,以及謹慎使用同步操作。其實聰明的讀者應該已經發現,在主執行緒使用同步操作是一定會構成死鎖的,所以我個人建議在序列佇列中不要使用同步操作。
儘管我們已經知道了併發程式設計中的問題,以及其對應方法。但是日常開發中,我們怎樣及時發現這些問題呢?其實 Xcode 提供了一個非常便利的工具 —— Thread Sanitizer (TSan)。在Schemes中勾選之後,TSan就會將所有的併發問題在 Runtime 中顯示出來,如下圖:
這裡我們有7個執行緒問題,TSan清晰地告訴了我們這是讀寫問題,展開之後會告訴我們具體觸發程式碼,十分方便。16年的WWDC上,蘋果也鄭重向大家宣告,如果有併發問題,請記得用 TSan。
Operation 流程化開發
上文中提到 Operation 特別適合模組化工作,也支援多工的互相依賴。這裡我們就來看一個具體的開發案例吧:
實現一個相簿 App,其首頁是個滑動列表(Table View)。列表每行展示加上了濾鏡的圖片。具體實現如下圖:
仔細分析一下相關的操作,實際上就是三步:先載入資料,然後解碼成圖片,最後再給圖片加上濾鏡。所以用 Operation 實現起來如下圖:
對於載入資料,我們可以定義如下的 Operation 子類來進行操作:
class DataLoadOperation: Operation {
fileprivate let url: URL
fileprivate var loadedData: Data?
fileprivate let completion: ((Data?) -> ())?
init(url: URL, completion: ((Data?) -> ())? = nil) {
...
}
override func main() {
if isCancelled { return }
ImageService.loadData(at: url) { data in
if isCancelled { return }
loadedData = data
completion?(data)
}
}
}
這裡我們要注意,DataLoadOperation中的三個變數皆為私有。這是因為其實後續圖片解碼操作並不關心資料是如何操作的,它只關心是否能提供解碼圖片的資料,所以我們可以用 Protocol 來提供這個藉口即可:
// 此協議定義應和 ImageDecodeOperation 放在同一檔案
protocol ImageDecodeOperationDataProvider {
var encodedData: Data? { get }
}
// 次擴充套件應和 DataLoadOperation 放在同一檔案
extension DataLoadOperation: ImageDecodeOperationDataProvider {
var encodedData: Data? { return loadedData }
}
接著再來看看解碼圖片的 Operation 如何實現:
class ImageDecodeOperation: Operation {
fileprivate let inputData: Data?
fileprivate var outputImage: UIImage?
fileprivate let completion: ((UIImage?) -> ())?
init(data: Data?, completion: ((UIImage?) -> ())? = nil) {
...
}
override func main() {
let encodedData: Data?
if isCancelled { return }
if let inputData = inputData {
encodedData = inputData
} else {
let dataProvider = dependencies
.filter { $0 is ImageDecodeOperationDataProvider }
.first as? ImageDecodeOperationDataProvider
encodedData = dataProvider?.encodedData
}
guard let data = encodedData else { return }
if isCancelled { return }
if let decodedData = Decoder.decodeData(data) {
outputImage = UIImage(data: decodedData)
}
if isCancelled { return }
completion?(outputImage)
}
}
extension ImageDecodeOperation: ImageFilterDataProvider {
var image: UIImage? { return outputImage }
}
最後我們再來看 ImageFilterOperation 及其子類如何實現。這裡由於直接輸出 Image,所以就無需用:
protocol ImageFilterDataProvider {
var image: UIImage? { get }
}
class ImageFilterOperation: Operation {
fileprivate let filterInput: UIImage?
fileprivate var filterOutput: UIImage?
fileprivate let completion: ((UIImage?) -> ())?
init(image: UIImage?, completion:
((UIImage?) -> ())? = nil) {
...
}
var filterInput: UIImage? {
var image: UIImage?
if let inputImage = _filterInput {
image = inputImage
} else if let dataProvider = dependencies
.filter({ $0 is ImageFilterDataProvider })
.first as? ImageFilterDataProvider {
image = dataProvider.image
}
return image
}
}
// LarkFilter 和 ReyesFilter 的實現也類似
class MoonFilterOperation : ImageFilterOperation {
override func main() {
if isCancelled { return }
guard let filterInput = filterInput else { return }
if isCancelled { return }
filterOutput = filterInput.applyMoonEffect()
if isCancelled { return }
completion(imageFiltered)
}
}
最後我們用 OperationQueue 將這些 Operation 拼接在一起:
let operationQueue = OperationQueue()
let dataLoadOperation = DataLoadOperation(url: url)
let imageDecodeOperation = imageDecodeOperation(data: nil)
let moonFilterOperation = MoonFilterOperation(image: nil, completion: completion)
let operations = [dataLoadOperation, imageDecodeOperation, moonFilterOperation]
// Add dependencies
imageDecodeOperation.addDependency(dataLoadOperation)
moonFilterOperation.addDependency(imageDecodeOperation)
operationQueue.addOperations(operations, waitUntilFinished: false)
大功告成。從上面我們可以發現,每個操作模組都可以用 Operation 進行自定義和封裝。模組的對應邏輯非常清楚,程式碼複用率和靈活度也非常之高。如果要繼續改進,我們還可以實現一個 AsyncOperation 的類,然後讓 DataLoadOperation 繼承該類,這樣資料載入由同步變為非同步,其效率會大大提高。
總結
iOS 開發中,併發程式設計主要用於提升 App 的執行效能,保證App實時響應使用者的操作。主執行緒一般用於負責 UI 相關操作,如繪製圖層、佈局、互動相應。很多 UIKit 相關的控制元件如果不在主執行緒操作,會產生未知效果。Xcode 中的 Main Thread Checker 可以將相關問題檢測出來並報錯。
其他執行緒例如後天執行緒一般用來處理比較耗時的工作。網路請求、資料解析、複雜計算、圖片的編碼解碼管理等都屬於耗時的工作,應該放在其他執行緒處理。iOS 提供了兩套靈活豐富的 API:GCD 和 Operation。GCD的優點在於簡單快捷,Operation 勝在功能豐富、適合模組化操作。我們享受其便利的同時,也應該及時發現和處理併發程式設計中的三大問題。
作者:故胤道長
連結:https://www.jianshu.com/p/39d6edb54d24
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。