Swift 6.2 并发实战:默认 MainActor,按需逃逸
Swift 6.0 把数据竞争变成了编译错误,结果让所有人为此付出了一年的代价。严格并发检查器把普通的 UI 代码变成了一堵墙——满屏的 Sendable 违规,还有”无法在 nonisolated 上下文中引用主线程隔离的属性”这类报错。诊断本身没错(那些代码确实可能发生竞争),但报错的数量淹没了真正的信号,许多团队要么停留在 Swift 5 模式,要么到处撒 @MainActor,直到错误终于安静下来。
Swift 6.2 改的不是规则,而是默认值。数据竞争安全的保证没有变;变的是编译器的起点。采用新的默认设置后,那堵墙大半就消失了,因为编译器现在直接假定你的应用本来就成立的事实:绝大多数代码运行在主线程上,而你只在指定的、有名字的地方刻意离开它。这正是我在 941 系列应用中采用的模型1。下面讲讲它的工作原理,以及开启之后仍会咬你一口的六类错误。
要点速览
- SE-0466 让你把整个模块默认设为
@MainActor。 设置SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor,从此不必再给每个视图、模型和视图模型逐一加注解2。 - SE-0461 让 nonisolated 的
async函数默认运行在调用方的 actor 上(nonisolated(nonsending)),于是调用异步代码不再强制发生一次 actor 跳转,随之而来的”跨边界Sendable“报错也一并消失3。 @concurrent是逃逸出口。 给函数标上@concurrent,就能把繁重的 CPU 工作(解码、图像处理)刻意而显式地推到后台线程上4。- Xcode 26 为新项目默认开启这两项。总开关是
SWIFT_APPROACHABLE_CONCURRENCY = YES5。 - 这个模型把负担反转了过来:你不再需要证明每一行代码都能安全地脱离主线程运行,而是把一切都留在主线程上,只证明你离开它的那少数几处。
- 切换之后仍有六类具体错误存活下来。一旦看清模式,这六类都只需一行修复。
这场反转,以及它为何重要
旧模型(Swift 6.0):代码在你显式隔离之前都是 nonisolated 的。每个触及 UI 状态的类型都需要 @MainActor,每个跨隔离边界的异步调用都需要 Sendable 一致性,而编译器会把每一处缺口都标记出来。对于一个 95% 的代码本就运行在主线程上的应用来说,你把时间都花在了给这 95% 加注解上,只为描述一个从未存在疑问的事实。
新模型(Swift 6.2):代码默认在主线程上,直到你离开它。SE-0466 让你把主线程隔离声明为模块默认值,于是一个视图、它的模型和它的辅助类型全都是 @MainActor,一个注解都不用写2。SE-0461 接着免去了第二项税负:nonisolated 的 async 函数现在运行在调用它的那个 actor 上,而不再跳到全局执行器,因此 await 它不会把你拖过隔离边界,也不会要求作用域内的一切都满足 Sendable3。
这个心智模型恰好契合 UI 应用真实的运行方式。”默认主线程”不是一种妥协;对一个状态即视图的应用而言,这就是事实本身。并发于是成了你主动伸手去取的例外——有名字、被约束——而不再是你需要在每一行上严防死守的弥漫性环境。编译器的任务从”证明这段代码可以安全地并发运行”翻转为”既然你说它要并发运行,那就证明它是安全的”,而后一个问题需要被追问的地方少得多。
如何开启
两个构建设置,二者都在 Xcode 26 的新项目默认值之中,也都值得在既有项目上显式设定5:
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
SWIFT_APPROACHABLE_CONCURRENCY = YES
第一个就是 SE-0466:模块默认采用主线程隔离。第二个是总开关,启用整套”易上手并发”特性,其中包括 SE-0461 的”跟随调用方运行”行为。在 Swift 包中,你通过 swiftSettings 配合相应的 upcoming-feature 标志来设定同样的默认值,而不是用 Xcode 的构建设置6。
在既有项目上把两项都打开,错误数量会陡然下降,因为检查器此前标记的大多是它无法预先假定为主线程的主线程代码。剩下的是一份简短的真正边界情形清单。这些值得逐一记住名字,因为每一处都是你的代码确实离开主线程的地方,而修复之道就是把这件事说清楚、说精确。
切换后存活下来的六类错误
以下是在 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 之下仍会出现的严格并发错误,取自把 941 系列应用迁移到该模型的实践1。每一类都是真实的边界,而非误报,因此修复手段是一个精确的注解,而不是一句压制。
1. Sendable 类型上的纯函数。 Sendable 枚举上的纯方法(一个 URL 构造器、一个格式化器)在模块默认下继承了主线程隔离,于是从 nonisolated 上下文调用时就会报错:“在同步的 nonisolated 上下文中调用主线程隔离的实例方法。” 该方法不触及任何状态,所以把它隔离到主线程是错的。给它标上 nonisolated7:
nonisolated func searchURL(for query: String) -> URL? { ... }
2. 默认参数中的单例静态成员。 static let shared = Foo() 在默认下是主线程隔离的,但默认参数的值是在调用方的上下文中求值的,而调用方往往是 nonisolated 的:“无法在 nonisolated 上下文中引用主线程隔离的静态属性 ‘shared’。” 把该静态成员标为 nonisolated。如果类型是 Sendable(或者你自己守护其状态从而是 @unchecked Sendable),就不需要任何不安全限定符:
final class KeychainProxySecretStore: @unchecked Sendable {
nonisolated static let shared = KeychainProxySecretStore()
}
3. 不可变的基本类型常量。 同样的默认参数问题也会击中一个普通常量:一个从 nonisolated 默认实参中引用的 static let defaultInterval。修复方式完全一样,而这个常量本就可以安全共享:
nonisolated static let defaultInterval: TimeInterval = 15 * 60
4. 读取被捕获的 self 的 Task 体。 外层闭包捕获了 [weak self];在内部,一个 Task { @MainActor in self?.foo() } 读取了那个被捕获的可选值:“在并发执行的代码中引用了被捕获的变量 ‘self’。” 这个 Task 从外围作用域并发地读取了一个 var 绑定,竞争就出在这里。在 Task 边界处重新捕获 self,让 Task 拥有一个不可变的绑定:
NotificationCenter.default.addObserver(...) { [weak self] _ in
Task { @MainActor [weak self] in
self?.value = next
}
}
5. 读取主线程状态的 KVO 回调。 webView.observe(\.canGoBack) { wv, _ in ... } 回调是 @Sendable 的,因而是 nonisolated 的,但 WKWebView.canGoBack 是主线程隔离的:“无法在 Sendable 闭包中引用主线程隔离的属性 ‘canGoBack’。” 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. 本不该在主线程上的繁重工作。 这一类检查器不会标记,而它恰恰是你最该自己抓住的。在主线程默认之下,一个同步的 CPU 密集型方法(JSON 解码一份庞大的载荷、缩放一张图像)会运行在主线程上,把你的 UI 卡住。默认会让你留在主线程上;@concurrent 则是你主动离开的方式4:
@concurrent
func decodeLargePayload(_ data: Data) async throws -> Report {
try JSONDecoder().decode(Report.self, from: data)
}
@concurrent 把函数卸载到全局执行器上,并且按设计与 @MainActor、自定义全局 actor 以及 nonisolated(nonsending) 互斥:一个函数要么运行在它的调用方所在之处,要么刻意地远离它,绝不含糊4。新模型所要求的全部纪律,都体现在这一个模式里。凡是触及 UI 的一切都留在主线程上,只在确有需要、可度量地需要一个后台线程的工作上,才去动用 @concurrent。
当默认设置并不适合你
“默认主线程”适合应用:SwiftUI 和 UIKit 代码绝大多数都在主线程上,默认值与现实相符。但在两种情形下它就不那么合身了,对此装作没看见只会浪费你的时间。
- 没有 UI 的库或框架目标。 一个网络层、一个解析器、或一个数据引擎没有理由默认运行在主线程上,那样做反而会迫使你几乎在所有东西上都加
@concurrent或nonisolated。对这些目标,让SWIFT_DEFAULT_ACTOR_ISOLATION保持不设置,沿用旧的方式刻意地做隔离。 - 以 actor 为主的并发系统。 如果你的设计确实有许多东西在并行运行(一条真正的流水线,而不是一个只带几个后台任务的应用),主线程默认就会跟你作对。你需要的是显式的 actor 和 nonisolated 的代码,而 SE-0466 的默认值是个错误的起点。
不过对一个应用而言,这个取舍很容易:把两项设置都打开,让错误数量崩塌下去,再把残留的那一小撮当作一张地图,标明你的代码究竟在哪里离开了主线程。这张地图值得拥有。旧模型给你一千条警告却没有地图;新模型给你六处诚实的边界,外加一个终于与应用实际运行方式相符的默认值。
最后说一句噪声与信号之别。当 Xcode 重建索引时——尤其是在刚刚重新生成一个项目之后——SourceKit 会在编辑器里显示跨文件的索引错误(”在作用域中找不到类型 X”、”没有这样的模块”)。那些是索引产物,不是并发错误。只要 xcodebuild 报告 BUILD SUCCEEDED,并发模型就已经满足,编辑器只是还在追赶1。追逐索引幽灵,是在一次本已成功的迁移上浪费一个下午的最快方式。
-
作者在 941 系列 iOS 应用(Ki、Return、Get Bananas)中的生产代码,全部以
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor和SWIFT_APPROACHABLE_CONCURRENCY = YES发布。下文的六类错误模式及其修复,构成了 Ki 1.0.0 完整的严格并发清理集。 ↩↩↩ -
Swift Evolution,SE-0466: Control default actor isolation inference。让一个模块默认采用
@MainActor隔离,于是 UI 和应用目标都运行在主线程上,除非代码通过@concurrent或显式 actor 选择退出。 ↩↩ -
Swift Evolution,SE-0461: Run nonisolated async functions on the caller’s actor by default。nonisolated 的
async函数默认采用nonisolated(nonsending),运行在调用方的 actor 上而非跳到全局执行器,从而免去了随跳转而来的跨边界Sendable要求。 ↩↩ -
Swift Evolution,SE-0461 引入了
@concurrent属性,用于让一个函数选择运行在全局执行器(一个后台线程)上。@concurrent与nonisolated(nonsending)是 nonisolated 异步函数两种对立的隔离模式:一个函数要么运行在它的调用方所在之处,要么刻意地远离它。@concurrent不能与@MainActor或自定义全局 actor 组合使用。 ↩↩↩ -
SWIFT_APPROACHABLE_CONCURRENCY是启用”易上手并发”系列 upcoming 特性(包括 SE-0461 行为)的总开关式 Xcode 构建设置,而SWIFT_DEFAULT_ACTOR_ISOLATION用于选择模块的默认隔离。Xcode 26 的新项目以主线程默认开启二者。文档见 Donny Wals 的 “Exploring concurrency changes in Swift 6.2” 与 Paul Hudson 的 “What’s new in Swift 6.2”,二者均与底层提案 SE-0461 和 SE-0466 交叉印证。 ↩↩ -
Swift,Swift Concurrency Migration Guide,”Enabling Complete Concurrency Checking” 与语言模式配置。在 Swift 包中,默认隔离与”易上手并发”特性是通过
swiftSettings的 upcoming-feature 标志来设定的,而不是 Xcode 的构建设置。 ↩ -
Swift,Migration Guide: global actor isolation and
nonisolated。一个@MainActor类型的方法继承主线程隔离;nonisolated让某个方法选择退出,这对于不触及任何隔离状态的纯函数来说是正确的。 ↩ -
Apple Developer,
MainActor.assumeIsolated(_:)。断言当前执行已经处在主线程上,并同步运行闭包而不发生 actor 跳转。若该不变量不成立,断言会在运行时触发陷阱,因此它只在调用方确保位于主线程时才有效。 ↩