面试里聊
Sendable,最容易被一句话带偏:"我不写: Sendable也能跑啊。"架构师视角下,这句话等价于:"我没开编译器安全带,所以也没撞见气囊。"
1. Sendable 在并发生态里的"生态位"
Swift Concurrency 体系里,Sendable 的职责非常明确:
async/await:描述异步控制流Task/TaskGroup:组织并发任务Actor/MainActor:隔离共享状态访问Sendable:约束"跨并发域传值是否安全"
一句话 :Sendable 是跨隔离域/跨线程传递数据的"静态安全证明"。
它不负责让你的代码"跑起来",它负责让你的代码"长期可维护且不出并发事故"。
2. 坑 1:不写 Sendable 也能打印日志,误以为"编译器不管"
很多人第一次写 detached:
swift
struct User {
let id: Int
let name: String
}
func sendToBg(_ user: User) {
Task.detached {
print("后台收到:\(user.name)")
}
}
结果能跑、能打印,于是得出结论:Sendable 可有可无。
架构师解读
Sendable是编译期约束,不是运行期机制。- 你没看到报错,可能只是项目的 Strict Concurrency Checking 不够严格(或 Swift 语言模式不是 6)。
- 更重要的是:即使它在当前设置下没报,团队协作、编译器升级、跨模块演进后,这类"不显式表达并发意图"的代码会非常脆弱。
工程建议(DTO 约定)
swift
struct User: Sendable {
let id: Int
let name: String
}
3. 坑 2:值类型也会"看起来像共享"------闭包捕获语义反直觉
你以为 struct 是值语义,所以后台任务应该拿到"创建时的值",但你写:
swift
struct Teacher: Sendable { var name: String }
func test() {
var t = Teacher(name: "Oliver")
Task.detached {
try? await Task.sleep(nanoseconds: 200_000_000)
print("后台读取:\(t.name)")
}
t.name = "Jack"
print("主线程修改:\(t.name)")
}
你很可能看到后台打印的是 Jack。
根因
- 不写捕获列表时,闭包捕获的是外层
var t这个"变量位置",而不是你心里期待的"值快照"。 - 这会让"跨域传值"的语义变得不清晰:你到底是传"快照",还是传"后续可能变化的变量"?
正确演进:显式快照
swift
let snapshot = t
Task.detached {
print("后台读取:\(snapshot.name)")
}
或者:
swift
Task.detached { [t] in
print("后台读取:\(t.name)")
}
架构落地规则
- "跨并发域传值"统一使用 不可变快照(let + struct),避免让捕获语义隐式决定数据版本。
4. 坑 3:final class: Sendable 在 Swift 6 会直接"教做人"
很多同学自然会写:
swift
final class Profile: Sendable {
var nickName: String
init(nickName: String) { self.nickName = nickName }
}
Swift 6 直接报(或升级为 error):
Stored property 'nickName' of 'Sendable'-conforming class 'Profile' is mutable
根因
class是引用语义:跨并发域传的是同一个对象地址。var是共享可变状态:多个并发任务可能同时读写。- 你让它
Sendable,等于向编译器承诺"它能安全跨域共享",但你没有任何隔离/同步策略。
正确演进 1:不可变引用对象
swift
final class Profile: Sendable {
let nickName: String
init(nickName: String) { self.nickName = nickName }
}
这类对象可以安全跨域共享:因为它不可变。
5. 坑 4:@unchecked Sendable 不是解决方案,是"关闭报警器"
当你写:
swift
final class Profile: @unchecked Sendable {
var nickName: String
init(nickName: String) { self.nickName = nickName }
}
编译器不拦你了,但你获得的不是安全,而是"责任自负"。
很容易写出这种并发读写:
swift
Task.detached { user.profile.nickName = "后台改名1" }
Task.detached { print(user.profile.nickName) }
你会看到输出顺序与读取值不稳定,这不是"多线程正常现象",这是共享可变引用状态的典型风险:data race 可能发生,只是未必崩在你眼前。
架构师原则
@unchecked Sendable只允许出现在你能明确解释同步策略的地方(锁/串行队列/actor/mainactor),并且要极少、可审计。
6. 正确建模:共享可变状态就用 actor,不要硬把 class 送上 Sendable
如果 Profile 本质上就是"会变的共享状态",它的正确生态位是 actor:
swift
actor Profile {
private var nickName: String
init(nickName: String) { self.nickName = nickName }
func updateName(_ newName: String) {
nickName = newName
}
func currentName() -> String {
nickName
}
}
外部必须通过 await 访问:
swift
Task.detached {
await user.profile.updateName("后台改名1")
let name = await user.profile.currentName()
print("后台读取:\(name)")
}
注意:你仍然会看到读写交错(因为任务调度非确定),但这是在 actor 隔离下的"合法交错",不是裸奔式共享内存并发读写。
7. 架构落地:一套团队可执行的规则
- 跨线程传值对象(DTO/参数/响应) :优先
struct: Sendable,尽量let;需要变化时用"生成新值",不要原地改。 - 共享可变状态(缓存/账户/会话/库存) :优先
actor建模,把并发边界收敛到隔离域。 final class: Sendable:只用于不可变引用对象(全let)。@unchecked Sendable:仅用于你能证明线程安全的历史类,并收敛到极少数点,禁止泛滥。
8. 结语:Sendable 的价值不在"现在能跑",在"未来敢改"
架构师看并发,不看"这次打印对不对",看的是:
- 代码能否被编译器证明安全
- 并发边界是否清晰
- 状态是否按职责被建模(DTO vs Stateful)
- 团队是否有统一约束来避免隐患扩散
一句话收尾 :跨域传值用 Sendable struct,共享可变状态用 actor,@unchecked Sendable 只做最后兜底。