Swift 与 Objective-C

[Swift进阶 #3] Sendable与Swift 6并发错误迁移

开启Swift 6模式后,并发错误会大量出现,而Sendable通常是核心。本篇整理这个协议的含义:值能否跨越隔离边界,以及按值类型化、不可变化、升级为actor的顺序给出迁移方案。

7 分钟阅读
[Swift进阶 #3] Sendable与Swift 6并发错误迁移 封面图

如果团队开启过Swift 6模式,应该记得那一刻:原本运行正常的项目突然涌出几十、几百个并发错误。

错误信息的主角大多只有一个:Sendable。

Concurrency系列第3篇整理最后这块拼图,以及Swift 6的strict concurrency。

Sendable提出的问题——这个值可以跨越边界吗

在actor篇中,我们将隔离概括为“串行执行上下文”:actor内部、MainActor之上,以及不属于二者的协作池。

但值会频繁跨越这些边界。它们作为actor方法的参数进入,作为返回值离开,也会被Task闭包捕获。

风险由此产生。隔离保护的是actor自身的状态,但如果跨越边界的值是可变引用类型呢?

同一个实例可能同时被两个隔离上下文持有,actor阻止的数据竞争会通过带入的对象重新出现。就像边境检查很严格,却不检查入境物品。

Sendable就是这套检查标准。它是没有要求方法的标记协议,含义只有一个:

这种类型的值跨越隔离边界后仍可安全地并发使用。

哪些类型是安全的?直觉上很简单。

值类型跨越时会被复制,因此是安全的(前提是所有存储属性都是Sendable)。Int、String,以及仅由Sendable属性组成的struct和enum都属于此类。大多数情况下,编译器会自动认可它们。

actor也很安全,因为它内置了自我保护。

不可变类(final且只有let属性)也安全。既然无法修改,就不会有竞争。

真正危险的只有一种:拥有可变状态的类。值类型优先篇中的“共享+可变=危险”,正是Sendable的判定标准。

函数类型有单独的@Sendable标记。Task { }中的闭包就是典型的@Sendable闭包,带有该标记的闭包不能捕获non-Sendable值。

闭包篇提到过,捕获可能成为跨越隔离边界的走私通道,因此语言会检查通道本身。

strict concurrency——将警告变成错误,将规范变成检查

这些规则在Swift 6之前就存在,但默认情况下基本保持沉默。

Swift 6语言模式的核心,就是开启全部检查并将其提升为错误,也就是strict concurrency。在complete checking下,non-Sendable值跨越隔离边界的每个位置都会变成编译错误。

错误大量出现,并不是因为代码突然变差了,而是原本潜在的竞争现在才显现出来。

这就像引入可选值时,所有“可能为nil的地方”都暴露出来。那时需要nil检查,如今则是把并发假设迁移到类型系统中的过渡期。

好在编译器也越来越聪明。Swift 6加入的基于区域的隔离分析(Swift Evolution提案SE-0414,region-based isolation)就是例子。

即使是non-Sendable值,只要能证明“发送方不会再触碰它”,也允许移动。

所有权已经转移的值不可能制造竞争。因此,许多理论上应该报错的代码实际上仍能通过。

sending参数标记也遵循同样的方向。总之,规则正从“一律禁止”变得更精确,转向“证明安全后允许”。

检查示意图:struct、actor和final let类通过;可变类被拒绝
真正危险的只有拥有可变状态的类

实战迁移——按错误类型处理

大量错误大多会归结为几种模式。下面整理各类型的标准处理方案。

类型1。我的模型是non-Sendable。 这是最常见、也最健康的错误。首选方案是改成struct。

如果不需要引用身份的数据模型被声明成了class,这正是迁移为值类型的理由。

如果必须是class,就用final + let使其不可变并采用Sendable。如果必须可变,说明该状态需要所有者,因此应考虑升级为actor。

类型2。全局变量和static var。 之前警告过“static var本质上是全局状态”的所有位置都会变成错误。

如果确实需要全局状态,就声明隔离。UI相关内容加上@MainActor,否则用actor封装或改为不可变的let。

类型3。委托或回调类卡在边界上。 这经常出现在与UIKit时代API交互的地方。

如果该类型实际上只供主线程使用,声明@MainActor通常就是答案。把“这个类原本就只在主线程使用”的隐含事实改成显式声明。

类型4。实际上安全,但编译器不知道。 例如内部由锁保护的类,以及C库包装器。

此时的出口是@unchecked Sendable。它声明“安全性由我保证,请关闭检查”,但名称中的unchecked已经提醒这是unsafe系列工具。

遵循哲学篇第1篇中的显式出口原则,在注释中留下保证依据(哪把锁保护什么),并将其限制在最小范围内。

如果为了迁移方便开始用unchecked覆盖错误,最后得到的只是关闭检查、却挂着Swift 6标志的代码。

策略上不必一次性全部开启。语言模式可以按模块选择。

标准做法是自下而上:先将依赖较少的末端模块(工具和模型)升级到Swift 6模式,最后再升级应用target。

也可以使用Xcode的upcoming feature标志,提前只提高检查级别并观察警告。

读懂方向——为什么要让我们经历这些?

迁移的痛苦确实存在,因此有必要准确理解这项成本的依据。

Swift 6的承诺是:能编译就没有数据竞争。无法复现的间歇性崩溃,以及发布后才出现的时序错误,这些问题类别都会在编译时消失。

这与内存安全(可选值、ARC——Automatic Reference Counting,自动引用计数)走过的道路相同。并发安全正从“写得好就行”转变为“由语言保证”。

这一方向也是哲学系列轨迹的延伸:将容易出错的规则提升到类型系统中,在有成本的地方要求显式标记(@unchecked、sending),再通过渐进式采用(模块级语言模式)吸收过渡期摩擦。

Swift Evolution篇中提到的流程仍在通过加入默认隔离选项等缓冲机制来降低摩擦。现在遇到的警告正处于这次转变的中间阶段。

从Swift 5到正常Swift 6的迁移阶段路标插图
值类型化→不可变化→actor→MainActor,unchecked最后使用并附上依据

总结

  • Sendable标记的是能够在跨越隔离边界后安全并发使用的类型。值类型、actor和不可变类是安全的;可变类就是全部危险来源。
  • @Sendable闭包会检查捕获,防止闭包成为竞争的走私通道。
  • Swift 6 strict concurrency将这些检查提升为错误。错误爆发并不表示代码变差,而是潜在竞争暴露出来了。
  • 处理优先级:值类型化 → 不可变化 → 升级为actor → 声明隔离(@MainActor)→ 最后使用明确记录依据的@unchecked Sendable。
  • 从末端模块开始自下而上迁移,语言模式按模块逐步提升。

下一篇是Concurrency系列的收尾:结构化并发。内容包括Task、async let和TaskGroup构成的任务树,以及取消(cancellation)如何沿着这棵树传播。


参考资料

延伸阅读