← 所有文章

Swift 6.2 並行實務:預設 MainActor,刻意才離開

Swift 6.0 把資料競爭變成編譯錯誤,而這一年來,大家也都為此付出了代價。嚴格並行檢查器把原本平凡的 UI 程式碼,變成一整面牆的 Sendable 違規,以及「main actor-isolated property can not be referenced from a nonisolated context」之類的錯誤。診斷本身沒錯(那些程式碼確實有可能競爭),但錯誤量之大反而淹沒了真正的訊號,於是不少團隊乾脆停留在 Swift 5 模式,或是猛灑 @MainActor 直到錯誤安靜下來為止。

Swift 6.2 改的是預設值,而不是規則。資料競爭安全的保證一模一樣,變的只是編譯器的起點。採用新的預設值,那面牆大半就會消失,因為編譯器現在會假設您的 App 本來就成立的事實:大部分程式碼都跑在 main actor 上,而您是在具名、刻意的地方才離開它。這正是我在 941 系列 App 上採用的模型1。以下說明它的運作方式,以及開啟之後仍會咬人的那六個錯誤。

重點摘要

  • SE-0466 讓您把整個模組預設為 @MainActor 設定 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor,從此不必再替每一個 view、model 與 view-model 加註解2
  • SE-0461 讓 nonisolated 的 async 函式預設在呼叫端的 actor 上執行(nonisolated(nonsending)),於是呼叫 async 程式碼不再被迫進行 actor 跳轉,連帶過去那些跨界 Sendable 錯誤也一併消失3
  • @concurrent 是逃生口。 把函式標記為 @concurrent,就能刻意且清楚地把吃 CPU 的繁重工作(解碼、影像處理)推到背景執行緒上4
  • Xcode 26 會替新專案把這兩個預設都開啟。總開關是 SWIFT_APPROACHABLE_CONCURRENCY = YES5
  • 這個模型把負擔反轉了:您不再需要證明每一行都能安全地離開主執行緒,而是把一切留在主執行緒上,只證明您離開的那少數幾處。
  • 切換之後仍會殘存六個具體錯誤。一旦看懂模式,這六個都有一行解法。

反轉,以及它為何重要

舊模型(Swift 6.0):程式碼在您隔離它之前都是 nonisolated。每個碰到 UI 狀態的型別都需要 @MainActor,每個跨隔離邊界的 async 呼叫都需要 Sendable 一致性,而編譯器會把每一處缺口標出來。對一個 95% 程式碼早已跑在主執行緒上的 App 來說,您把時間花在替那 95% 加註解,只為描述一個從未有過疑問的事實。

新模型(Swift 6.2):程式碼在您離開它之前都在 main actor 上。SE-0466 讓您把 main-actor 隔離宣告為模組預設,於是一個 view、它的 model 與它的輔助型別,全都是 @MainActor,一個註解也不必寫2。SE-0461 接著移除第二筆稅:nonisolated 的 async 函式現在會在呼叫它的那個 actor 上執行,而不是跳到全域 executor,因此 await 它並不會把您拖過隔離邊界,也不會要求作用域內的一切都得是 Sendable3

這個心智模型,正好對應 UI App 實際的行為。主執行緒優先不是妥協,而是「狀態即視圖」的 App 的本質。並行於是成為您主動伸手去拿的例外——具名而受限——而不是每一行都得提防的環境條件。編譯器的工作從「證明這段可以安全地並行執行」翻轉成「您說這段要並行執行,那就證明它安全」,而第二個問題要問的地方少得多。

開啟它

兩個建構設定,兩者都在 Xcode 26 新專案的預設值之中,也都值得在既有專案上明確設定5:

SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_APPROACHABLE_CONCURRENCY = YES

第一個是 SE-0466:模組預設為 main-actor 隔離。第二個是總開關,啟用整套 approachable-concurrency 特性集,其中包含 SE-0461 的「呼叫端執行」行為。在 Swift package 裡,您是透過 swiftSettings 搭配對應的 upcoming-feature 旗標來設定相同的預設值,而不是用 Xcode 建構設定6

在既有專案上把兩者都打開,錯誤數會大幅下降,因為檢查器標出的多半是它先前無法假設為主執行緒的主執行緒程式碼。剩下的,是一份簡短的真實邊界清單。它們值得逐一記住名字,因為每一處都是您的程式碼真正離開 main actor 的地方,而解法就是精確地把這件事說出來。

切換後殘存的六個錯誤

以下是在 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 之下仍會出現的嚴格並行錯誤,取自把 941 系列 App 遷移到這個模型的過程1。每一個都是真實的邊界,而非誤報——這正是為何解法是一個精確的註解,而不是一道壓制。

1. Sendable 型別上的純函式。 某個 Sendable enum 上的純方法(URL 建構器、格式化器)在模組預設下會繼承 main-actor 隔離,接著在從 nonisolated 情境呼叫時報錯:「Call to main actor-isolated instance method in a synchronous nonisolated context.」 這個方法不碰任何狀態,所以把它隔離到 main actor 是錯的。標記為 nonisolated7:

nonisolated func searchURL(for query: String) -> URL? { ... }

2. 預設參數裡的單例靜態屬性。 static let shared = Foo() 在預設下是 main-actor 隔離的,但預設參數值是在呼叫端的情境裡求值的,而呼叫端往往是 nonisolated:「Main actor-isolated static property ‘shared’ can not be referenced from a nonisolated context.」 把該靜態屬性設為 nonisolated。如果型別是 Sendable(或因為您自己保護其狀態而是 @unchecked Sendable),就不需要任何 unsafe 限定詞:

final class KeychainProxySecretStore: @unchecked Sendable {
    nonisolated static let shared = KeychainProxySecretStore()
}

3. 不可變的基本型別常數。 同樣的預設參數問題,也會打到一個單純的常數:某個從 nonisolated 預設引數裡被引用的 static let defaultInterval。解法一模一樣,而這個常數本來就能安全共享:

nonisolated static let defaultInterval: TimeInterval = 15 * 60

4. 讀取所捕捉 selfTask 主體。 外層閉包捕捉了 [weak self];在內部,一個 Task { @MainActor in self?.foo() } 讀取了那個被捕捉的 optional:「Reference to captured var ‘self’ in concurrently-executing code.」 這個 Task 並行地從外圍作用域讀取一個 var 綁定,而那正是競爭所在。在 Task 邊界重新捕捉 self,讓 Task 擁有一份不可變的綁定:

NotificationCenter.default.addObserver(...) { [weak self] _ in
    Task { @MainActor [weak self] in
        self?.value = next
    }
}

5. 讀取 main-actor 狀態的 KVO 回呼。 一個 webView.observe(\.canGoBack) { wv, _ in ... } 回呼是 @Sendable 的,因此是 nonisolated,但 WKWebView.canGoBack 是 main-actor 隔離的:「Main actor-isolated property ‘canGoBack’ can not be referenced from a Sendable closure.」 KVO 是在更動該值的那條執行緒上同步送達的,而 WKWebView 的導覽狀態只在主執行緒上更動,所以這次讀取是安全的。用 MainActor.assumeIsolated 來斷言它,這能完全省去 Task 跳轉,並保持同步8:

let pushNav: @Sendable (WKWebView?) -> Void = { [weak self] webView in
    MainActor.assumeIsolated {
        guard let self else { return }
        // safe to read webView?.canGoBack synchronously
    }
}

assumeIsolated 是對編譯器的一個承諾,不是一個提問。只在執行期不變式確實成立之處使用它(一個有文件背書的主執行緒回呼),因為一個錯誤的承諾換來的是崩潰,而不是警告。

6. 不該跑在主執行緒上的繁重工作。 這一個檢查器不會標出來,而它卻是最需要您自己抓出來的。在 main-actor 預設下,一個同步、吃 CPU 的方法(JSON 解碼大型負載、縮放影像)會跑在 main actor 上,讓您的 UI 卡頓。預設讓您留在主執行緒上;@concurrent 才是您刻意離開的方式4:

@concurrent
func decodeLargePayload(_ data: Data) async throws -> Report {
    try JSONDecoder().decode(Report.self, from: data)
}

@concurrent 會把函式卸載到全域 executor 上,且在設計上與 @MainActor、自訂全域 actor 以及 nonisolated(nonsending) 互斥:一個函式要嘛在它的呼叫端所在之處執行,要嘛刻意離它而去,絕不模稜兩可4。新模型所要求的紀律,全都濃縮在這個模式裡。凡是碰到 UI 的一切都留在主執行緒上,只在可量測地需要背景執行緒的工作上,才伸手去拿 @concurrent

當預設值不適合您時

main-actor 優先很適合 App:SwiftUI 與 UIKit 程式碼絕大多數都在主執行緒上,預設值正好對應現實。它在兩種情況下就沒那麼合適,而假裝不是這樣只會浪費您的時間。

  • 沒有 UI 的程式庫或框架目標。 一個網路層、一個剖析器,或一個資料引擎,沒有理由預設到 main actor,而這樣做會逼得幾乎所有東西都得掛上 @concurrentnonisolated。對這類目標,讓 SWIFT_DEFAULT_ACTOR_ISOLATION 維持未設定,並依舊用反過來的老方法刻意做隔離。
  • 大量使用 actor 的並行系統。 如果您的設計真的會讓許多事情並行跑(一條真正的管線,而不是只有幾個背景任務的 App),main-actor 預設就會跟您作對。您要的是明確的 actor 與 nonisolated 程式碼,而 SE-0466 的預設是錯誤的起點。

不過對一個 App 來說,這個選擇很簡單:把兩個設定都打開,讓錯誤數崩落,並把剩下的那一小撮,當成一張精確標示出您的程式碼在哪裡離開主執行緒的地圖。那張地圖值得擁有。舊模型給您一千條警告,卻沒有地圖;新模型給您六個誠實的邊界,以及一個終於對應 App 實際運作方式的預設值。

最後提一句雜訊與訊號之分。當 Xcode 重建索引時——尤其是在剛重新產生一個專案之後——SourceKit 會在編輯器裡顯示跨檔索引錯誤(「Cannot find type X in scope」、「No such module」)。那些是索引產物,不是並行錯誤。如果 xcodebuild 回報 BUILD SUCCEEDED,並行模型就已滿足,編輯器只是還在追趕進度1。追逐索引幽靈,是在一場早已成功的遷移上浪費一整個下午最快的方法。



  1. 作者橫跨 941 系列 iOS App(Ki、Return、Get Bananas)的正式環境程式碼,全部都以 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActorSWIFT_APPROACHABLE_CONCURRENCY = YES 出貨。以下六個錯誤模式與解法,正是 Ki 1.0.0 完整的嚴格並行清理集合。 

  2. Swift Evolution,SE-0466: Control default actor isolation inference。讓一個模組預設為 @MainActor 隔離,於是 UI 與 App 目標都跑在 main actor 上,除非程式碼透過 @concurrent 或明確的 actor 選擇退出。 

  3. Swift Evolution,SE-0461: Run nonisolated async functions on the caller’s actor by default。一個 nonisolated 的 async 函式預設為 nonisolated(nonsending),在呼叫端的 actor 上執行,而不是跳到全域 executor,這便移除了隨那次跳轉而來的跨界 Sendable 要求。 

  4. Swift Evolution,SE-0461 引入 @concurrent 屬性,讓一個函式選擇在全域 executor(一條背景執行緒)上執行。@concurrentnonisolated(nonsending) 是 nonisolated async 函式的兩種對立隔離模式:一個函式要嘛在它的呼叫端所在之處執行,要嘛刻意離它而去。@concurrent 不能與 @MainActor 或自訂全域 actor 併用。 

  5. SWIFT_APPROACHABLE_CONCURRENCY 是 Xcode 的總開關建構設定,啟用 approachable-concurrency 的 upcoming features(包含 SE-0461 的行為),而 SWIFT_DEFAULT_ACTOR_ISOLATION 則選定模組的預設隔離。Xcode 26 的新專案會以 main-actor 預設同時啟用兩者。文件見 Donny Wals,「Exploring concurrency changes in Swift 6.2」,以及 Paul Hudson,「What’s new in Swift 6.2」,兩者皆與底層提案 SE-0461 與 SE-0466 交叉比對過。 

  6. Swift,Swift Concurrency Migration Guide,「Enabling Complete Concurrency Checking」與語言模式設定。在 Swift package 裡,預設隔離與 approachable-concurrency 特性是透過 swiftSettings 的 upcoming-feature 旗標來設定,而不是用 Xcode 建構設定。 

  7. Swift,Migration Guide: global actor isolation and nonisolated。一個 @MainActor 型別的方法會繼承 main-actor 隔離;nonisolated 讓某個方法選擇退出,這對不碰任何隔離狀態的純函式而言是正確的。 

  8. Apple Developer,MainActor.assumeIsolated(_:)。斷言目前的執行已經在 main actor 上,並同步執行該閉包而不進行 actor 跳轉。若該不變式不成立,這道斷言會在執行期觸發陷阱(trap),因此它只在呼叫端保證位於主執行緒之處才有效。 

相關文章

Apple Foundation Models:裝置端 LLM 框架完全解析

深入解析 Apple 的 Foundation Models 框架:LanguageModelSession、@Generable 引導式生成、工具呼叫、可用性判斷,以及何時該離開裝置端模型。

2 分鐘閱讀

MLX on Apple Silicon: When You Need Your Own Model, Not Apple's

Foundation Models gives you Apple's sealed on-device LLM. MLX runs your own: quantized open-weight models and LoRA fine-…

8 分鐘閱讀

兩個MCP伺服器讓Claude Code變成iOS建置系統

XcodeBuildMCP與Apple的Xcode MCP讓Claude Code能以結構化方式存取iOS建置、測試與除錯。設定方法、實際成果與誠實的經驗教訓。

3 分鐘閱讀