Swift 6 严格并发检查实战:从警告到零错误的完整迁移指南
本文是 Swift Concurrency 系列第五章,聚焦 Swift 6 严格并发检查(Strict Concurrency Checking)——从语言演进背景、核心机制、常见编译错误到实战修复策略,帮你平稳迁出"数据竞争地带"。
一、Swift 版本迭代简史
在深入 Swift 6 之前,先回顾一下 Swift 的演进脉络,了解"严格并发"为何成为这一版本的最大主题。
| 版本 | 年份 | 核心主题 |
|---|---|---|
| Swift 1.0 | 2014 | 发布,面向对象 + 函数式,ARC 内存管理 |
| Swift 2.0 | 2015 | 错误处理(do/try/catch),Protocol Extensions,开源 |
| Swift 3.0 | 2016 | 命名规范大重构(API Guidelines),Linux 支持 |
| Swift 4.0 | 2017 | Codable,String 改进,键路径(Key Path) |
| Swift 4.2 | 2018 | CaseIterable,Random API 统一 |
| Swift 5.0 | 2019 | ABI 稳定!Result 类型,字符串插值重构 |
| Swift 5.1 | 2019 | SwiftUI 发布,@propertyWrapper,Opaque Return Types |
| Swift 5.5 | 2021 | async/await,Actor,Sendable(并发奠基版) |
| Swift 5.7 | 2022 | if let 简写,Clock/Duration,Regex Literal |
| Swift 5.9 | 2023 | if/switch 表达式,Macros(宏系统),Parameter Packs |
| Swift 5.10 | 2024 | 完整并发检查预览,全局变量隔离警告 |
| Swift 6.0 | 2024 | 严格并发检查强制开启,数据竞争编译期保证 |
为什么 Swift 6 是里程碑?
Swift 5.5 引入了 async/await 和 Actor,但并发检查默认是宽松的——大量数据竞争隐患不会报错。Swift 6 将其升级为编译错误,Apple 的目标是:
"Swift 6 语言模式使并发安全成为编译期保证,而不仅仅是最佳实践。"
二、什么是严格并发检查?
2.1 三种检查模式
Swift 提供三个并发检查级别(在 Xcode Build Settings 或 Package.swift 中配置):
SWIFT_STRICT_CONCURRENCY = minimal // 默认(Swift 5 行为)
SWIFT_STRICT_CONCURRENCY = targeted // 中间档:对已标注的代码检查
SWIFT_STRICT_CONCURRENCY = complete // Swift 6 行为(最严格)
在 Package.swift 中:
.target(
name: "MyApp",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency") // Swift 5.9+
// 或直接设置语言版本
]
)
开启 Swift 6 模式(Xcode 16+):
// Swift Language Version = Swift 6
// 或 Package.swift
.target(
swiftLanguageVersions: [.version("6")]
)
2.2 核心保证:消灭数据竞争
严格并发检查的本质是禁止跨并发域的可变状态共享。编译器会强制检查:
- Sendable 合规性:跨 actor/任务边界传递的类型必须是
Sendable - Actor 隔离:访问 actor 的属性/方法必须在 actor 上下文中
- 全局状态隔离:全局/静态变量必须是不可变或被 actor 隔离
- 闭包捕获:
@Sendable闭包中不能捕获非 Sendable 类型
2.3 四大核心检查规则深度解析
规则一:Sendable 合规性 — 跨线程传数据必须有"通行证"
跨越 Task / actor 边界的每个对象,必须能证明"我被多个线程同时持有不会出问题"。
// ❌ 场景:把购物车传给后台 Task 做价格计算
class ShoppingCart {
var items: [String] = []
var totalPrice: Double = 0
}
let cart = ShoppingCart()
Task.detached {
// Swift 6 Error: Sending 'cart' risks causing data races
// 主线程可能同时修改 cart,这里也在读 → 数据竞争
print(cart.totalPrice)
}
// ✅ 修复 A:改成 struct(值类型,传递时自动拷贝)
struct ShoppingCart: Sendable {
var items: [String] = []
var totalPrice: Double = 0
}
// Task 持有的是一份拷贝,主线程修改原件互不影响
// ✅ 修复 B:改成 actor(同一份对象,但访问串行化)
actor ShoppingCart {
var items: [String] = []
var totalPrice: Double = 0
}
记忆口诀:Sendable 是"安全通行证",没通行证的对象不能过并发边界。
规则二:Actor 隔离 — 访问 actor 的属性/方法必须 await
actor 内部有一个隐式的"串行队列",任何外部访问都必须排队(await)。
actor UserCenter {
var nickname = "游客"
var isLoggedIn = false
func login(name: String) {
nickname = name
isLoggedIn = true
}
}
let center = UserCenter()
// ❌ 直接访问会报错(在 actor 外部)
// print(center.nickname) // Error: Expression is 'async' but is not marked with 'await'
// ✅ 必须 await,保证串行
Task {
await center.login(name: "Tom")
let name = await center.nickname
print(name) // Tom
}
// 两个任务并发操作 → actor 自动排队,结果确定
Task { await center.login(name: "Alice") }
Task { await center.login(name: "Bob") }
// 结果一定是 Alice 或 Bob,不会产生混乱的中间状态
记忆口诀:actor 像"单窗口银行柜台",不管多少人同时来,只服务一个,其余排队。
规则三:全局状态隔离 — 全局变量必须是 let 或被 actor 保护
全局 var 是"公共厕所"——多个线程随时可以进,必然出问题。
// ❌ Swift 6 Error: Global variable is not concurrency-safe
var currentUser: String = "guest"
var imageCache: [String: Data] = [:]
// ✅ 方案一:改成 let(不可变,没有竞争)
let appVersion: String = "1.0.0"
// ✅ 方案二:@MainActor 隔离(UI 相关全局状态)
@MainActor var currentUser: String = "guest"
// 只有主线程能读写,SwiftUI 天然安全
// ✅ 方案三:封装进 actor(后台缓存,推荐)
actor ImageCache {
static let shared = ImageCache()
private var storage: [String: Data] = [:]
func store(_ data: Data, key: String) { storage[key] = data }
func fetch(_ key: String) -> Data? { storage[key] }
}
// 使用:任何线程调用都安全
Task { await ImageCache.shared.store(data, key: "avatar") }
记忆口诀:全局变量要么"锁门"(
let),要么"派管理员"(actor),要么"指定专属厕所"(@MainActor)。
规则四:闭包捕获 — @Sendable 闭包不能捕获非 Sendable 类型
@Sendable 闭包需要跨线程传递,因此它"打包带走"的东西必须是安全的(Sendable)。
// ❌ ViewModel 是普通 class,不是 Sendable
class FeedViewModel {
var posts: [String] = []
func loadPosts() {
Task.detached {
// Swift 6 Error: Capture of 'self' with non-sendable type
// 'FeedViewModel' in a @Sendable closure
self.posts = ["post1", "post2"] // ❌
}
}
}
// ✅ 修复:加 @MainActor,整个类在主线程隔离
@MainActor
class FeedViewModel: ObservableObject {
@Published var posts: [String] = []
func loadPosts() {
Task {
// 继承 @MainActor,self 的访问安全
let data = await fetchFromServer() // 后台执行
posts = data // ✅ 回到主线程赋值
}
}
private func fetchFromServer() async -> [String] {
try? await Task.sleep(for: .seconds(1))
return ["post1", "post2"]
}
}
记忆口诀:
@Sendable闭包是"密封快递盒",装进去的东西必须是密封安全的,不能装"会漏水"的对象。
四大规则总结
线程 A ←──── 并发边界 ────→ 线程 B / Task / Actor
传递对象? → 规则一:必须是 Sendable(有通行证)
访问属性? → 规则二:必须在 actor 上下文中 await(排队)
全局变量? → 规则三:let / @MainActor / actor 三选一
闭包捕获? → 规则四:只能捕获 Sendable 类型(密封打包)
| 规则 | 本质问题 | 核心解法 |
|---|---|---|
| Sendable 合规 | class 被多线程共享引用 | 改 struct / actor / @unchecked |
| Actor 隔离 | 无保护访问 actor 内部 | 强制 await 串行访问 |
| 全局状态隔离 | 全局 var 任意线程可写 | let / @MainActor / actor |
| 闭包捕获 | 闭包跨线程持有非安全对象 | @MainActor class / Sendable 类型 |
四条规则解决的是同一件事:不让两个线程同时"摸"同一块可变内存。
三、常见编译错误与修复实战
3.1 错误一:非 Sendable 类型跨越并发边界
问题代码:
class UserSession { // ❌ 未标注 Sendable
var token: String = ""
var userId: Int = 0
}
let session = UserSession()
Task {
// ❌ Swift 6 Error: Sending 'session' risks causing data races
print(session.token)
}
修复方案 A:改为 struct(值语义,自动 Sendable)
struct UserSession: Sendable { // ✅ struct 默认满足 Sendable
var token: String = ""
var userId: Int = 0
}
修复方案 B:class 显式声明 + 加锁(不推荐新代码)
final class UserSession: @unchecked Sendable { // ⚠️ 需自己保证线程安全
private let lock = NSLock()
private var _token: String = ""
var token: String {
get { lock.withLock { _token } }
set { lock.withLock { _token = newValue } }
}
}
修复方案 C(推荐):改为 Actor
actor UserSession { // ✅ Actor 天然 Sendable + 自动隔离
var token: String = ""
var userId: Int = 0
func updateToken(_ newToken: String) {
token = newToken
}
}
// 使用
let session = UserSession()
Task {
await session.updateToken("abc123")
let t = await session.token
print(t)
}
3.2 错误二:全局可变变量
问题代码:
var globalCache: [String: Data] = [:] // ❌ Swift 6 Error: Global variable is not concurrency-safe
修复方案 A:用 @MainActor 隔离
@MainActor
var globalCache: [String: Data] = [:] // ✅ 只在主线程访问
// 访问时需在主线程上下文
Task { @MainActor in
globalCache["key"] = Data()
}
修复方案 B:用 nonisolated(unsafe) 标注(慎用)
nonisolated(unsafe) var globalCache: [String: Data] = [:]
// ⚠️ 告诉编译器"我保证安全,请不要检查"
修复方案 C:封装进 Actor
actor CacheManager {
static let shared = CacheManager()
private var storage: [String: Data] = [:]
func set(_ data: Data, forKey key: String) {
storage[key] = data
}
func get(forKey key: String) -> Data? {
storage[key]
}
}
3.3 错误三:@Sendable 闭包捕获
问题代码:
class ViewModel: ObservableObject {
var items: [String] = []
func loadData() {
Task {
// ❌ Capture of 'self' with non-sendable type 'ViewModel' in a @Sendable closure
self.items = ["a", "b", "c"]
}
}
}
修复方案:@MainActor + ObservableObject
@MainActor
class ViewModel: ObservableObject { // ✅ @MainActor 隔离整个类
@Published var items: [String] = []
func loadData() {
Task {
let result = await fetchRemote() // 可跨线程执行
items = result // ✅ 自动回到 MainActor
}
}
private func fetchRemote() async -> [String] {
try? await Task.sleep(for: .seconds(1))
return ["a", "b", "c"]
}
}
3.4 错误四:Protocol 中的并发要求
问题代码:
protocol DataLoader {
func load() async -> [String]
}
// ❌ 在某些上下文中 conformance 需要 Sendable
class NetworkLoader: DataLoader {
func load() async -> [String] { ... }
}
修复方案:
// 方案 A:Protocol 本身要求 Sendable
protocol DataLoader: Sendable {
func load() async -> [String]
}
// 方案 B:存在类型约束
protocol DataLoader {
func load() async -> [String]
}
// 使用时加约束
func use<T: DataLoader & Sendable>(_ loader: T) async { ... }
3.5 错误五:@preconcurrency 渐进迁移
当你依赖的旧模块还没有迁移到 Swift 6 时,可以用 @preconcurrency 临时抑制警告:
// 导入尚未迁移的模块
@preconcurrency import SomeOldFramework
// 或在 Protocol Conformance 上
class MyView: @preconcurrency UIViewController {
// ...
}
四、nonisolated 关键字详解
nonisolated 用于在 Actor 中声明某个方法不需要 actor 隔离,可以从任意上下文调用:
actor DataStore {
var data: [String] = []
// ❌ 必须 await 调用
func getData() -> [String] { data }
// ✅ nonisolated:无需 await,可同步调用
nonisolated var description: String {
"DataStore instance" // 只能访问不可变 or nonisolated 的属性
}
// nonisolated 的方法不能访问 actor 隔离的属性
nonisolated func staticInfo() -> String {
"Version 1.0" // ✅ 只访问常量/外部数据
}
}
let store = DataStore()
print(store.description) // ✅ 无需 await
print(await store.getData()) // 需要 await
五、@MainActor 传播规则
@MainActor 会沿着调用链传播,理解这点对排错非常重要:
@MainActor
class HomeViewController: UIViewController {
// 所有方法自动在主线程
func updateUI() {
label.text = "Updated" // ✅
}
// 如需后台工作,用 nonisolated 或 Task.detached
nonisolated func heavyWork() async -> String {
// 在非主线程执行
return "result"
}
func loadAndUpdate() {
Task {
let result = await heavyWork() // 后台
updateUI() // 自动回主线程(因为在 @MainActor 上下文)
}
}
}
传播场景总结:
| 场景 | 结论 |
|---|---|
@MainActor class 内部的方法 | 自动继承 @MainActor |
@MainActor 函数内 await 后 | 仍在主线程 |
Task { } 在 @MainActor 上下文创建 | 继承主线程隔离 |
Task.detached { } | 不继承,独立调度 |
nonisolated 方法 | 脱离 actor 隔离 |
六、渐进迁移策略
Apple 官方推荐的迁移路径(不用一次跳到 Swift 6):
第一步:开启 targeted 检查
SWIFT_STRICT_CONCURRENCY = targeted
只检查已有 async/await 和 Actor 标注的代码,低风险。
第二步:修复所有警告
重点关注:
Sendable警告nonisolated相关警告@MainActor遗漏
第三步:升级到 complete 检查
SWIFT_STRICT_CONCURRENCY = complete
这时会暴露更多隐藏问题,逐一解决。
第四步:切换到 Swift 6 语言版本
SWIFT_VERSION = 6
所有并发检查变为编译错误,不再是警告。
七、实战 Checklist
开启 Swift 6 前,用这份清单扫描你的代码:
- 全局/静态 var → 改为
let、nonisolated(unsafe)、或@MainActor - class 跨线程传递 → 加
Sendable、改struct、或改actor - ObservableObject 子类 → 加
@MainActor - Delegate 回调 → 检查是否在主线程,用
@MainActor声明 Delegate protocol - 第三方库 → 用
@preconcurrency import过渡 - 闭包捕获 → 检查
[weak self]+@Sendable约束
八、小结
Swift 6 的严格并发检查不是为了让你"多写几个关键字",而是把数据竞争这类运行时 Bug 提前消灭在编译阶段。迁移的过程可能短暂痛苦,但带来的收益是:
- 🔒 零数据竞争的类型系统保证
- 🚀 更好的并发性能(编译器可以更大胆优化)
- 🧠 代码意图更清晰(谁在哪个线程做什么,一目了然)
迁移建议:不要急于一次切换到 Swift 6 模式,按
minimal → targeted → complete三步走,每步修完警告再推进。