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,且用且珍惜

延伸阅读 & 工具

相关推荐
Kapaseker18 小时前
Swift 构建 Android 应用?它来了
ios·swift
HarderCoder1 天前
Swift 协议(Protocol)指南(四):协议扩展(Protocol Extension)——让“协议”自己也有默认实现
swift
HarderCoder1 天前
Swift 协议(Protocol)指南(三):Primary Associated Type、some/any 与泛型式协议实战
swift
HarderCoder1 天前
Swift 协议(Protocol)指南(二):关联类型、Self 约束与泛型递归,一次彻底搞懂
swift
HarderCoder1 天前
Swift 协议(Protocol)指南(一):从语法到实战
swift
HarderCoder1 天前
Swift TaskGroup 结果顺序踩坑指南:为什么返回顺序和创建顺序不一致,以及最通用的修复办法
swift
Swift社区1 天前
iOS 基于 Foundation Model 构建媒体流
ios·iphone·swift·媒体
大熊猫侯佩2 天前
侠客行・iOS 26 Liquid Glass TabBar 破阵记
ios·swiftui·swift
qixingchao3 天前
iOS SwiftUI 动画开发指南
ios·swiftui·swift
大熊猫侯佩3 天前
猿族代码战记:Mutex 升级版——守护 Swift 并发的“香蕉仓库”
swiftui·swift·apple