Swift 新併發框架之 async/await

語言: CN / TW / HK

即使對於經驗豐富的開發者來説,寫出健壯性、可維護性高的併發代碼也是一項具有挑戰性的任務,其挑戰主要體現在兩個方面:

  • 傳統併發模型是基於異步模式,代碼維護性不夠友好;

  • 併發往往意味着 Data Races,這是一類難復現、難排查的常見問題。

Swift 在 5.5 開始引入的新併發框架主要着力解決這 2 個問題。

本文是 『 Swift 新併發框架 』系列文章的第一篇,主要介紹 Swift 5.5 引入的 async/await。

本系列文章對 Swift 新併發框架中涉及的內容逐個進行介紹,內容如下:

本文同時發表於我的個人博客

Overview


在正式開始前,簡單回顧一下同步/異步、串行/並行的概念:

  • 同步(Synchronous)、異步(Asynchronous) 通常指方法(/函數),同步方法表示直到任務完成才返回,異步方法則是將任務拋出去,在任務完成前就返回;

這也就意味着需要通過某種方式獲得異步任務的結果,如:Delegate、Closure 等。

  • 串行(Serial)、並行(Concurrent) 通常指 App 執行一組任務的模式,串行表示一次只能執行一個任務,只有當前任務完成後才啟動下一個任務,而並行指可以同時執行多個任務。最常見的莫過於 GCD 中的串行、並行隊列;

ps. 在此我們不嚴格區分併發、並行的區別。

  • 傳統的併發模型都是基於異步模式的,即異步獲取併發任務的結果。

同步代碼是線性的 (straight-line),非常適合人腦處理。

而異步代碼是非線性的、跳躍式的 (類似於 goto 語句),對於單核的人腦來説是一大挑戰。

除了在閲讀上對人腦思維模式構成較大挑戰外,異步代碼在具體實現上常伴有以下問題:

  • 回調地獄 (Callback Hell);

  • 錯誤處理 (Error Handling);

  • 容易出錯。

初探


我們先通過一個簡單的例子對比一下傳統併發模型與新的併發模型間的區別。

該例子通過 token 獲取頭像,其步驟有:

  • 通過 token 獲取頭像 URL;

  • 通過 URL 下載頭像數據(加密);

  • 對頭像數據解密;

  • 圖片解碼。

```swift class AvatarLoader { func loadAvatar(token: String, completion: (Image) -> Void) { fetchAvatarURL(token: token) { url in fetchAvatar(url: url) { data in decryptAvatar(data: data) { data in decodeImage(data: data) { image in completion(image) } } } } }

func fetchAvatarURL(token: String, completion: (String) -> Void) { // fetch url from net... completion(avatarURL) }

func fetchAvatar(url: String, completion: (Data) -> Void) { // download avatar data... completion(avatarData) }

func decryptAvatar(data: Data, completion: (Data) -> Void) { // decrypt... completion(decryptedData) }

func decodeImage(data: Data, completion: (Image) -> Void) { // decode... completion(avatar) } } ```

loadAvatar 方法中回調層級之深不言而喻。

上述代碼還遺漏了一個重要問題:錯誤處理,其中的網絡請求、解密、解碼都有可能出錯。

優雅地處理錯誤是一項非常考驗基本功的任務。

一般地,錯誤處理分為 2 種情況:

  • 同步方法:優先考慮通過 throw 拋出error,這樣調用方就不得不處理錯誤,因此帶有一定的強制性;

  • 異步方法:在回調中傳遞 error,這種情況下調用方通常會有意無意地忽略錯誤,使健壯性大打折扣。

為了處理錯誤,對上述代碼進行升級:

```swift class AvatarLoader { func loadAvatar(token: String, completion: (Image?, Error?) -> Void) { fetchAvatarURL(token: token) { url, error in guard let url = url else { // 在這個路徑,經常容易漏掉執行 completion 或者 return 語句 completion(nil, error) return }

  fetchAvatar(url: url) { data, error in
    guard let data = data else {
      completion(nil, error)
      return
    }

    decryptAvatar(data: data) { data, error in
      guard let data = data else {
        completion(nil, error)
        return
      }

      decodeImage(data: data) { image, error in
        completion(image, error)
      }
    }
  }
}

}

func fetchAvatarURL(token: String, completion: (String?, Error?) -> Void) { // fetch url from net... completion(avatarURL, error) }

func fetchAvatar(url: String, completion: (Data?, Error?) -> Void) { // download avatar data... completion(avatarData, error) }

func decryptAvatar(data: Data, completion: (Data?, Error?) -> Void) { // decrypt... completion(decryptedData, error) }

func decodeImage(data: Data, completion: (Image?, Error?) -> Void) { // decode... completion(avatar, error) } } ```

可以看到,為了處理錯誤,在 completion 中增加了 error 參數,同時需要將 2 個參數都定義成 Optional

同時,在 loadAvatar 中添加了大量的 guard,這樣的代碼無疑非常醜陋。

Optional 無形中增加了代碼成本。

為此,Swift 5 引入了 Result 用於優化上述錯誤處理場景:

```swift class AvatarLoader { func loadAvatar(token: String, completion: (Result) -> Void) { fetchAvatarURL(token: token) { result in switch result { case let .success(url): fetchAvatar(url: url) { result in switch result { case let .success(decryptData): decryptAvatar(data: decryptData) { result in switch result { case let .success(avaratData): decodeImage(data: avaratData) { result in completion(result) }

          case let .failure(error):
            completion(.failure(error))
          }
        }
      case let .failure(error):
        completion(.failure(error))
      }
    }
  case let .failure(error):
    completion(.failure(error))
  }
}

}

func fetchAvatarURL(token: String, completion: (Result) -> Void) { // fetch url from net... completion(.success(avatarURL)) }

func fetchAvatar(url: String, completion: (Result) -> Void) { // download avatar data... completion(.success(avatarData)) }

func decryptAvatar(data: Data, completion: (Result) -> Void) { // decrypt... completion(.success(decryptData)) }

func decodeImage(data: Data, completion: (Result) -> Void) { // decode... completion(.success(avatar)) } } ```

Result 是 enum 類型,含有 successfailure 2 個 case。

可以看到,通過使用 Result,參數不必是 Optional,另外可以通過 switch/case 來處理結果,在一定程度保證了調用方對錯誤的處理。

在上面這個 Callback Hell 中,直觀上, Result 不但沒有使代碼簡潔,反而更加複雜了。

主要是沒有把代碼抽離開來,不要對 Result 有什麼誤解^__^。

通過這個簡單的例子,可以看到基於 Callback 的異步模型問題不少。

因此,將異步代碼同步化一直是業界努力的方向。

如:Promise,不過其同步也是建立在 callback 基礎上的。

Swift 5.5 引入了 async/await 用於將異步代碼同步化。

很多語言都已支持 async/await,如: JavaScript、Dart 等

先直觀感受一下 async/await

```swift class AvatarLoader { func loadAvatar(token: String) async throws -> Image { let url = try await fetchAvatarURL(token: token) let encryptedData = try await fetchAvatar(url: url) let decryptedData = try await decryptAvatar(data: encryptedData) return try await decodeImage(data: decryptedData) }

func fetchAvatarURL(token: String) async throws -> String { // fetch url from net... return avatarURL }

func fetchAvatar(url: String) async throws -> Data { // download avatar data... return avatarData }

func decryptAvatar(data: Data) async throws -> Data { // decrypt... return decryptData }

func decodeImage(data: Data) async throws -> Image { // decode... return avatar } } ```

相比基於 Callback 的異步版本,基於 async/await 的版本是不是清晰多了。

尤其是 loadAvatar 方法從感觀上就是一個同步方法,閲讀起來無比順暢。

其錯誤處理也使用了同步式的 throws。

至此,通過對比,對 async/await 有了一個較直觀的認識,下面簡單探討一下其實現機制。

深究


首先,還是有必要對 async/await 作一個正式的介紹:

  • async — 用於修飾方法,被修飾的方法則被稱為異步方法 (asynchronous method),異步方法意味着其在執行過程中可能會被暫停 (掛起);

  • await — 對 asynchronous method 的調用需加上 await。同時,await只能出現在異步上下文中 (asynchronous context);

await 則表示一個潛在暫停點 (potential suspension points)。

什麼是 asynchronous context ?其存在於 2 種環境下:

  • asynchronous method body — 異步方法體屬於異步上下文的範疇;

  • Task closure — Task 任務閉包也屬於 asynchronous context。

Task 是在 Swift 5.5 中引入的,主要用於創建、執行異步任務,後續文章會介紹。

因此,只能在異步方法或 Task 閉包中通過 await 調用異步方法。

異步方法執行過程中可能會暫停?

potential suspension points?

怎麼暫停?

剛開始接觸 async/await 時,下意識地可能會有這些疑問。

2 個關鍵點:

  • 暫停的是方法,而不是執行方法的線程;

  • 暫停點前後可能會發生線程切換。

在 Swift 新併發模型中進一步弱化了『 線程 』,理想情況下整個 App 的線程數應與內核數一致,線程的創建、管理完全交由併發框架負責。

Swift 對異步方法 (asynchronous method) 的處理就遵守了上述思想:

  • 異步方法被暫停點 (suspension points) 分割為若干個 Job

  • 在併發框架中 Job 是任務調度的基本單元;

  • 併發框架根據實時情況動態決定某個 Job 的執行線程;

  • 也就是同一個異步方法中的不同 Job 可能運行在不同線程上。

正是由於異步方法在其暫停點前後可能會變換執行線程,因此在異步方法中要慎用鎖、信號量等同步操作。

```swift let lock = NSLock.init() func test() async { lock.lock() try? await Task.sleep(nanoseconds: 1_000_000_000) lock.unlock() }

for i in 0..<10 { Task { await test() } } ```

像上面這樣的代碼在 lock.lock() 處會產生死鎖,換成信號量也是一樣。

await 之所以稱為『 潛在 』暫停點,而不是暫停點,是因為並不是所有的 await 都會暫停,只有遇到類似 IO、手動起子線程等情況時才會暫停當前調用棧的運行。

總之,對於異步方法如何切分 Job 等細節可以不關心,await 可能會暫停當前方法的運行,並在時機成熟後在其他線程恢復運行是我們需要明確瞭解的

參考資料

swift-evolution/0296-async-await.md at main · apple/swift-evolution · GitHub

swift-evolution/0302-concurrent-value-and-concurrent-closures.md at main · apple/swift-evolution · GitHub

swift-evolution/0337-support-incremental-migration-to-concurrency-checking.md at main · apple/swift-evolution · GitHub

swift-evolution/0304-structured-concurrency.md at main · apple/swift-evolution · GitHub

swift-evolution/0306-actors.md at main · apple/swift-evolution · GitHub

swift-evolution/0337-support-incremental-migration-to-concurrency-checking.md at main · apple/swift-evolution · GitHub

Understanding async/await in Swift • Andy Ibanez

Concurrency — The Swift Programming Language (Swift 5.6)

Connecting async/await to other Swift code | Swift by Sundell