Swift 與 Objective-C

[Swift 進階 #1] async/await 原理:停止的不是執行緒

await 暫停的不是執行緒,而是函式。本文整理 suspension 的真正意義:函式將狀態儲存在堆積,並把執行緒歸還給協作式執行緒池,以及為什麼循序 await 不是平行執行。

閱讀 6 分鐘
[Swift 進階 #1] async/await 原理:停止的不是執行緒 封面圖

在錯誤處理篇和閉包篇中,我多次加上「進入 async/await 之後」這個前提。現在來看本篇主題。

這是開啟進階系列的 Swift Concurrency 連載第 1 篇,從 async/await 究竟解決了什麼,以及它如何運作開始。

語法本身一天就能學會:在函式加上 async,在呼叫處加上 await。

困難的是接下來的問題:「await 會讓執行緒停止嗎?」「async 函式在哪個執行緒上執行?」

如果無法回答這些問題,並行處理程式碼就只剩下背誦咒語。

本文的目標,就是精確回答這兩個問題。

回呼真正的問題——不是縮排,而是編譯器失明

async/await 之前的非同步處理,就是閉包篇中看到的 completion 處理常式。

大家常把「回呼地獄」的縮排視為問題,但那只是症狀。根源在於編譯器看不見流程。

func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
    fetchUser { result in
        switch result {
        case .success(let user):
            fetchAvatar(user.avatarURL) { avatarResult in
                // completion 忘記呼叫怎麼辦?編譯器不知道
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

不論在哪條路徑漏掉 completion、呼叫兩次,或從錯誤的執行緒呼叫,編譯器都不會說什麼。

這是用呼叫閉包的慣例,取代語言中「回傳」這個基本概念的結果。語言原本保證的所有路徑都回傳值、錯誤會傳遞,如今全交給開發者的自律。

async/await 把非同步處理重新帶回語言的管轄範圍。

func loadProfile() async throws -> Profile {
    let user = try await fetchUser()
    let avatar = try await fetchAvatar(user.avatarURL)
    return Profile(user: user, avatar: avatar)
}

回傳是 return,錯誤是 throws。編譯器再次檢查每條路徑是否都產生值或拋出錯誤。

錯誤處理篇整理的 do-catch 與傳遞規則,在非同步處理中也原樣運作。這不是語法糖,而是找回失去的編譯器視力。

suspension 的真正意義——停止的是函式,不是執行緒

第一個問題:await 會讓執行緒停止嗎?

不會。停止的是函式,執行緒會被釋放去做其他工作。

這個區分是 Swift Concurrency 中最重要的一句話。

在 await 位置,函式會把自己的進度狀態(區域變數與執行位置)儲存在堆積,而不是堆疊,然後歸還原本佔用的執行緒。

這稱為 suspension(暫停)。等待的工作完成後,會接續儲存的狀態恢復(resume)執行。

恢復時不保證使用剛才的執行緒。同一個函式前後的程式碼,可能在不同執行緒上執行。

拜這個設計所賜,Swift 的並行處理執行階段採用協作式執行緒池。執行緒數量大約等於 CPU 核心數,函式會在每個 await 互相讓出執行緒。

這與建立數百條執行緒並讓它們睡眠(blocking)的 GCD(Grand Central Dispatch)時代形成對比。執行緒不會睡眠,因此執行緒暴增與不必要的內容切換,在架構上都會減少。

由此可直接得到一條實務規則:不能在協作式執行緒池中進行阻塞。

用 semaphore 等待或呼叫 sleep,會讓一整條以讓出執行緒為前提設計的執行緒睡著。若有 4 個核心、約 4 條執行緒,其中一條睡著,就等於整個系統有四分之一停止。

「await 是讓出,block 是禁止」是協作式執行緒池的第一條規則。

函式在 await 車站把狀態裝進袋子並歸還執行緒的插圖
在 await 時,函式收拾狀態,執行緒歸還給其他工作

在哪個執行緒上執行——問的不是執行緒,而是隔離

第二個問題:async 函式會在哪個執行緒上執行?

Swift Concurrency 的答案是「放棄這個問題」。執行緒是執行階段管理的資源;開發者指定的是隔離(isolation),也就是「這段程式碼屬於哪個序列執行脈絡」。

代表性的隔離是 MainActor。標有 @MainActor 的函式與型別,保證在主執行緒上執行。

這不同於以前憑感覺在 UI 更新程式碼中到處撒 DispatchQueue.main.async。「只能在主執行緒」的要求會宣告在型別系統中,並由編譯器檢查。

UIViewController 與 SwiftUI View 已經以 @MainActor 宣告。在檢視畫面程式碼中會自動取得主執行緒隔離。

相反地,沒有任何隔離標記的 async 函式不屬於特定 actor(nonisolated),會在協作式執行緒池的某處執行。

如果想把繁重計算移出主執行緒,不需要撰寫「送到背景執行緒」的程式碼。Swift 的思考方式是宣告該工作不屬於 MainActor 隔離。

必要時,可以用 Task 開啟新的非同步脈絡。(Task 的結構化用法會在本系列第 4 篇介紹。)

語言也提供與既有回呼 API 的橋接。可以用 withCheckedThrowingContinuation 將回呼 API 包裝成 async 函式。

精確呼叫 continuation 的 resume 一次,就是這份契約;「Checked」版本會在執行階段偵測違反契約的情況。面對遺留 SDK 時,這是最先該熟悉的橋接工具。

循序 await 的陷阱——並行處理不是免費的

上面的 loadProfile 程式碼其實有一個效能陷阱。如果另一個請求與 fetchUser 無關呢?

// 循序:頭像必須等橫幅結束才開始(總計 2秒)
let avatar = try await fetchAvatar()   // 1秒
let banner = try await fetchBanner()   // 1秒

await 的意思是「在這裡等待」,所以這樣寫會讓兩個請求依序排隊。若工作彼此不相依,應使用 async let 同時啟動。

// 同時:兩個請求一起執行(總計 1秒)
async let avatar = fetchAvatar()
async let banner = fetchBanner()
let profile = try await Profile(avatar: avatar, banner: banner)

async/await 不會自動將工作平行化。選擇循序或同時執行,仍然是設計者的工作。

不過,這項選擇已從回呼組合變成一行語法的差異,這就是進步。async let 與 TaskGroup 的系統化用法,會在結構化並行處理篇詳細介紹。

比較循序 await 的 2 秒與 async let 的 1 秒的時序圖
await 不會自動平行化;獨立工作請用 async let

總結

  • 回呼真正的問題不是縮排,而是編譯器失明。async/await 將回傳與錯誤傳遞交還給語言,恢復編譯器檢查。
  • await 時停止的是函式,不是執行緒。函式狀態儲存在堆積,執行緒會被歸還,恢復時也不保證是同一條執行緒。
  • 執行階段是協作式執行緒池。因此,禁止在其中阻塞(semaphore、sleep)。
  • 問的不是「哪條執行緒」,而是「哪種隔離」。UI 由 @MainActor 宣告保證,回呼 API 則用 continuation 包裝。
  • await 不會自動平行化。獨立工作要用 async let 同時啟動。

下一篇是本系列的核心:actor。內容包括資料競爭究竟是什麼、actor 如何把它提升為編譯時概念,以及惡名昭彰的 reentrancy 陷阱。

延伸閱讀