Swift 6 并发编程:深入理解 `@unchecked Sendable` 的合法使用与陷阱

前言:Sendable 是什么?为什么要有 @unchecked

Swift 5.5 引入结构化并发(Structured Concurrency)后,Sendable 协议成为编译期防止数据竞争的第一道闸门。

只有被标记为 Sendable 的类型,才能安全地跨并发域传递(例如从后台任务送回主线程)。

但现实往往不完美:

  • 类内部用 DispatchQueueNSLock 做了手工同步;
  • 包含系统遗留类型,声明上不是 Sendable,却文档保证线程安全;
  • 不可变值类型,语义上只读,但成员偏偏没标 Sendable

于是 Swift 给出逃生舱口:

@unchecked Sendable ------ "我知道自己在干嘛,出了事我负责"。

官方定义与历史回顾

来源:SE-0302

时期 名称 说明
草案阶段 ConcurrentValue 后改名 Sendable
正式版 Sendable 编译期自动推导
逃生舱 @unchecked Sendable 手工宣称线程安全,无编译期检查

什么时候该用 @unchecked Sendable

场景 示例 是否推荐
✅ 手工同步的类 DispatchQueue.sync / NSLock 首选
✅ 只读语义 结构体/类所有属性 let 且成员也是 Sendable 安全
✅ 系统类型已知安全 包装 NSMutableDataCGContext 可用
❌ 懒得加锁 没有任何同步手段 找死
❌ 可变字典/数组 直接暴露 _storage:[String:Any] 找死+1

代码实战:正确姿势 vs 错误姿势

① 线程安全计数器(正确)

swift 复制代码
final class Counter: @unchecked Sendable {
    // 有可变变量,必须加上@unchecked
    private var value: Int = 0
    private let queue = DispatchQueue(label: "counter.sync")

    func increment() {
        queue.sync { value += 1 }
    }

    func get() -> Int {
        queue.sync { value }
    }
}

要点

  • 所有读写同一队列
  • 类标记为 final,禁止子类化(防止子类引入非同步状态)

② 只读快照(正确)

swift 复制代码
struct UserSnapshot: @unchecked Sendable {
    let id: Int
    let name: String
    // 所有成员都是 let & Sendable,语义只读。甚至不用写Sendable
}

③ "偷懒"包装可变数组(错误)

swift 复制代码
final class BadCache: @unchecked Sendable {   // ❌
    var list: [String] = []                   // 无任何保护
}

编译器不会报错,但并发写入立刻数据竞争 → 崩溃或幽灵 Bug。

与 Actor 的权衡:我到底选哪个?

维度 Actor @unchecked Sendable
编译期检查 ✅ 完全隔离 ❌ 零检查
运行时开销 actor 跳一次 锁/队列等待
代码侵入 必须 async 可保持同步
适用场景 新代码、逻辑清晰 遗留代码、性能临界

一句话:"能 Actor 就 Actor,不能 Actor 再 unchecked。"

单元测试:如何证明你真的"线程安全"

swift 复制代码
import Testing
@testable import MyCmdExec

struct MyCmdTest {

    @Test("testConcurrentIncrement")
    func testConcurrentIncrement() async {
        let counter = Counter()
        
        // 使用 TaskGroup 并发执行 10,000 次递增操作
        await withTaskGroup(of: Void.self) { group in
            for _ in 0..<10_000 {
                group.addTask { counter.increment() }
            }
        }
        
        // 断言最终计数值为 10,000
        #expect(counter.get() == 10_000, "并发递增后计数值不正确")
    }

}

Tips

  • 用 Thread Sanitizer 跑一次(Scheme → Diagnostics → Thread Sanitizer)
  • 压力测试 ≥1 万次并发才算及格
  • 测试通过后在注释里写 "已通过 TSan 1w 并发测试",方便后人

文档模板:写给未来的自己/同事

swift 复制代码
/// 线程安全缓存,内部使用 `NSLock` 保护 `_storage`。
/// - 已通过 Thread Sanitizer 10_000 并发读写测试
/// - 所有写操作均先拷贝副本再替换,保证无数据竞争
final class ThreadSafeCache<Key: Hashable & Sendable, Value>: @unchecked Sendable {
    private var _storage: [Key: Value] = [:]
    private let lock = NSLock()

    func insert(_ value: Value, for key: Key) {
        lock.withLock { _storage[key] = value }
    }

    func value(for key: Key) -> Value? {
        lock.withLock { _storage[key] }
    }
}

总结

  1. @unchecked Sendable = 编译器放手,人权归你
  2. 必须加锁/队列/不可变,否则就是定时炸弹
  3. 能写测试就写测试,Thread Sanitizer 是你的好朋友
  4. 优先 Actor,实在绕不过再 unchecked,且用且珍惜

延伸阅读 & 工具

相关推荐
HarderCoder3 小时前
Swift 6.0 协议扩展:解锁协议新特性,写出更优雅、更高效的代码
swift
HarderCoder2 天前
Swift 6 并发时代,如何优雅地“抢救”你的单例?
swift
zhangmeng2 天前
FlutterBoost在iOS26真机运行崩溃问题
flutter·app·swift
HarderCoder2 天前
SwiftUI 踩坑记:onAppear / task 不回调?90% 撞上了“空壳视图”!
swift
HarderCoder2 天前
@isolated(any) 深度解析:Swift 并发中的“隔离追踪器”
swift
大熊猫侯佩2 天前
桃花岛 Xcode 构建秘籍:Swift 中的 “Feature Flags” 心法
app·xcode·swift
用户092 天前
SwiftUI Charts 函数绘图完全指南
ios·swiftui·swift
YungFan2 天前
iOS26适配指南之UIColor
ios·swift
HarderCoder2 天前
Swift 6.2 新特性 `@concurrent` 完全导读
swift