Swift Sendable 与数据竞争治理落地手册
很多团队升级到 Swift 并发后,第一批线上问题并不是崩溃,而是“偶尔错一次、过几天再来一次”的业务异常:状态回退、重复提交、缓存污染、回调读到过期对象。根因通常不是单个 await,而是并发边界没有被类型系统完整表达。
Sendable 的价值就在这里。它不是“语法限制”,而是并发域之间的数据契约:哪些类型可以安全跨线程传递、哪些必须隔离、哪些需要显式兜底。把这件事做实,数据竞争会从线上隐患变成编译器可见问题;做不实,再多代码评审也会漏。
一、先统一认知:Sendable 解决什么,不解决什么
Sendable 保证的是“值在跨并发域传递时不会因为共享可变状态产生未定义行为”。
它不保证以下内容:
- 不保证业务语义正确,例如扣款顺序、幂等逻辑。
- 不保证跨系统一致性,例如数据库与缓存的同步。
- 不保证性能最优,过度复制同样会慢。
但它极其关键,因为它把“能否安全跨域传递”前移到编译期。
如果一个类型携带了可变引用状态,编译器会逼你明确处理:要么隔离、要么改造、要么承担 @unchecked Sendable 的责任。
二、并发边界图:先画图,再改代码
在中大型工程里,最忌讳直接全局搜 Sendable 然后盲改。正确顺序是先画边界图:
flowchart LR
UI[MainActor UI层] --> UC[UseCase 并发编排层]
UC --> DOM[Domain 值模型]
UC --> NET[Network Actor]
UC --> DB[Storage Actor]
NET --> DTO[Sendable DTO]
DB --> SNAP[不可变 Snapshot]
DOM --> EVT[Sendable 领域事件]
这张图至少要回答三个问题:
- 哪些层之间发生跨并发域传递。
- 传递的是值对象、引用对象,还是闭包。
- 哪些边界必须由 Actor 托管,哪些可用值语义直传。
没有边界图,Sendable 迁移会变成修一个报一个,时间全耗在返工上。
三、类型治理策略:四类对象四种处理方式
1)纯值对象:直接 Sendable
struct + 不可变字段是最理想路径。对 DTO、请求参数、响应快照优先采用值语义,编译器通常可自动推导。
2)引用对象但只读:封装成不可变快照
历史包袱里常见 class Config、class SessionContext 这类对象。若业务上可只读,建议导出 Snapshot:
struct SessionSnapshot: Sendable {
let userId: String
let region: String
let flags: [String: Bool]
}
final class SessionContext {
private var flags: [String: Bool] = [:]
func snapshot() -> SessionSnapshot {
SessionSnapshot(userId: userId, region: region, flags: flags)
}
}
这样可以把可变性关在边界内,不把竞态带到并发链路。
3)必须可变且共享:放入 Actor
例如 token 刷新状态、请求配额、内存缓存命中统计。只要多个任务会并发读写,就不要继续裸露 class,直接进 Actor。
4)遗留第三方类型:受控使用 @unchecked Sendable
这条是高风险通道,只能在下面条件都满足时使用:
- 你能证明内部线程安全(或只读)。
- 外层有隔离策略(Actor 或串行队列)。
- 代码旁边写清楚风险说明与验证方式。
并且必须加测试,不允许“先过编译再说”。
四、闭包和 API 设计:@Sendable 才是常见雷区
数据竞争很多不是来自类型,而是来自闭包捕获。
当闭包跨任务执行时,必须标注 @Sendable,这会强制你处理捕获对象的并发安全。
常见错误是捕获可变引用:
var retries = 0
Task.detached {
retries += 1 // 非法:跨并发域捕获可变状态
}
正确做法有三种:
- 改成值传递,在闭包里只使用局部不可变副本。
- 把计数器放进 Actor。
- 用原子库或受控同步原语,但要证明必要性。
API 设计上,建议默认把跨边界回调签名写成 @Sendable,把不安全调用点暴露在编译期。
五、迁移计划:三阶段而不是一次性大爆炸
阶段 A:观测与分层(1~2 周)
- 开启并发告警,收集
Sendable诊断清单。 - 按模块分组:UI、Domain、Infra。
- 统计热路径与高风险模块,先改收益最大的 20%。
阶段 B:核心链路收敛(2~4 周)
- 网络、存储、鉴权模块先 Actor 化。
- DTO 和事件模型全面值语义。
- 关键回调统一
@Sendable。
阶段 C:严格模式与门禁(持续)
- 新模块启用更严格并发检查。
- PR 模板加入并发边界自检项。
- CI 加并发回归、压力和取消测试。
这样迁移速度比“一把梭全部改”更快,因为返工更少。
六、风险控制:@unchecked Sendable 的审计制度
如果项目里出现 @unchecked Sendable,必须可追溯。建议建立最小审计表:
- 类型名称与所在模块。
- 为什么无法改成真正
Sendable。 - 现在的隔离手段是什么。
- 哪些测试在验证它。
- 预计何时移除。
没有审计制度,@unchecked Sendable 会像 TODO 一样越积越多,最后变成并发债务黑洞。
七、调优策略:安全与性能要同时看
很多团队在并发改造后遇到新问题:CPU 变高、延迟变长。原因通常是“为了满足契约做了过多复制”。
调优原则:
- 热路径避免重复构造大值对象,优先复用不可变快照。
- 对大集合采用分块传输,而非整包复制。
- 计算与存储分离,Actor 只做状态保护,不做重计算。
- 用 Instruments 验证分配热点,别凭直觉优化。
可执行指标建议:
- 每个关键请求跨域传值对象大小 P95 控制在预算内。
- 迁移前后比较
Allocations和尾延迟,不只看平均值。 - 如果
Sendable改造让关键路径回退超过阈值,必须给出替代方案。
八、测试矩阵:把竞态问题前移到 CI
1)编译期测试
把并发警告当错误处理,避免“先忽略再修”。
2)运行期交错测试
并发执行同一业务操作,验证最终状态不变量,特别是计费、库存、消息去重等模块。
3)取消与重试测试
构造超时、上游取消、网络抖动,验证不会遗留脏状态。
4)长时稳定性测试
用固定种子跑上万次并发场景,统计失败分布,确保无低频漂移。
5)性能回归测试
改造后对比基线:CPU、内存、P99、峰值对象分配数。
九、排障流程:出现数据竞争告警时怎么做
flowchart TD
A[发现异常: 数据错乱或并发告警] --> B[定位跨域传递点]
B --> C{类型是否 Sendable}
C -- 否 --> D[重构为值语义或 Actor 托管]
C -- 是 --> E[检查闭包捕获与隔离上下文]
E --> F{是否存在 unchecked Sendable}
F -- 是 --> G[核查审计记录与测试覆盖]
F -- 否 --> H[排查业务状态机与幂等逻辑]
D --> I[补回归与压力测试]
G --> I
H --> I
这个流程强调先看边界,再看实现细节。直接改业务逻辑通常只能“暂时压住”,不能根治。
十、工程实践清单(可直接放进 PR 模板)
- 新增跨并发域模型是否声明
Sendable。 - 新增并发回调是否声明
@Sendable。 - 是否引入了新的
@unchecked Sendable,若有是否补审计。 - Actor 是否只保护状态,未承载重 CPU。
- 关键路径是否补了交错测试与取消测试。
- 是否有性能回归数据支持本次改动。
十一、结语
Sendable 不是“语法麻烦”,而是并发工程的合同系统。合同越清晰,事故越少;合同越模糊,线上问题越像玄学。
真正成熟的团队不会问“要不要上 Sendable”,而是问“我们的并发边界是否被类型系统完整表达,且可持续验证”。当这个问题答得出来,竞态问题就从运维风险变成可管理成本。