Swift 併發新體驗
引言
對於誕生於 2014 年的 Swift 而言,它已不再年輕。至今我還記得初次體驗 Swift 時的喜悅之情,比起冗長的 OC 而言,它更加現代、簡潔、優雅。但 Swift 的前期發展是野蠻而動盪的,每次釋出新版本時都會導致舊專案出現大量的報錯和告警,專案遷移工作令開發者苦不堪言。不得不說,Swift 誕生之初就敢於在專案中實踐並運用的人,是真的猛士。我是從 Swift 4 才開始將專案逐漸從 OC 向 Swift 遷移的,到 2019 年 Swift 5 實現了 ABI 穩定時,才全面遷移至純 Swift 開發。
ABI 的穩定象徵著 Swift 的成熟,然而在併發程式設計方面,Swift 卻落後了一截。Chris Lattner 早在2017年發表的 《Swift 併發宣言》 中就描繪了令人興奮的前景。2021 年 Swift 5.5 的釋出終於將 Concurrency 加入了標準庫,從此,Swift 併發程式設計變得更為簡單、高效和安全。
在此之前,我們通常使用閉包來處理非同步事件的回撥,如下是一個下載網路圖片的示例:
swift
func fetchImage(from: String, completion: @escaping (Result<UIImage?, Error>) -> Void) {
URLSession.shared.dataTask(with: .init(string: from)!) { data, resp, error in
if let error = error {
completion(.failure(error))
} else {
DispatchQueue.main.async {
completion(.success(.init(data: data!)))
}
}
}.resume()
}
程式碼並不複雜,不過這只是針對下載單一圖片的場景。我們將需求設計的再複雜一點點:先下載前兩張圖片(無先後順序)並展示,然後再下載第三張圖片並展示,當三張圖片都下載完成後,再展示在 UI 介面。當然,實際開發中一般是先下載的圖片先展示,這裡的非常規設計只作舉例而已。
完整的實現程式碼變成如下:
```swift import UIKit
class ViewController: UIViewController {
let sv = UIScrollView(frame: UIScreen.main.bounds) let imageViews = [UIImageView(), UIImageView(), UIImageView()] let from = [ "http://images.pexels.com/photos/10646758/pexels-photo-10646758.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500", "http://images.pexels.com/photos/9391321/pexels-photo-9391321.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500", "http://images.pexels.com/photos/9801136/pexels-photo-9801136.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500" ]
override func viewDidLoad() { super.viewDidLoad()
sv.backgroundColor = .white
view.addSubview(sv)
sv.contentSize = .init(width: 0, height: UIScreen.main.bounds.height + 100)
imageViews.enumerated().forEach { i, v in
v.backgroundColor = .lightGray
v.contentMode = .scaleAspectFill
v.clipsToBounds = true
v.frame = .init(x: 0, y: CGFloat(i) * 220, width: UIScreen.main.bounds.width, height: 200)
sv.addSubview(v)
}
let group = DispatchGroup()
let queue = DispatchQueue(label: "fetchImage", qos: .userInitiated, attributes: .concurrent)
let itemClosure: (Int, DispatchWorkItemFlags, @escaping () -> ()) -> DispatchWorkItem = { idx, flags, completion in
return DispatchWorkItem(flags: flags) {
self.fetchImage(from: self.from[idx]) { result in
print(idx)
switch result {
case let .success(image):
self.imageViews[idx].image = image
case let .failure(error):
print(error)
}
completion()
}
}
}
from.enumerated().forEach { i, _ in
group.enter()
let flags: DispatchWorkItemFlags = (i == 2) ? .barrier : []
queue.async(group: group, execute: itemClosure(i, flags, {
group.leave()
}))
}
group.notify(queue: queue) {
DispatchQueue.main.async {
print("end")
}
}
} } ```
這裡使用了 GCD 來實現需求,看上去也不是特別複雜,我們還能使用 PromiseKit 來管理事件匯流排,不直接編寫 GCD 層面的程式碼,使程式碼更簡潔更易讀。但是試想一下,實際需求可能更復雜,我們也許要先從服務端獲取一些資料後,再下載圖片並進行解碼以及快取,同時可能還會有下載音訊、影片等任務要處理,這樣的情況就更加複雜了。不管有沒有使用 PromiseKit 這樣優秀的庫,隨著業務的複雜度增加,都無法迴避會越來越明顯地暴露出來的問題:
- 閉包本身難以閱讀,還有導致迴圈引用的潛在風險
- 回撥必須覆蓋各種情況,一旦遺漏則難以排查問題所在
- Result 雖然較好地處理了錯誤,但難以解決錯誤向上傳遞的問題
- 巢狀層級太深導致回撥地獄
- ......
async/await 初體驗
針對上面的這些問題,Concurrency 的解決方案是使用 async/await
模式,該模式在 C#、Javascript 等語言中有著成熟的應用。現在,我們終於可以在 Swift 中使用它了!
下面是使用 async/await 改造 fetchImage 的程式碼,這裡先了解一下 async
和 await
關鍵字的基本使用:
- async:新增在函式末尾,標記其為非同步函式
- await:新增在呼叫 async 函式前,表明該處的程式碼會受到阻塞,直到非同步事件返回
swift
func fetchImage(idx: Int) async throws -> UIImage { // 1
let request = URLRequest(url: .init(string: from[idx])!)
// 2
let (data, resp) = try await URLSession.shared.data(for: request)
// 3
print(idx, Thread.current)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
throw FetchImageError.badNetwork
}
guard let image = UIImage(data: data) else {
throw FetchImageError.downloadFailed
}
return image
}
-
async throws 表明該函式是非同步的、可丟擲錯誤的
-
URLSession.shared.data 方法的全名如下,因此我們需要使用 try await 來呼叫該方法
swift
public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)
- 程式碼執行到這裡時,表明下載圖片的非同步事件已經結束了
相信你對 async/await 的使用已經有點感覺了:async 用來標記非同步事件,await 用來呼叫非同步事件,等待非同步事件返回,然後繼續執行後面的程式碼。它和 throws、try 這對關鍵詞很像,幾乎總是同時出現在相關場合。有的讀者可能會納悶,為何 try await 和 async throws 的順序是反的,這裡不必糾結,設計如此罷了,而且 try await 好像聽上去和寫起來更順一點?
接下來我們要做的就是呼叫非同步函式 fetchImage,並且需要控制圖片的下載順序,實現程式碼:
swift
// 1
async let image0 = try? fetchImage(idx: 0)
async let image1 = try? fetchImage(idx: 1)
// 2
let images = await [image0, image1]
imageViews[0].image = images[0]
imageViews[1].image = images[1]
// 3
imageViews[2].image = try? await fetchImage(idx: 2)
- async let 可以讓多個非同步事件同時執行,這裡表示同時非同步下載前兩張圖片。
前面我們說了 async 用來標記非同步函式,await 用來呼叫,幾乎總是出現在同一場合。而且編譯器會去檢查呼叫 async 函式時是否使用了 await,如果沒有,則會報錯。而這裡,我們在呼叫 fetchImage 時並沒有使用 await,依然可以通過編譯,是因為在使用 async let 時,如果我們沒有顯示地使用 try await,Swift 會隱式的實現它,而且能將 try await 的呼叫時機推遲。
上面的程式碼,我們將它改成如下也是可以的:
swift
async let image0 = fetchImage(idx: 0)
async let image1 = fetchImage(idx: 1)
let images = try await [image0, image1]
-
await 阻塞當前任務,等待上面的兩個非同步任務返回結果
-
前兩張圖片下載完成之後,繼續非同步下載第三張圖片並展示
將上面的程式碼放在 viewDidLoad 中執行,發現凡是有 async 的地方都報紅了。這是因為如果某個函式內部呼叫了 async 函式,該函式也需要標記為 async,這樣才能為函式體內部提供非同步環境,並且將非同步事件進行傳遞。而 viewDidLoad 沒有被標記為 async,編譯器發現了這一問題並報錯了。但是,我們不能這樣做。因為 viewDidLoad 是重寫的 UIViewController 中的方法,它是執行在主執行緒中的同步函式而且必須如此。
那麼這個問題該如何解決呢?Swift 為我們提供了 Task
,在建立的 Task 例項閉包中,我們將獲得一個新的非同步環境,如此,就可以呼叫非同步函數了。Task 就像打破同步環境結界的橋樑,為我們提供了通向非同步環境的通道。
我們將上面的程式碼放在 Task 例項的閉包中,就可以順利執行程式了。
swift
Task {
// 1
async let image0 = fetchImage(idx: 0)
async let image1 = fetchImage(idx: 1)
// 2
let images = try await [image0, image1]
imageViews[0].image = images[0]
imageViews[1].image = images[1]
// 3
imageViews[2].image = try? await fetchImage(idx: 2)
}
上面的程式碼最終的表現結果和改造前還有點細微差別:前兩張圖片雖然是同時非同步下載的,但是會相互等待,直到兩張圖片都下載完成後,才展示在介面上。這裡提供兩個思路去實現與之前同樣的效果,一是將展示圖片的邏輯放在 fetchImage 方法中,另一種是使用 Task 解決,參考程式碼如下:
swift
Task {
let task1 = Task {
imageViews[0].image = try? await fetchImage(idx: 0)
}
let task2 = Task {
imageViews[1].image = try? await fetchImage(idx: 1)
}
let _ = await [task1.value, task2.value]
imageViews[2].image = try? await fetchImage(idx: 2)
}
關於 Task、TaskGroup 並不在本文的討論範疇,後面會有單獨的章節去詳述。
這裡要補充說明的是,當我們使用 async let 時,實際上是在當前任務中隱式地建立了一個新的 task,或者叫子任務。async let 就像一個匿名的 Task,我們沒有顯示地建立它,也不能使用本地變數儲存它。所以 Task 相關的 value、cancel() 等屬性和方法,我們都無法使用。
async let 其實就是一個語法糖,我們可以使用它應對多數場景下的非同步事件處理。如果要處理的非同步事件數量多且關係複雜,甚至涉及到事件的優先順序,那麼使用 Task、TaskGroup 是更明智的選擇。
Refactor to Async
如果你想把之前基於回撥的非同步函式遷移至 async/await(最低支援 iOS 13),Xcode 內建了非常方便的操作,能夠快速地進行零成本的遷移和相容。
如圖所示,選中相應的方法,右鍵選擇 Refactor,會有三種選擇:
- Convert Function to Async:將當前的回撥函式轉換成 async,覆蓋當前函式
- Add Async Alternative:使用 async 改寫當前的回撥函式,基於改寫後的函式結合 Task 再提供一個回撥函式
- Add Async Wrapper:保留當前的回撥函式,在此基礎上提供一個 async 函式
從上我們可以得知 Wrapper 支援的 iOS 版本範圍是大於 Alternative 的,我們可以根據專案的最低支援版本按需操作:
< iOS 13
,選 3>= iOS 13
- 整體遷移至 async:選 1
- 保留回撥函式 API:選 3 或 1
小結
async/await 簡化了非同步事件的處理,我們無需和執行緒直接打交道,就可以寫出安全高效的併發程式碼。回撥機制經常衍生出的麵條式程式碼也不復存在,我們可以用線性結構來清晰地表達併發意圖。
這得益於結構化併發的程式設計正規化在背後做理念支撐,結構化併發的思想和結構化程式設計是類似的。每個併發任務都有自己的作用域,並且有著明確且唯一的入口和出口。不管這個併發任務內部的實現有多複雜,它的出口一定是單一的。
我們把要執行併發任務想象成一根管道,水流就是管道內要執行的任務。在非結構化程式設計的世界,子任務會生成許多的管道分支,水流會從不同的分支出口流出去,也可能會遇到故障,我們需要在不同的出口去處理水流結果,出口越多,我們越手忙腳亂。而結構化程式設計的世界裡,我們無需關心各個分支出口,只要守住管道另一端的唯一出口就可以了,分支出口不管多複雜,水流最終會回到管道的出口。