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

延伸阅读 & 工具

相关推荐
崽崽长肉肉13 小时前
swift中的知识总结(一)
ios·swift
Yakamoz14 小时前
Swift Array的写时复制
swift
汉秋17 小时前
SwiftUI 中的 compositingGroup():真正含义与渲染原理
swiftui·swift
汉秋18 小时前
SwiftUI 中的 @ViewBuilder 全面解析
swiftui·swift
胖虎11 天前
SwiftUI 页面作为一级页面数据被重置问题分析
ios·swiftui·swift·state·observedobject·stateobject·swiftui页面生命周期
健了个平_242 天前
【iOS】如何在 iOS 26 的UITabBarController中使用自定义TabBar
ios·swift·wwdc
1024小神2 天前
xcode 配置了AppIcon 但是不显示icon图标
ios·swiftui·swift
奶糖 肥晨2 天前
架构深度解析|基于亚马逊云科技与Swift Alliance Cloud构建高可用金融报文交换架构
科技·架构·swift
Swift社区3 天前
用 Task Local Values 构建 Swift 里的依赖容器:一种更轻量的依赖注入思路
开发语言·ios·swift
TouchWorld3 天前
iOS逆向-哔哩哔哩增加3倍速播放(4)- 竖屏视频·全屏播放场景
ios·swift