译文:Non-Sendable types are cool too you know
作者:Matt
Sendable经常被大家讨论。尽管它是Swift并发模型重要的组成部分,我认为非Sendable类型是非常有趣的并且也很重要。当我们遇到并发问题时,非Sendable类型常常被认为是问题所在。但是,实际上非Sendable类型是一个完美的解决方案。
让我们深入研究一下。但是在我们研究之前,我必须确认你将要接触的内容-不是入门级的知识。
线程安全?
我想要从困扰了很多人的问题入手。不管他们用的是哪种编程语言,程序员都大致了解线程安全模型。
这里是官方文档对于Sendable的说明:
线程安全类型,它的值可以被任意的并发上下文共享并且不会导致数据竞争。
听起来非常清楚。但是,这里有些细节很奇怪。全局 actor 隔离类型隐式遵守Sendable。那意味着UIView(MainActor隔离类型) 也是Senable。你认为那是线程安全类型吗?让我们看下它的官方解释:
UI操作必须在主线程执行
我本希望能看到'该类型非线程安全'之类的明确警告,以彻底强调这一点。但这显然仍不像是能在任意并发上下文中安全共享的类型。对我来说,线程安全意味着在任何时间任何线程都可以使用。那意味着什么?
UIView实际上确实满足线程安全的定义 - 但是仅限于Swift 6。那是因为编译器识别其仅限主线程的要求,并且严格执行。
之所以在Sendable中强调线程安全,是因为语言可以达到同样的效果。但是我认为这会让学习Swift并发的人感到困惑。更糟糕的是,@unchecked Sendable实际上故意绕开了编译器检查。这些类型必须以符合线程安全通用语义的方式实现线程安全——这里的定义与 Swift 并发体系之外的业界标准完全一致。
什么是Sendable
Sendable是标记协议。这是一种没有实现要求的协议,只是描述一些语义要求。Sendable类型意味着它可以跨越隔离域传输。这些类型是线程安全的——需特别注意该术语在 Swift 并发体系中的特定含义。
什么是非Sendable类型
非Sendable类型意味着它不遵守Sendable。这些类型不能跨隔离域共享。它们被困在创建时所在的隔离域。很多都是非Sendable类型。
有时候,Swift 6编译器能够安全得将非Sendable类型从一个隔离域传递到另一个隔离域。这能显著提高易用性,但是它并没有改变这些类型的本质。它们仍然不安全,不能被共享,编译器也不会允许这样做。
或许你会感到很惊讶,非Sendable类型也是线程安全。那是因为编译器强制你用线程安全的方式使用它。实际上,所有使用 Swift 6 语言模式构建的代码都是线程安全的。
协议
当协议与隔离机制相遇时,这一领域经常会让人们遇到问题。由于隔离性是所有类型的属性,协议也必然会定义它们的隔离性要求。而有时候,这并不是你想要的。
protocol NonIsolatedProtocol {
func someFunction()
}
@MainActor
class MyClass: NonIsolatedProtocol {
private var state = 0
// 报错
func someFunction() {
}
}
这里的问题是,协议要求someFunction是无隔离,但是这个类型本身是MainActor隔离,这里发生了冲突。这种冲突是非常普遍的。
移除隔离
有很多方法处理隔离不匹配。一种最直接的方式就是使用关键字nonisolated,移除隔离。这会有选择性地让整个类型不再应用任何隔离机制。
@MainActor
class MyClass: NonIsolatedProtocol {
private let state = 0
nonisolated func someFunction() {
// 这里依然会报错 self 是MainActor
print("my state is:", state)
}
}
然后当你这样做的时候,你只是在推迟解决问题。你现在可以遵守该协议了,但其实现方式可能并非你所期望。这并不是特别有用。
这是一个刻意构造的例子,但我想强调一点。我们仅仅从协议的方法上移除了隔离性。如果我们改为完全移除隔离性,那么一切都能正常工作。这个对象的内部实现完全没有任何地方需要隔离性。但是,如果(内部实现)有需要隔离性的地方,编译器会立刻报错。记住,这门语言不允许你犯错!
现在我要大胆断言:如果你可以从一个类型中移除隔离性,你就应该这样做。
隔离性是一种约束,因此移除它可以增加灵活性。下面是移除了所有隔离性之后,上述问题的解决方案。它看起来就很普通。
protocol NonIsolatedProtocol {
func someFunction()
}
class MyClass: NonIsolatedProtocol {
private var state = 0
func someFunction() {
print("my state is:", state)
}
}
这个方案之所以如此出色,是因为它解决了根本问题。NonIsolatedProtocol 的定义做出了 MyClass 无法遵守的承诺。不恰当的隔离性,无论是过多还是过少,都可能对设计不利。
隔离性去哪儿了?
当有人建议移除隔离性时,许多人会感到担忧。甚至 nonisolated 这个关键字也会让人紧张。我认为这可以理解,因为它听起来好像可能不安全。
但事实并非如此。一个非 Sendable 类型仍然必须以某种方式被隔离。所有东西始终具有某种明确定义的隔离性。这个问题只是被推高了一层,转移到了该类型的使用者。这个对象将在某个隔离域中被创建,并且会一直留在那里。它无法移动,因为只有 Sendable 类型才能做到这一点。
我确实想承认,过度隔离实际上有助于捕获一类重要的问题:与错误标注的代码的互操作性。这是一个本质上很难处理的问题。但是,我确实想指出这一点,因为直到最近我才完全理解这个问题。)
non-Sendable + async
如果说有一个我一直看到的巨大危险信号,那就是带有异步方法的非 Sendable 类型。
class MyClass {
private var state = 0
func someAsyncFunction() async {
print("my state is:", state)
}
}
要理解为什么这里有问题,你必须理解非隔离的异步函数是如何工作的。它们总是在全局执行器(global executor)上运行。或者,换句话说,非隔离的异步函数总是在后台运行。
我们看一下调用端:
@MainActor
class Client {
// 隔离在 MainActor
let instance = MyClass()
// 也是 MainActor
func useInstance() async {
// 错误
await instance.someAsyncFunction()
}
}
我们知道someAsyncFunction 必须运行在一个非隔离的上下文。我们也知道instance不是Sendable类型。并且这意味者为了调用这个方法,instance必须无隔离。从定义上来说,我们使用一个只能从非隔离环境使用的类型。这是合理的,但是不一定是你想要的。
解决方案?
当我第一次遇到这个问题时,我是这样做的。
class MyClass {
private var state = 0
@MainActor
func someAsyncFunction() async {
print("my state is:", state)
}
}
我知道我想要一个非隔离类型,这样我就可以遵守非隔离协议。但是,我也想要异步方法。而我能想到的唯一安全地做到这一点的方法就是应用某种隔离。但这实际上只是同一问题的另一面。现在,只有当实例已经在 MainActor 上时,这个方法才能被调用。
我们能做的更好吗?
是的,实际上我们能。但是不要被这个方法签名吓到。
class MyClass {
private var state = 0
func someAsyncFunction(isolation: isolated (any Actor)? = #isolation) async {
print("my state is:", state)
}
}
我们在这里所做的是,利用一个隔离参数并结合 Swift 6 的一些强大的新特性,继承调用端的隔离性。但这使得我们的函数能够普遍适用于所有可能的隔离场景。这可以从 MainActor、常规 actor 类型以及非隔离上下文中使用。你甚至不需要与隔离参数进行交互。仅凭它的存在就确立了我们想要的隔离行为。
@MainActor
class Client {
// 隔离MainActor上
let instance = MyClass()
// 同样在MainActor
func useInstance() async {
// ... 这个方法也是在MainActor
await instance.someAsyncFunction()
}
}
注意这里缺少了isolation的参数。默认使用#isolation。
隔离参数让非Sendable类型参与并发变得更容易。而且,额外的好处是,这保证了在函数入口处不会挂起。在 someAsyncFunction 内部的后续调用仍然可能会挂起。但是,这开启了一个小的同步窗口,而这对于解决某些类型的并发问题至关重要。
更新
事实证明,将隔离参数设为可选会带来更多复杂性。但我之前没有意识到这一点,因为存在一个编译器错误。这本身并没有“错”,但将其设为非可选意味着 #isolation 默认值将不再起作用。所以,这里需要做出重大的权衡。但是,最安全、也最麻烦的选项实际上是这样的:
class MyClass {
private var state = 0
func someAsyncFunction(isolation: isolated any Actor) async {
print("my state is:", state)
}
}
我想强调这一点,但我仍然认为在大多数情况下,在这里使用可选类型是有意义的。它更简单、更方便,而且一旦那个 bug 被修复,它将不再允许任何不安全的情况发生。
@isolated(any)
我认为几乎所有非 Sendable 类型都应该在其异步函数中使用这种隔离参数技术。但是,这样做非常冗长。这让我开始思考这种技术与 @isolated(any) 之间的关系。
class MyClass {
private var state = 0
@isolated(any)
func someAsyncFunction() async {
print("my state is:", state)
}
}
如今, @isolated(any) 只能用于闭包。有趣的是,闭包本身也可以拥有隔离参数。但是,我越想越觉得,隔离参数和 @isolated(any) 似乎在解决同一个问题。我错了吗?
(未来的我:是的,我错了!这个特性经常引起混淆。这里可能仍然有一些想法,但这大部分说不通。)
你应该使用非 Sendable 类型。
我认为非 Sendable 类型非常有用。它们更容易与协议一起使用。它们和隔离类型一样“线程安全”。而且现在我们有办法让它们拥有可用的异步方法。它们的并发故事中存在一个小缺陷。但是,总的来说,我认为它们可以成为一个非常强大的工具,用于建模可变状态,并且可以与任意隔离的上下文一起工作。
非 Sendable 类型非常棒,你应该使用它们!