Swift 併發新體驗

語言: CN / TW / HK

引言

對於誕生於 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 = [ "https://images.pexels.com/photos/10646758/pexels-photo-10646758.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500", "https://images.pexels.com/photos/9391321/pexels-photo-9391321.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500", "https://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 的程式碼,這裡先了解一下 asyncawait 關鍵字的基本使用:

  • 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 }

  1. async throws 表明該函式是非同步的、可丟擲錯誤的

  2. URLSession.shared.data 方法的全名如下,因此我們需要使用 try await 來呼叫該方法

swift public func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse)

  1. 程式碼執行到這裡時,表明下載圖片的非同步事件已經結束了

相信你對 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)

  1. 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]

  1. await 阻塞當前任務,等待上面的兩個非同步任務返回結果

  2. 前兩張圖片下載完成之後,繼續非同步下載第三張圖片並展示

將上面的程式碼放在 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,會有三種選擇:

  1. Convert Function to Async:將當前的回撥函式轉換成 async,覆蓋當前函式
  2. Add Async Alternative:使用 async 改寫當前的回撥函式,基於改寫後的函式結合 Task 再提供一個回撥函式
  3. Add Async Wrapper:保留當前的回撥函式,在此基礎上提供一個 async 函式

從上我們可以得知 Wrapper 支援的 iOS 版本範圍是大於 Alternative 的,我們可以根據專案的最低支援版本按需操作:

  • < iOS 13,選 3
  • >= iOS 13
  • 整體遷移至 async:選 1
  • 保留回撥函式 API:選 3 或 1

小結

async/await 簡化了非同步事件的處理,我們無需和執行緒直接打交道,就可以寫出安全高效的併發程式碼。回撥機制經常衍生出的麵條式程式碼也不復存在,我們可以用線性結構來清晰地表達併發意圖。

這得益於結構化併發的程式設計正規化在背後做理念支撐,結構化併發的思想和結構化程式設計是類似的。每個併發任務都有自己的作用域,並且有著明確且唯一的入口和出口。不管這個併發任務內部的實現有多複雜,它的出口一定是單一的。

我們把要執行併發任務想象成一根管道,水流就是管道內要執行的任務。在非結構化程式設計的世界,子任務會生成許多的管道分支,水流會從不同的分支出口流出去,也可能會遇到故障,我們需要在不同的出口去處理水流結果,出口越多,我們越手忙腳亂。而結構化程式設計的世界裡,我們無需關心各個分支出口,只要守住管道另一端的唯一出口就可以了,分支出口不管多複雜,水流最終會回到管道的出口。