译:非Sendable类型也有用武之地

111 阅读9分钟

译文: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 类型非常棒,你应该使用它们!