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

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

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

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

但现实往往不完美:

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

于是 Swift 给出逃生舱口:

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

官方定义与历史回顾

来源:SE-0302

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

什么时候该用 @unchecked Sendable?

场景 示例 是否推荐
✅ 手工同步的类 DispatchQueue.sync / NSLock 首选
✅ 只读语义 结构体/类所有属性 let 且成员也是 Sendable 安全
✅ 系统类型已知安全 包装 NSMutableData、CGContext 等 可用
❌ 懒得加锁 没有任何同步手段 找死
❌ 可变字典/数组 直接暴露 _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,且用且珍惜

延伸阅读 & 工具

相关推荐
东坡肘子16 小时前
参与学生移动应用大赛有感 -- 肘子的 Swift 周报 #161
人工智能·swiftui·swift
songgeb5 天前
YYImage Bug:一行之差,正常 JPEG 如何在 iOS 被读成条纹
ios·swift
fthux5 天前
Tick Rest,一个休息提醒工具的自我修养
macos·开源·swift
Fatbobman(东坡肘子)6 天前
快速消失的硬盘容量 -- 肘子的 Swift 周报 #160
开发语言·人工智能·redis·ios·swiftui·iphone·swift
chaoahyl6 天前
WaterDiary 喝水记录 App 实战(2)· 实现喝水首页与本地保存
swift
茶底世界之下7 天前
iPhone Duo 适配实战,Awaking 踩过的 8 个坑
ios·swiftui·swift
xbzb8 天前
Swift 对象存储建环实验 + Ceilometer 计量:两个收尾服务
swift·ring·实验·ceilometer·象存储·建环
2501_916008898 天前
iOS App 图标、启动屏和多语言搭配 方法,上架前使用KXBuild一次做好 App 图标
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
2501_915918418 天前
iOS 推送通知怎么实现,从 APNs p8 密钥到 iOS 真机收到第一条推送
vscode·ios·objective-c·个人开发·swift·敏捷流程
东坡肘子8 天前
快速消失的硬盘容量 -- 肘子的 Swift 周报 #160
人工智能·swiftui·swift