注意!尽管标题这样写,我不确定这是不是最好的介绍文章。你或许可以从这里开始。
最近,有人问了我一个关于隔离的问题。细节无需赘述,但我确实开始认真思考,毕竟当时他们举步维艰。隔离机制是Swift并发模型的核心基础,但它本身是一个全新的概念。
尽管是新的概念,但是它实际上也使用了我们熟悉的机制。关于隔离机制是如果工作的,你可能已经了解了很多,只是你还没有意识到。
我来用最简单的方式拆解这些概念:
到底什么是隔离
隔离是Swift用来消除数据竞争的一种机制。有了它,编译器就可以推断出数据是怎么被访问的,并保证数据访问是线程安全的。需要特别说明的是,这里特指以非安全方式访问可变状态的情况,而非泛指所有类型的竞态条件。
定义决定隔离域
你总是可以查看定义来理解隔离。
这一点和其他类型的线程安全机制(锁、队列)有着本质的区别。根据我的观察,这绝对是并发编程中排名第一的认知误区。
swift
// 这个类型没有隔离
class MyClass {
// 这里也没有隔离
func method() {
// 这里是没有隔离的
}
func asyncMethod() async {
// 异步不能影响隔离
// 这里也没有隔离
}
}
查看定义并不总是很简单。如果说类型有父类或者遵循某些协议,那么这里有继承的情形。继承并不总是发生在同样的文件,或者同样的模块,你可能需要查阅这些资料才能获得完整认知。在实际开发中,在不是UI代码中,继承很少影响到隔离。
swift
class MyClass: SomeSupertype, SomeProtocol {
// 这里是否隔离取决于继承
func method() {
}
}
记住:隔离是在编译时就指定了。我不断重复这点是因为它非常重要,而且它经常造成混淆。
隔离有三种味道
- 无隔离
- 静态
- 动态
默认一切申明是非隔离。你必须显示改变隔离状态。
actor
类型,全局actor
,以及隔离式参数共同构成静态隔离的三种形态。全局actor
尤其普遍。对于并发需求,很多项目、甚至是大型项目,仅仅需要使用@MainActor
。重度使用并发的框架作者,可能会发现隔离参数的用途,但是我认为它们在日常开发过程中不会扮演主要的角色。
接下来,我们将聊一聊动态隔离。
在你等待的时候隔离会改变
当你看到await
关键字时, 隔离可能会改变
swift
@MainActor
func doStuff() async {
// 这里是Main Actor
await anotherFunction() // 查看anotherFunction的定义
// 返回Main Actor
}
这里同样是一个非常容易混淆的点。在其他并发系统中,运行时非常重要。但是在Swift中定义同样也很重要。
闭包可以继承隔离
这一点完全不同于类型继承。它只适用于闭包参数,并且它只出现在直接控制并发功能的API中,比如Task
。注意我们仍然遵守了这样的规则:隔离的行为仍然被定义控制。它使用了属性@_inheritActorContext
。
这令人感到困惑。
这意味者隔离不会突然改变除非你决定去改变它。当你创建了一个Task
,任务体内将继续使用创建时的隔离状态。这非常方便并且总是你想要的。你也可以选择跳出默认规则。
swift
@MainActor
class MyIsolatedClass {
func myMethod() {
Task {
// 继承隔离,MainActor
}
Task.detached {
// 显示申明非隔离状态
}
}
}
隔离适用于变量
函数并不是唯一可以被隔离的。非本地变量也可以被隔离
swift
@MainActor
class MyIsolatedClass {
static var value = 1 // 隔离在MainActor中
}
编译器从Swift 5.10 时开始检查这种隔离状态,这让很多人感到不可思议。但是这也是合理的。这里值可以被模块里的任何地方访问,所以需要以线程安全的方式来访问它们。显示隔离是一种获得这种安全性的方式,但是它绝对不是唯一的方式。
你可以退出隔离
如果有些地方不是你想要的隔离,你可以使用nonisolated
跳出隔离。这对于那些不可变且可以安全地从其他线程访问的静态常量来说也很有意义。
swift
@MainActor
class MyIsolatedClass {
nonisolated func nonIsolatedMethod() {
// 没有隔离
}
nonisolated static let someConstantSTring = "线程安全"
}
如上文所述,目前针对 Task
的隔离退出机制运作方式略有不同。不过,开发团队正在积极改变这一功能。
隔离让协议变得棘手
协议从定义上来说,可以像控制其他类型的定义一样控制隔离。
swift
//无隔离
protocol NoIsolation {
func method()
}
//隔离在MainActor
@MainActor
protocol GloballyIsolatedProtocol {
func method()
}
protocol PerMemberIsolatedProtocol {
//函数隔离在MainActor
@MainActor
func method()
}
协议使用的隔离方式,包括完全不使用隔离,可能会产生重大影响。在使用并发时,这一点尤其需要注意。如果你的代码中大量使用了协议,研究完整的并发检查是如何影响你的设计是一个非常好的主意。
(我收集了一些处理协议和并发的技巧,我还在不断发现新的问题!如果你遇到了我没有提到的问题,请告诉我。)
动态隔离
对于那些在并发出现之前构建的系统,这种情况经常会发生。一个我们可以使用的工具是动态隔离。这些 API 让我们能够以一种仅通过查看定义无法察觉的方式来表达隔离性。
swift
// 这个委托实际上总是会在 MainActor 上进行调用。
// 但是查看定义我们无法表达这个隔离情况
@MainActor
class MyMainActorClass: SomeDelegate {
nonisolated funcSomeDelegateCallback() {
// 向编译器承诺我们在运行时出于 MainActor 上
MainActor.assumeIsolated {
// 访问MainActor,包含`self`
}
}
}
SwiftUI 令人困惑
这里不是直接和语言隔离系统相关,但是它是影响了很多人的一个实际问题。SwiftUI 的隔离模型极易出错,我甚至认为它应该被修改。现在你看到一个不是 Main-Actor 隔离的SwiftUI 视图,它可能是一个bug。
UIKit 和 AppKit 均强制要求整个类型遵循 MainActor
隔离规则,因此在这方面使用起来更为简便。
我也有一些主意来处理这种情况,但是我也希望得到更多来自有经验的用户的反馈。
设计是新的,但并非不可实现
我想只需要稍加练习,你就能透彻理解隔离机制。并且我认为概念是简单的,实际上正确使用隔离非常困难。我希望我已经讲得足够多,能帮助你入门。但是如果我有什么遗漏或者错误的地方,请告诉我。并发并不容易。
既然你看到这儿了,别忘记打开警告。