Swift 與 Objective-C

[Swift 深入 #4] 結構化並行處理:為什麼不該隨意建立 Task

結構化並行處理回答了誰要負責非同步工作的結束。本文整理子工作無法離開父層範圍的原理、取消訊號的傳播方式,以及使用 Task.detached 時必須手動補回的保證。

閱讀 6 分鐘
[Swift 深入 #4] 結構化並行處理:為什麼不該隨意建立 Task 封面圖

Concurrency 系列還剩最後一個問題。到目前為止,我們看過 async 函式(第 1 篇)、actor(第 2 篇)與 Sendable(第 3 篇)。

本文接續上一篇文章 Swift 深入 #3

我們還沒有妥善處理進入非同步世界的入口 Task。而一旦開始處理 Task,這個系列最實用的概念就會出現。

結構化並行處理。

名稱聽起來很宏大,但問題很簡單:啟動非同步工作後,誰要對它的結束負責?

「結構化」的意思 — 工作也有範圍

這個名稱來自結構化程式設計這個舊術語。經過 goto 能把控制流程任意跳轉的年代後,if 與 for 等區塊結構開始保證「控制流程會回到進入的位置」。

結構化並行處理將同一原則套用到非同步工作。子工作不能離開父層範圍,而父工作必須等所有子工作完成後才能結束。

第 1 篇介紹的 async let,其實就是這項原則的第一個例子。

func loadDashboard() async throws -> Dashboard {
    async let profile = fetchProfile()    // 子工作 1
    async let feed = fetchFeed()          // 子工作 2
    return try await Dashboard(profile: profile, feed: feed)
}   // 這個函式在兩個子工作完成清理前無法返回

使用 async let 建立的子工作,必須在函式返回前完成或被取消。發生例外時也一樣。

如果 feed 拋出錯誤,仍在執行的 profile 會自動收到取消訊號。函式完成清理後才會傳遞錯誤。

因為工作被繫結在範圍內,所以結構上不可能存在「啟動後就忘記的工作」。

在閉包篇中,escaping 是「生命週期比函式更長的閉包」的警告標籤。結構化並行處理則直接從預設模型中移除了「生命週期比函式更長的工作」。

如果子工作的數量是動態決定的,就使用 TaskGroup。平行取得 URL 清單的典型程式碼如下。

let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
    for url in urls {
        group.addTask { (url, try await download(url)) }
    }
    var result: [URL: Image] = [:]
    for try await (url, image) in group {
        result[url] = image
    }
    return result
}

有兩點值得注意。結果會依完成順序抵達(若需要順序,就像上面一樣與鍵一起保存),而且閉包結束時,群組中的所有子工作都會完成清理。

如果說 async let 是「幾個子工作」的語法,那麼 TaskGroup 就是「n 個子工作」的語法,而範圍保證相同。

取消 — 訊號會到,但停止是你的工作

結構化並行處理的第二個支柱是取消傳播。父工作被取消時,取消訊號會沿著樹向下傳給所有後代。

離開畫面時,該畫面啟動的網路要求會連鎖取消,就是拜這個結構所賜。

不過 Swift 的取消是協作式(cooperative)的。取消訊號不會強制終止工作。

它只會設定 isCancelled 旗標;確認旗標並停止,是工作本身的責任。

func processLargeFile() async throws {
    for chunk in chunks {
        try Task.checkCancellation()   // 如果已取消,就拋出 CancellationError
        await process(chunk)
    }
}

為什麼不是強制終止?因為工作若在檔案寫到一半、持有鎖定,或交易進行到一半時立即死亡,系統就會留下損壞狀態。

協作式取消的邏輯是:「只有工作自己知道適合在哪個位置停止。」

實務上的含義很明確:長時間執行的迴圈必須加入 checkCancellation。

URLSession 等系統 API 內部已經會遵守取消,因此直接交給它們即可。

相反地,忽略取消的長時間計算程式碼,即使取消也不會停止。不是無法取消,而是沒有檢查。

描繪從父工作傳向子工作的取消訊號與檢查清單的示意圖
取消訊號會沿著樹向下傳播,但只有進行檢查的工作會停止

Task 與 Task.detached — 非結構化世界及其代價

如果到這裡都是結構的世界,那麼 Task { } 就在外部。使用 Task 建立的工作不會加入父子樹,是非結構化(unstructured)工作。

它不受範圍約束,錯誤和取消也不會自動傳播。

那為什麼需要它?因為我們需要一座從同步世界通往非同步世界的橋樑。

按鈕點擊處理常式是同步函式,無法使用 await,因此用 Task { await viewModel.refresh() } 開啟新的非同步情境。這就是 Task 合理的使用位置。

SwiftUI 的 .task 修飾器在這座橋上加入了生命週期管理(檢視消失時自動取消),因此 UI 中應優先使用它。

問題在於 Task 變成習慣。在 async 函式中再次建立 Task,或以 fire-and-forget 方式丟出的 Task 都是例子。結構所提供的保證(等待完成、錯誤傳播、取消連鎖)全部都得手動補回。

你必須保存參照、手動呼叫 cancel,也要手動記錄錯誤。沒有這些管理程式碼,就會出現悄悄消失的錯誤與殭屍工作。

整理成規則:在 async 情境中,預設使用 async let・TaskGroup;Task 只在同步→非同步的邊界使用。

Task.detached 更遠離結構。它不繼承優先順序、actor 隔離或 task-local 值,是完全孤立的工作(SE-0304)。

有人會因為「想離開 @MainActor 情境來做繁重工作」而使用它,但多數情況是錯誤的處方。只要宣告為 nonisolated async 函式,就會自動在協作執行緒池中執行(第 1 篇)。

真正需要 detached 的情況很少,大致只有必須刻意獨立於目前情境的背景工作。借用官方文件的說法,它是最後手段(last resort)。

系列總結 — 四個概念組成的一幅圖

在 Concurrency 系列結束之際,讓我們一次整理完整全貌。

async/await 將非同步流程帶回編譯器的視野(第 1 篇)。actor 將序列化保護內建於共享可變狀態(第 2 篇),Sendable 則檢查跨越隔離邊界的值是否安全(第 3 篇)。

而結構化並行處理將工作生命週期繫結到範圍。開始的工作一定會結束,取消則會沿著樹流動(本篇)。

貫穿四者的句子只有一句:將並行處理的隱含規律轉化為語言的明確結構。

執行緒管理規律變成協作執行緒池與 suspension,鎖定規律變成 actor。「這個物件能不能傳到其他執行緒」的口耳相傳知識變成 Sendable,而「別忘了清理要求」的 code review 意見變成工作樹。

Swift 哲學系列第 1 篇提到的安全優先原則,已經擴展到並行處理這個最困難的領域。這就是 Swift Concurrency 的完整故事。

對比圍欄內的結構化工作與漂浮在外的非結構化 Task 的插圖
結構外的 Task 必須手動補回完成、錯誤與取消保證

總結

  • 結構化並行處理的原則是「子工作無法離開父層範圍」。async let(固定數量)與 TaskGroup(動態數量)就是對應語法,發生錯誤時同儕取消與清理會自動完成。
  • 取消是協作式的。訊號會沿著樹傳播,但真正停止的是加入 checkCancellation 並進行檢查的工作本身。
  • Task { } 只用作同步→非同步的橋樑。async 情境中習慣性建立 Task,會把結構保證轉成手動管理的負債。Task.detached 是最後手段。
  • 整個系列的重點:執行緒、鎖定與生命週期管理的隱含規律,已轉移為 suspension・actor・Sendable・工作樹等語言結構。

從下一篇開始,我們將深入效能:final 為何是效能關鍵字、協定呼叫在哪裡變慢,以及靜態分派與動態分派的實際面貌。

延伸閱讀

來源與驗證

  • SE-0304: Structured ConcurrencySwift Evolution · 標準或規範 · 查核 2026年8月17日依據: Task・TaskGroup 的父子結構、取消・優先順序・task-local・actor 情境繼承