前言: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] }
}
}
总结
@unchecked Sendable
= 编译器放手,人权归你- 必须加锁/队列/不可变,否则就是定时炸弹
- 能写测试就写测试,Thread Sanitizer 是你的好朋友
- 优先 Actor,实在绕不过再 unchecked,且用且珍惜
延伸阅读 & 工具
- SE-0302 原文
- Swift 官方文档:Sendable
- Thread Sanitizer 指南:Apple TN2434
- khorbushko.github.io/article/202...