Swift 6 并发深渊:@unchecked Sendable 与“隐式 MainActor”如何合谋杀死你的 App

故事开场:一行"看似无害"的全局变量

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 盒子

  1. 盒子实现(省略锁,聚焦并发语义)
swift 复制代码
/// 线程安全盒子,靠外部加锁保证互斥
final class Box<T>: @unchecked Sendable {   // ① 手动宣布"我线程安全"
    var value: T
    init(_ value: T) { self.value = value }
}
  1. 把 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的断言从而崩溃。

语言级细节深挖

  1. 函数类型不能显式写 nonisolated

    你无法写成 let f: nonisolated (String) -> Void,所以编译器只能推断。

  2. "函数作为存储属性"是并发检查的空档

    如果闭包被塞进 @unchecked Sendable 对象的 var 里,编译器跳过对这条闭包的隔离检查;但运行时的"队列断言"依旧生效。

  3. @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

  1. 任何"全局可变状态"在 Swift 6 都必须给出正式同步策略------靠文档" pinky swear "已无效。
  2. @unchecked Sendable 只是编译期消音器,运行时依旧会队列断言。
  3. 闭包隐式继承创建点的 actor 隔离;若跨队列使用,务必声明为 @Sendable
  4. 如果闭包要塞进泛型容器,给容器加上 ~Copyable + consuming + sending,能让编译器提前抓到"跨隔离区传值"问题。
  5. 日志、配置、缓存等"启动时一次写入、运行期多读"场景,最省心的办法:
    • 类型别名强制 @Sendable
    • 内部用 NSRecursiveLock / pthread_mutex 做互斥
    • 若最低支持 iOS 18,直接换 Mutex + ~Copyable

扩展场景:不止日志

  • 全局网络拦截器 URLProtocolstatic var handlers
  • SwiftUI 预览用的 InMemoryCache -游戏引擎的"单例 ResourceRegistry"

凡是你曾经用 static var + "文档警告"搞定的地方,迁 Swift 6 时都可以用本文套路:

  1. 先加 @Sendable 把"隐式隔离"炸出来;
  2. 再决定用 actor 还是 Mutex 还是 Box<T: ~Copyable>
  3. 最后写单元测试:多线程并发写 + 主线程读,跑 TSan / XCTest 压力测试,确保零崩溃、零数据竞争。
相关推荐
HarderCoder4 小时前
告别 UIKit 生命周期:SwiftUI 视图一生全解析——从 init 到 deinit 的“隐秘角落”
swiftui·swift
HarderCoder5 小时前
Swift 中的基本运算符:从加减乘除到逻辑与或非
ios·swift
HarderCoder5 小时前
Swift 中“特性开关”实战笔记——用编译条件+EnvironmentValues优雅管理Debug/TestFlight/AppStore三环境
ios·swift
HarderCoder5 小时前
Swift 并发任务中到底该不该用 `[weak self]`?—— 从原理到实战一次讲透
ios·swift
大熊猫侯佩10 小时前
天网代码反击战:Swift 三元运算符的 “一行破局” 指南
swiftui·swift·apple
大熊猫侯佩1 天前
在肖申克监狱玩转 iOS 26:安迪的 Liquid Glass 复仇计划
ios·swiftui·swift
大熊猫侯佩1 天前
用最简单的方式让 SwiftUI 画一颗爱你的小红心
swiftui·swift·apple