概述
今年恰巧是 Swift 语言十周岁生日,也是苹果推出 Swift 6 的 Big Year!!!在全新的 Swift 6 大版本中,苹果再接再厉,持续完善和增强了 Swift 语言的语法和语义。
而对于早在 Swift 5.5 就推出的异步并发模型,在 Swift 6 中也添加了诸多新功能让其继续大放异彩。sending 关键字就是其中一件“和璧隋珠”,它的出现弥补了 Sendable 在某些场景下可能造成的“附赘悬疣”。
在本篇博文中,您将学到如下内容:
- 邂逅 Sending
- Sendable 的作用
- Sendable 可能出现的“多事之秋”
- 拯救者 sending 来了!
相信学完本课后,小伙伴们将理解为何 Swift 语言团队要在 Swift 6 中用 sending 全面代替 Sendable 关键字的深刻原因。
那还等什么呢?让我们马上开始冒险行动吧! Let’s go!!!;)
1. 邂逅 Sending
全新的 Swift 6 大版本对旧版本语言做了诸多加强,尤其是结构化并发的语义和语法。在 Swift 6 中苹果将语言内置的数据竞争(Data Race)保护机制做了脱胎换骨般的升级,在将编译器切换为 Swift 6 之后,不出意外的话之前很多异步并发代码都将出现警告甚至错误。
苹果在 Swift 6 中同样引入了海量全新的特性,sending 关键字有幸成为其中极为重要的一员。
sending 从语义上和 Sendable 极为相似,但它们在特定的撸码场景中又有天壤之别。sending 关键字有着后者没有的独特“魅力”,这就是为什么苹果要在 Swift 6 里用 sending 替代 @Sendable 修饰传向 Task、连续体(Continuations)以及任务组(Task Group)中的闭包了。
要搞清楚 sending 引入的真正奥义,我们需要先从 Sendable 聊起。
2. Sendable 的作用
在 Swift 6 之前的并发世界里,我们还没有 sending 关键字,但是我们有 Sendable 的定义啊!
“月有阴晴圆缺,人有悲欢离合”。同样的,为了保证并发操作中的数据隔离,对于共享可变状态的对象来说要么我们用锁(Lock),要么我们仅在特定的 Actor 中访问它们。
我们可以非常安全的将 Sendable 类型的值从一个并发域(Domain)传到另一个域,或在 @Sendable 闭包中安全的捕获它们。举个“栗子”,我们可以将 Sendable 值作为实参在调用时传递给 Actor 中的方法。
简单来说,Sendable 用来向编译器保证:我们从一处传递至另一处的闭包或值不会有任何并发中的数据竞争问题,即确保并发安全。
从 Swift 5 中 Task 类型构造器的签名可以看到,我们是用 @Sendable 来修饰传入任务的闭包的:
public init(
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async -> Success
)
这里用 @Sendable 来修饰闭包的作用是实现任务之间或任务与孤立上下文之间的安全隔离。换句话说,我们在闭包内部任何操作或捕获都必须是安全的,或者称为必须是 Sendable 的。
3. Sendable 可能出现的“多事之秋”
综上所述,从本质上来说 @Sendable 闭包只允许捕获本身是 Sendable 的“东东”。
这意味着,下面这段代码在 Swift 5.10 编译器严格并发(strict concurrency)模式开启时将是不安全的:
// 下面代码要求 Swift 5 编译器,甚至 Xcode 16 中的 Swift 5 模式也不可以
func exampleFunc() {
let isNotSendable = MyClass()
Task {
// 抛出错误:Capture of 'isNotSendable' with non-sendable type 'MyClass' in a `@Sendable` closure
isNotSendable.count += 1
}
}
此时编译器将抛出错误,因为我们在 Task 的 @Sendable 闭包中引用了非 Sendable 类型(MyClass)的实例。这种现象我们在实际撸码中偶尔会遇到,尤其是在 @Sendable 闭包中。
注意:上面代码在 Xcode 16 中用 Swift 6 编译器的 Swift 5 模式编译时不会产生错误。
这是因为在 Swift 6 中即使采用 Swift 5 模式,其 Task 类型中闭包的修饰已经从 @Sendable 替换为 sending 了。
如果我们需要在 Swift 6 编译器产生同样的结果,需要自己动手创建 @Sendable 闭包:
public func sendableClosure(
_ closure: @Sendable () -> Void
) {
closure()
}
按照 @Sendable 的定义来看我们的确“食言而肥”在 Task 闭包中捕获了非 Sendable 对象,所以 Swift 编译器在此抛出错误的行为是非常正确的,只不过很傻而已。
因为在上面这个特定例子中,我们的操作是绝对安全的!我们只是在方法内部创建了 MyClass 的实例并且仅在闭包中引用了它,我们从未在 Task 闭包外部对“其指手画脚”,因为在 exampleFunc 方法结束执行时 MyClass 对象就已经灰飞烟灭了。
所以在这种场景下,@Sendable 是谨慎过了头。我们需要的是并发操作安全而不是非必要的“过度安全”。
所以在这个时候,小伙伴们都猜到了:sending 将前来拯救我们啦!
4. 拯救者 sending 来了!
在 Swift 6 中编译器团队添加了一个新特性,那就是允许我们向编译器坦白我们打算捕获非 Sendable(non-sendable)状态的意图,但这些状态绝对不会在捕获后再次被访问。这就是添加 sending 关键字的真谛!
这使得我们能够将 non-sendable 对象传递给一个需要在隔离上下文中安全调用的闭包。
在 Swift 6 中,下面的代码是绝对合法、有效且安全的:
func exampleFunc() async {
let isNotSendable = MyClass()
Task {
isNotSendable.count += 1
}
}
这是因为 Task 类型构造器签名中闭包已被悄悄的改为用 sending 关键字修饰了:
public init(
priority: TaskPriority? = nil,
operation: sending @escaping () async -> Success
)
由于现在闭包使用的是 sending 而不是 @Sendable,编译器可以检查我们传递给任务的 MyClass 实例在任务捕获它之后是否被访问或使用。因此,虽然上述代码是有效的,但我们实际上可以撸出一些不再有效的代码:
func exampleFunc() async {
let isNotSendable = MyClass()
// #1
Task {
isNotSendable.count += 1
}
// #2
print(isNotSendable.count)
}
通过仔细观察上面代码可以发现:虽然非 Sendable 类型 MyClass 的实例 isNotSendable 在方法内部被定义,但在 #1 和 #2 两处代码可能出现并发数据竞争,所以这里编译器仍会毅然决然的抛出错误。
这种语言上的改变使我们能够将 non-sendable 状态传递给任务,这是我们有时会渴望要做的事情。它还确保我们不会做潜在不安全的事情,比如从多个隔离上下文中访问不可发送(non-sendable)的状态,而这正是上面示例中出现的情况。
如果我们在定义自己接受闭包作为参数的函数,并且希望这些函数在多个隔离上下文中都能安全地调用,那么我们需要将它们标记为 sending。该关键字被添加为闭包类型的前缀,类似于通常放置 @escaping 的位置。
如下代码所示:
public func sendingClosure(
_ closure: sending () -> Void
) {
closure()
}
大家可能不会经常定义自己的 sending 闭包或接受 sending 参数的函数,因为 Swift 编译器团队已经更新了 Task 、Detached Task、连续体以及任务组(Task Group)的初始化器,以接受 sending 而不是@Sendable 闭包。
因此我们会发现,在启用严格并发的情况下,Swift 6 允许你做一些在 Swift 5 中不允许做的事情。
这简直“泰裤辣”,小伙伴们觉得如何呢?么么哒!
总结
在本篇博文中,我们讨论了 Swift 6 中全新引入的 sending 关键字是如何解决 Swift 5 原来 @Sendable 在并发安全场景中一些“捉襟见肘”的问题的,大家值得拥有!
感谢观赏,再会啦!8-)