故事开场:一行"看似无害"的全局变量
swift
// 日志库对外暴露的 API
public enum Logging {
/// 宿主 App 可把自己喜欢的日志输出闭包塞进来
public static var sink: (String) -> Void = { print($0) }
}
Swift 6 编译器立刻翻脸:
Static property 'sink' is not concurrency-safe because it is nonisolated global shared mutable state
-
知识点 1
Swift 6 的"完整数据竞争检查"默认把所有全局可变状态当成"非隔离共享可变状态",必须给出明确同步策略,否则直接报错。
当然,如果开启了default isolation为main actor的话,是可以编译通过的
最容易想到的 3 条"捷径"
方案 | 改动量 | 副作用 |
---|---|---|
① 直接加 @MainActor |
一行代码 | 所有日志调用被迫异步切主线程,可能掉帧 |
② 包一层 private actor |
中等 | 旧代码要改成 Task { await ... } |
③ 自己写个"线程安全盒子" | 看起来最小 | 编译器不报错,但可能运行时崩溃------本文主角 |
走进深渊:@unchecked Sendable 盒子
- 盒子实现(省略锁,聚焦并发语义)
swift
/// 线程安全盒子,靠外部加锁保证互斥
final class Box<T>: @unchecked Sendable { // ① 手动宣布"我线程安全"
var value: T
init(_ value: T) { self.value = value }
}
- 把 sink 改成计算属性
swift
enum Logging {
typealias LoggingSink = (String) -> Void
static var sink: LoggingSink {
get { _sink.value }
set { _sink.value = newValue }
}
private static let _sink = Box<LoggingSink>({ print($0) })
}
-
知识点 2
@unchecked Sendable
会关闭编译期对 Box 内部成员的并发检查,但不会关闭运行时的dispatch_assert_queue
断言。→ 编译期"静音",运行时"开枪"。
崩溃现场:iOS 18 真机调试
swift
@main
struct MyApp: App {
init() {
// ④ 闭包在 @MainActor 上下文创建
Logging.sink = {
MainActor.assertIsolated("必须在MainActor")
print($0)
}
}
}
struct ContentView: View {
func userPressedTheButton() {
DispatchQueue.global().async {
Logging.sink("hello") // <--- ⑤ 全局队列调用
}
}
}
lldb 回溯关键帧:
_dispatch_assert_queue_fail
swift_task_isCurrentExecutorImpl
-
知识点 3
Swift 6 的闭包会隐式继承创建点的 actor 隔离。
在
MyApp.init()
里写的{ print($0) }
被偷偷贴上@MainActor
,而Logging.sink
本身又是非隔离的。当全局队列异步调用时,运行时断言"当前队列必须是主队列",于是触发MainActor的断言从而崩溃。
语言级细节深挖
-
函数类型不能显式写
nonisolated
你无法写成
let f: nonisolated (String) -> Void
,所以编译器只能推断。 -
"函数作为存储属性"是并发检查的空档
如果闭包被塞进
@unchecked Sendable
对象的var
里,编译器跳过对这条闭包的隔离检查;但运行时的"队列断言"依旧生效。 -
@unchecked Sendable ≠ 运行时免死金牌
它只关掉编译期诊断,不关
dispatch_assert_queue
/swift_task_isCurrentExecutorImpl
。
最小修复:把闭包声明成 @Sendable
swift
typealias LoggingSink = @Sendable (String) -> Void // ① 加注解
// MainActor.assertIsolated("必须在MainActor") 移除MainActor的断言
结果:
- 编译器立即阻止你把非 Sendable 闭包塞进来;
- 运行时不再做"错误队列"断言,崩溃消失。
进阶:模仿 Swift 6 的 Mutex(iOS 18+)
swift
// 官方 Mutex 签名(删减)
struct Mutex<Value: ~Copyable>: @unchecked Sendable {
init(_ v: consuming sending Value)
func withLock<R: ~Copyable>(
_ body: (inout sending Value) -> sending R
) -> sending R
}
-
知识点 4
~Copyable
+consuming
+sending
能在编译期把"跨隔离区传递非 Sendable 值"这种隐患提前暴露出来,而传统class Box<T>
做不到。
完整自实现"低配版 Mutex"(兼容 iOS 15)
swift
/// 兼容旧系统的手写 Mutex,利用 ~Copyable 提前发现风险
final class Box<T: ~Copyable>: @unchecked Sendable {
private var _value: T
private let lock = NSRecursiveLock()
init(_ value: consuming T) {
self._value = value
}
func withLock<R: ~Copyable>(
_ body: (inout sending T) -> sending R
) -> sending R {
lock.lock(); defer { lock.unlock() }
return body(&_value)
}
}
使用:
swift
let shared = Box<@Sendable (String) -> Void>({ print($0) })
DispatchQueue.global().async {
shared.withLock { $0("safe!") } // ⑥ 编译期+运行时均安全
}
总结 & 实战 checklist
- 任何"全局可变状态"在 Swift 6 都必须给出正式同步策略------靠文档" pinky swear "已无效。
@unchecked Sendable
只是编译期消音器,运行时依旧会队列断言。- 闭包隐式继承创建点的 actor 隔离;若跨队列使用,务必声明为
@Sendable
。 - 如果闭包要塞进泛型容器,给容器加上
~Copyable
+consuming
+sending
,能让编译器提前抓到"跨隔离区传值"问题。 - 日志、配置、缓存等"启动时一次写入、运行期多读"场景,最省心的办法:
- 类型别名强制
@Sendable
- 内部用
NSRecursiveLock
/pthread_mutex
做互斥 - 若最低支持 iOS 18,直接换
Mutex
+~Copyable
- 类型别名强制
扩展场景:不止日志
- 全局网络拦截器
URLProtocol
的static var handlers
- SwiftUI 预览用的
InMemoryCache
-游戏引擎的"单例 ResourceRegistry"
凡是你曾经用 static var
+ "文档警告"搞定的地方,迁 Swift 6 时都可以用本文套路:
- 先加
@Sendable
把"隐式隔离"炸出来; - 再决定用
actor
还是Mutex
还是Box<T: ~Copyable>
; - 最后写单元测试:多线程并发写 + 主线程读,跑
TSan
/XCTest
压力测试,确保零崩溃、零数据竞争。