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 簡化了異步事件的處理,我們無需和線程直接打交道,就可以寫出安全高效的併發代碼。回調機制經常衍生出的麪條式代碼也不復存在,我們可以用線性結構來清晰地表達併發意圖。

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

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