Swift 與 Objective-C

[Swift 進階 #3] Sendable 與 Swift 6 並行處理錯誤遷移

啟用 Swift 6 模式後,大量並行處理錯誤會湧現,而主角通常是 Sendable。本篇整理這個協定的意義:值能否跨越隔離邊界,以及依序採用值型別、不可變化、升級為 actor 的遷移處方。

閱讀 7 分鐘
[Swift 進階 #3] Sendable 與 Swift 6 並行處理錯誤遷移 封面圖

如果團隊開啟過 Swift 6 模式,應該記得那一刻:原本運作正常的專案突然湧出數十、數百個並行處理錯誤。

錯誤訊息的主角大多只有一個:Sendable。

Concurrency 系列第三篇整理最後這塊拼圖,以及 Swift 6 的 strict concurrency。

Sendable 提出的問題 — 這個值可以跨越邊界嗎

在 actor 篇中,我們將隔離整理為「序列執行脈絡」:actor 內、MainActor 上,以及兩者皆非的協作式執行緒池。

但值會頻繁跨越這些邊界。它們會成為 actor 方法的引數、作為回傳值離開,並被 Task 閉包捕捉。

危險就在這裡。隔離保護的是 actor 自身的狀態,但如果跨界的值是可變參考型別呢?

同一個實例可能同時被兩個隔離脈絡持有,原本由 actor 阻擋的資料競爭,會透過帶入的物件復活。就像邊境檢查很嚴格,卻不檢查帶入物品。

Sendable 就是這套檢查標準。它是沒有必要方法的標記協定,意義只有一個:

此型別的值跨越隔離邊界後仍可安全地同時使用。

哪些型別安全?直覺上很簡單。

值型別跨越時會被複製,因此安全(前提是所有儲存屬性都是 Sendable)。Int、String,以及只由 Sendable 屬性組成的 struct 和 enum 都屬於此類。多數情況下編譯器會自動認可。

actor 也安全,因為它內建了自我保護。

不可變類別(final 且只有 let 屬性)也安全。既然不能變更,就不會有競爭。

真正危險的只有一種:具有可變狀態的類別。值型別優先篇整理的「共享+可變=危險」,正是 Sendable 的判定標準。

函式型別另有 @Sendable 標記。Task { }中的閉包就是典型的 @Sendable 閉包;加上此標記的閉包不能捕捉 non-Sendable 值。

閉包篇提過,捕捉可能成為跨越隔離邊界的走私通道,因此語言會檢查通道本身。

strict concurrency — 將警告變成錯誤,將規範變成檢查

這些規則在 Swift 6 之前就存在,但預設基本上保持沉默。

Swift 6 語言模式的核心,就是開啟全部檢查並將問題升級為錯誤,也就是 strict concurrency。在 complete checking 下,non-Sendable 值跨越隔離邊界的每個位置都會成為編譯錯誤。

錯誤大量出現,不是因為程式碼突然變差,而是原本潛藏的競爭現在才被看見。

這就像導入 Optional 時,所有「可能是 nil 的地方」都被揭露。當時需要 nil 檢查,如今則是把並行處理假設移入型別系統的過渡期。

幸好編譯器也比想像中更聰明。Swift 6 加入的區域導向隔離分析(Swift Evolution 提案 SE-0414,region-based isolation)就是例子。

即使是 non-Sendable 值,只要能證明「傳送方不會再碰它」,也允許移動。

因為所有權已移轉的值不可能製造競爭。因此,許多理論上應該報錯的程式碼實際上仍能通過。

sending 參數標記也遵循相同方向。總之,規則正從「一律禁止」變得更精準,改為「證明安全後允許」。

檢查示意圖:struct、actor、final let 類別通過;可變類別遭拒
真正危險的只有具有可變狀態的類別

實戰遷移 — 依錯誤類型處理

大量錯誤大多會收斂成幾種模式。以下整理各類型的標準處方。

類型 1。自製模型是 non-Sendable。 這是最常見、也最健康的錯誤。第一優先是改成 struct。

如果不需要參考身分的資料模型原本宣告成 class,這正是移到值型別的理由。

如果必須是 class,就用 final + let 使其不可變並採用 Sendable。如果必須可變,代表狀態需要擁有者,因此應考慮升級為 actor。

類型 2。全域變數・static var。 過去提醒「static var 實質上就是全域狀態」的所有位置都會變成錯誤。

如果真的需要全域狀態,就宣告隔離。UI 相關加上 @MainActor,否則用 actor 包裝或改成不可變的 let。

類型 3。Delegate・callback 類別卡在邊界。 這常出現在與 UIKit 時代 API 交會的位置。

若該型別實質上只供主執行緒使用,多數情況宣告 @MainActor 就是答案。把「這個類別本來就只在主執行緒使用」的默認事實改成明確宣告。

類型 4。實際安全,但編譯器不知道。 例如內部以鎖保護的類別、C 函式庫包裝器。

此時的出口是 @unchecked Sendable。它宣告「安全性由我保證,請關閉檢查」,但名稱中的 unchecked 已提醒這是 unsafe 類工具。

依照哲學篇第一篇所說的明確出口原則,應在註解中留下保證依據(哪把鎖保護什麼),並限制在最小範圍使用。

如果為了遷移方便開始用 unchecked 填滿錯誤,最後只會得到關閉檢查、卻掛著 Swift 6 標誌的程式碼。

策略上不必一次全部開啟。語言模式可以按模組選擇。

標準做法是由下而上:先將相依性少的末端模組(工具與模型)升級到 Swift 6 模式,最後才升級 App target。

也可以透過 Xcode 的 upcoming feature 旗標先提高檢查等級,觀察警告,作為準備階段。

看懂方向 — 為什麼要讓我們吃這些苦

遷移的痛苦確實存在,因此有必要準確理解付出成本的理由。

Swift 6 的承諾是:能編譯就代表沒有資料競爭。無法重現的間歇性崩潰,以及只在發布後發生的時序錯誤,這整類問題都會在編譯期消失。

這和記憶體安全(Optional、ARC — Automatic Reference Counting、自動參考計數)走過的路相同。並行處理安全性正從「寫得好就行」轉變為「由語言保證」。

這個方向也是哲學系列軌跡的延伸:將容易出錯的規範提升到型別中,在有成本的地方要求明確標記(@unchecked、sending),再以漸進式採用(模組級語言模式)吸收過渡期摩擦。

Swift Evolution 篇提到的流程仍持續加入基本隔離選項等緩衝機制來降低摩擦。現在遇到的警告,正是這場轉換的中途。

從 Swift 5 到正常 Swift 6 的遷移階段路標插圖
值型別化→不可變化→actor→MainActor,unchecked 最後使用並附上依據

總結

  • Sendable 是「型別跨越隔離邊界後仍能安全同時使用」的標記。值型別、actor、不可變類別安全;可變類別就是全部的危險來源。
  • @Sendable 閉包會檢查捕捉,防止閉包成為競爭的走私通道。
  • Swift 6 strict concurrency 將這些檢查升級為錯誤。錯誤爆發不是程式碼變差,而是潛在競爭浮現。
  • 處方優先順序:值型別化 → 不可變化 → 升級為 actor → 宣告隔離(@MainActor)→ 最後使用附上依據的 @unchecked Sendable。
  • 從末端模組由下而上遷移,語言模式按模組逐步提升。

下一篇是 Concurrency 系列的收尾:結構化並行處理。內容涵蓋 Task、async let、TaskGroup 組成的工作樹,以及取消(cancellation)如何沿著這棵樹傳播。


參考資料

延伸閱讀