为什么单例在 Swift 6 突然"不香了"
旧认知 | Swift 6 新现实 |
---|---|
static let shared = XXX() 随手一写 |
编译器直接甩出两行血红诊断:1. 非隔离的全局可变状态(nonisolated global shared mutable state )2. 非 Sendable 类型被共享(non-Sendable type may have shared mutable state ) |
只要不加锁就能跑 | 编译期即可检测数据竞争(data race) |
单例 = 全局可变垃圾桶 | 必须证明"任意并发上下文都安全" |
一句话:Swift 6 不禁止单例,但要求你"自证清白"。
两个核心错误 & 一站式修复清单
错误 1:static var / let 本身"非并发安全"
触发条件 | 编译器原文 |
---|---|
任何 static var |
Static property 'xxx' is not concurrency-safe because it is nonisolated global shared mutable state |
✅ 方案 A:把整个类型扔进 @MainActor
swift
// 适合 UI 密切类:整盘菜端给主线程
@MainActor
class GamePiece {
static var power = 100 // 安全,但任何读写都必须在主线程
func attack() { GamePiece.power += 10 } // 隐式 @MainActor
}
✅ 方案 B:只隔离静态变量
swift
// 保留其他方法可在后台并发
class GamePiece {
@MainActor
static var power = 100 // 仅 power 受主线程保护
// 非隔离方法,可在任意队列执行
nonisolated func description() -> String { "I am a piece" }
}
⚠️ 方案 C:nonisolated(unsafe) ------ 手动关保险箱
swift
class GamePiece {
// 编译器:我不管了,你自己保证单线程访问
nonisolated(unsafe) static var power = 100
}
使用守则
- 仅当你100% 确定访问序列不会并发(例如启动阶段单任务初始化)。
- 在 PR 评论里写下"TODO: Swift 6 临时逃生舱,后续改 actor"。
- 每版本复查,争取早日删除。
错误 2:实例"不是 Sendable"却被全局共享
触发条件 | 编译器原文 |
---|---|
static let shared = SomeClass() 且 SomeClass 没证明是 Sendable |
Static property 'shared' is not concurrency-safe because non-'Sendable' type 'SomeClass' may have shared mutable state |
✅ 方案 A:让类" immutable + final + Sendable"
swift
// 最简单:纯函数式、无状态
final class AuthProvider: Sendable {
static let shared = AuthProvider()
private init() {}
// 只允许读计算属性/方法,无 stored var
func token() -> String? { UserDefaults.standard.string(forKey: "token") }
}
❌ 踩坑:想偷偷加 mutable 存储属性
swift
final class AuthProvider: Sendable {
static let shared = AuthProvider()
private var currentToken: String? // 🚨 Stored property 'currentToken' of 'Sendable'-conforming class is mutable
private init() {}
}
结论:Sendable class 里一根毛都不能 mutable,否则编译器直接拍桌子。
✅ 方案 B:改 actor ------ 官方推荐终极形态
swift
actor AuthProvider {
static let shared = AuthProvider() // actor 隐式 Sendable,编译器秒过
private var currentToken: String?
func update(token: String) {
currentToken = token
}
func token() -> String? {
return currentToken
}
}
// 使用方
Task {
await AuthProvider.shared.update(token: "abc")
let t = await AuthProvider.shared.token()
}
优点
- 内部自由读写可变状态,外部通过
await
串行排队,零数据竞争。 - 无需自己加锁、无需 dispatch queue。
缺点
- 全 async/await 化,老工程需要一层适配。
✅ 方案 C:@unchecked Sendable ------ 老代码逃生舱 2 号
swift
// 你已经用 GCD/锁保证线程安全,只是不想大改
final class AuthProvider: @unchecked Sendable {
static let shared = AuthProvider()
private var currentToken: String?
private let lock = NSLock()
func update(token: String) {
lock.lock()
currentToken = token
lock.unlock()
}
}
守则
- 写成文档:"本类型已手动保证线程安全,原因如下......"
- 单元测试必加多线程压力测试,防止未来改代码时破功。
- 计划 一两个版本内 迁移到 actor。
完整决策树
csharp
遇到 static 报错
├─ 是 var ?
│ ├─ 必须可变 → 选隔离策略
│ │ ├─ 可放主线程 → @MainActor
│ │ └─ 不能放主线程 → 改 actor 或 nonisolated(unsafe)
│ └─ 其实可 let → 直接改 let
└─ 是 let 但实例非 Sendable ?
├─ 实例无状态 → final class: Sendable
├─ 实例有状态但想简单 → actor
└─ 有状态且暂时不改 → @unchecked Sendable + 手动锁
总结与实战感受
-
Swift 6 把"并发安全"从君子协定变成编译器红线。
以前"跑不死就行"的代码,现在不证明安全就编译不过------这是好事,越早暴露问题,线上越少崩溃。
-
actor 是苹果官方给出的"单例+可变状态"黄金搭档。
只要你的业务层已经拥抱 async/await,把单例改成 actor 通常改动量比加锁小,且可维护性更高。
-
@MainActor 是把双刃剑
- 适合真正 UI 绑定类(如
ThemeManager
、NavigationRouter
)。 - 别因为"编译不过"就一股脑扔主线程,否则会把主线程拖成单线程瓶颈。
- 适合真正 UI 绑定类(如
-
nonisolated(unsafe) 与 @unchecked Sendable 只能是"技术债",
务必记录 TODO、写压力测试、排进迭代计划,否则"临时"会变"永久",日后数据竞争哭都来不及。
-
单元测试必须多线程跑:
即使编译器绿了,也写个
1000_task_simultaneously_read_write
测试,用XCTAssert
锁死预期行为------这是最后一道保险。
扩展场景:更复杂的单例形态怎么玩?
-
需要后台刷新的缓存池
- actor + Task { await backgroundRefresh() }
- 通过
nonisolated
暴露只读接口,让外部无需await
即可读取快照。
-
多隔离域协作的"混合单例"
- 将"读"放任意线程(
nonisolated
计算属性)。 - 将"写"集中到自定义全局 actor(
@globalActor
),避免主线程被占用。
- 将"读"放任意线程(
-
依赖注入容器(DI Container)
- 用
actor DIContainer
管理所有服务注册表,注册/解析全程线程安全。 - 配合
@propertyWrapper
实现Injected
,在 SwiftUI/Redux 架构里无痛取单例。
- 用
-
与 Objective-C 共舞的老库
- 在
.mm
文件用std::mutex
保证 C++ 可变数据安全,然后 Swift 侧包一层@unchecked Sendable
壳。 - 记得把锁粒度降到"临界区"最小,防止性能回退。
- 在
一句忠告
单例不是原罪,全局可变状态才是。Swift 6 只是强迫我们"把状态关进笼子里"。
选对隔离策略、写好测试、及时还技术债,你的单例依旧可以优雅地活到下一个大版本。祝大家编译全绿,崩溃为零!