Skip to content

Swift Sendable 与数据竞争治理落地手册

6 min read

很多团队升级到 Swift 并发后,第一批线上问题并不是崩溃,而是“偶尔错一次、过几天再来一次”的业务异常:状态回退、重复提交、缓存污染、回调读到过期对象。根因通常不是单个 await,而是并发边界没有被类型系统完整表达。

Sendable 的价值就在这里。它不是“语法限制”,而是并发域之间的数据契约:哪些类型可以安全跨线程传递、哪些必须隔离、哪些需要显式兜底。把这件事做实,数据竞争会从线上隐患变成编译器可见问题;做不实,再多代码评审也会漏。

一、先统一认知:Sendable 解决什么,不解决什么

Sendable 保证的是“值在跨并发域传递时不会因为共享可变状态产生未定义行为”。 它不保证以下内容:

  1. 不保证业务语义正确,例如扣款顺序、幂等逻辑。
  2. 不保证跨系统一致性,例如数据库与缓存的同步。
  3. 不保证性能最优,过度复制同样会慢。

但它极其关键,因为它把“能否安全跨域传递”前移到编译期。 如果一个类型携带了可变引用状态,编译器会逼你明确处理:要么隔离、要么改造、要么承担 @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 领域事件]

这张图至少要回答三个问题:

  1. 哪些层之间发生跨并发域传递。
  2. 传递的是值对象、引用对象,还是闭包。
  3. 哪些边界必须由 Actor 托管,哪些可用值语义直传。

没有边界图,Sendable 迁移会变成修一个报一个,时间全耗在返工上。

三、类型治理策略:四类对象四种处理方式

1)纯值对象:直接 Sendable

struct + 不可变字段是最理想路径。对 DTO、请求参数、响应快照优先采用值语义,编译器通常可自动推导。

2)引用对象但只读:封装成不可变快照

历史包袱里常见 class Configclass 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

这条是高风险通道,只能在下面条件都满足时使用:

  1. 你能证明内部线程安全(或只读)。
  2. 外层有隔离策略(Actor 或串行队列)。
  3. 代码旁边写清楚风险说明与验证方式。

并且必须加测试,不允许“先过编译再说”。

四、闭包和 API 设计:@Sendable 才是常见雷区

数据竞争很多不是来自类型,而是来自闭包捕获。 当闭包跨任务执行时,必须标注 @Sendable,这会强制你处理捕获对象的并发安全。

常见错误是捕获可变引用:

var retries = 0
Task.detached {
    retries += 1 // 非法:跨并发域捕获可变状态
}

正确做法有三种:

  1. 改成值传递,在闭包里只使用局部不可变副本。
  2. 把计数器放进 Actor。
  3. 用原子库或受控同步原语,但要证明必要性。

API 设计上,建议默认把跨边界回调签名写成 @Sendable,把不安全调用点暴露在编译期。

五、迁移计划:三阶段而不是一次性大爆炸

阶段 A:观测与分层(1~2 周)

  1. 开启并发告警,收集 Sendable 诊断清单。
  2. 按模块分组:UI、Domain、Infra。
  3. 统计热路径与高风险模块,先改收益最大的 20%。

阶段 B:核心链路收敛(2~4 周)

  1. 网络、存储、鉴权模块先 Actor 化。
  2. DTO 和事件模型全面值语义。
  3. 关键回调统一 @Sendable

阶段 C:严格模式与门禁(持续)

  1. 新模块启用更严格并发检查。
  2. PR 模板加入并发边界自检项。
  3. CI 加并发回归、压力和取消测试。

这样迁移速度比“一把梭全部改”更快,因为返工更少。

六、风险控制:@unchecked Sendable 的审计制度

如果项目里出现 @unchecked Sendable,必须可追溯。建议建立最小审计表:

  1. 类型名称与所在模块。
  2. 为什么无法改成真正 Sendable
  3. 现在的隔离手段是什么。
  4. 哪些测试在验证它。
  5. 预计何时移除。

没有审计制度,@unchecked Sendable 会像 TODO 一样越积越多,最后变成并发债务黑洞。

七、调优策略:安全与性能要同时看

很多团队在并发改造后遇到新问题:CPU 变高、延迟变长。原因通常是“为了满足契约做了过多复制”。

调优原则:

  1. 热路径避免重复构造大值对象,优先复用不可变快照。
  2. 对大集合采用分块传输,而非整包复制。
  3. 计算与存储分离,Actor 只做状态保护,不做重计算。
  4. 用 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 模板)

  1. 新增跨并发域模型是否声明 Sendable
  2. 新增并发回调是否声明 @Sendable
  3. 是否引入了新的 @unchecked Sendable,若有是否补审计。
  4. Actor 是否只保护状态,未承载重 CPU。
  5. 关键路径是否补了交错测试与取消测试。
  6. 是否有性能回归数据支持本次改动。

十一、结语

Sendable 不是“语法麻烦”,而是并发工程的合同系统。合同越清晰,事故越少;合同越模糊,线上问题越像玄学。

真正成熟的团队不会问“要不要上 Sendable”,而是问“我们的并发边界是否被类型系统完整表达,且可持续验证”。当这个问题答得出来,竞态问题就从运维风险变成可管理成本。