作者:Matt
为了更好的使用Swift并发,你必须学会以隔离的思维来思考。它是编译器用来推断和防止数据竞争的最基本的机制。所有的变量和函数都会有隔离。也就是说,隔离和我们使用过的其他同步机制有着很大的区别。现在我有了更多实践经验,我经常感觉它(隔离机制)用起来很自然。但是达到这种状态,我们得花足够的时间。而且,兄弟们,在这一路上我可没少走弯路。
Self
让我们从self开始吧。很熟悉是吧!对于self,你可能感觉非常简单,但是这里面其实有很多门道。
func topLevelFunction() {
self // 错误: 找不到`self`
}
变量self只存在于类型函数。
struct Person {
let name: String
func whoAmI() {
print(self.name)
}
}
你有没有想过self是从哪里来的。函数whoAmI没有参数。我不知道Swift内部如何实现的。但是,我们可以像这样用一个顶层函数来建模。
func person_whoAmI(_ instance: Person) {
print(instance.name)
}
虽然不是很完美,但是这个版本大致相同。实际上,很多C API都采用这种模式来实现对象概念的模拟。和很多语言一样,Swift 加了一点语法和魔法让你更容易地使用结构体。并且你大概对这些概率都感到很舒适。
你可以用思考self的方式来思考隔离机制。
隔离参数
即使完全不涉及隔离参数,你仍然可以广泛使用 Swift 并发功能。下面是一个使用隔离参数的例子。
func usesIsolatedParams(_ actor: isolated any Actor) {
print("I'm isolated to \(actor)")
}
是的,actor类型非常复杂。但是,我很希望你把注意力放在函数形态上。它和上面的person_whoAmI非常像。它仅仅是一个带了一个参数的函数。尽管我们并不总是像这样使用隔离,但是我认为这样明确的展示出来非常有意思。
我也希望你留意actor参数,它用来定义了函数的隔离域,在函数体内不能更改。这是理解隔离机制的一个关键点。隔离在同步代码中不会改变。
Async
在这里,我得小心挑选使用的词语。对于同步代码,隔离不会发生改变,但是当你使用await和async let时,它会短暂的发生改变。
func usesIsolatedParams(_ actor: isolated any Actor) async {
print("I'm isolated to \(actor)")
// 同步代码,隔离不会发生改变
stuff()
things()
work()
// 这里可能会发生变化
await someAsyncFunction()
print("back to \(actor)")
// 这里可能会发生变化
async let value = anotherAsyncFunction()
print("and back again to \(actor)")
}
对于所有同步代码,隔离不会发生改变。对于在异步函数中同步的部分,隔离也不会发生改变。但是只要你调用异步函数,它可能会发生变化。
事实上,一个方法的定义完全控制了隔离。这和调用者是不是被隔离没有关系。它的工作原理和队列或者锁完全不一样,它值得我们花点时间去思考。
一旦你消化了这点,我们可以把所有情形提取出两个规则:
规则1:隔离由定义决定
规则2: 异步调用可能会改变隔离
追踪隔离机制
更新:SwiftUI 已经在Xcode16改了SDK。这一节是在View只能使用@MainActor装饰body时写的。请保持注意。
规则2保证了隔离是否可以变化。规则1决定了使用隔离的位置。并且,规则听起来很简单。但是当协议和继承参与进来的时候,隔离的普遍形式,全局actor中的MainActor,能影响定义。因为他们总是在别的文件和模块中,并不是很明显。
为了描述这一点,我将使用SwiftUI。我对SwiftUI的View类型如何处理隔离并不着迷。但是View是很多开发者熟悉的类型,并且正因其足够棘手,反而成为了绝佳的教学案例。
struct MyView: View {
var body: some View {
Text("hello: \(self)") // what is the isolation here?
}
}
看,一个self引用。可能并不需要花很长时间让你了解self。但每个程序员的成长过程中,都必然经历过需要反复推敲才能理解的阶段。
隔离就是这样的!
如果了解self是如何工作的,这是因为你了解了编译器使用的算法。甚至到了你无需刻意考虑的程度。尽管你已经很熟悉,我将讲述这里的隔离是如何发生的。
让我们把View的定义也带进来,我们可以看到所有的东西。记住,我们的目标是确定body属性的隔离。
protocol View {
associatedtype Body : View
// 4 - 协议成员
@ViewBuilder @MainActor
var body: Self.Body { get }
}
// 3 - 遵守协议
// 2 - 类型
struct MyView: View {
// 1 - 定义
var body: some View {
// 这里的隔离是怎样的
Text("hello \(self)")
}
}
此处逻辑较为复杂,我们需要逐步拆解分析。
- 首先我们检查下
var定义。没有隔离。 - 接下来,我们检查下类型定义。也没有隔离。
- 是
var遵守了协议吗。我们得检查。 - 协议定义了一个
MainActor隔离的body
全局Actor推断
整个过程较为复杂,根源在于全局Actor推断机制。这边文章深层次解析了这些规则。但我提炼出一个简单的规则。
规则3:协议指定隔离
在View的例子中,协议指定了一个特定成员的隔离。但是把隔离完整的应用到协议中是可能的。我想要强调这点,开始的时候,我也很困惑如何去使用它。
@MyCustomNonMainGlobalActor
protocol CustomActorStuff {
...
}
extension UIViewController: CustomActorStuff {
// 但是在这里所有都被推断成MyCustomNonMainGlobalActor
}
这里是规则1的结果:定义胜出。尽管协议可以指定隔离,但是你不能使用它改变隔离。
算法
这部分是来帮助你培养一些对于隔离的直观感受。我们需要学习编译器使用的算法。在继承和协议下,它变得很复杂。但是实际的算法可能非常简单。
-
检查定义
-
检查封装类型
-
有没有协议参与
3.1 检查它的定义
3.2 检查它的封装类型
我发现全局Actor推断只是听起来很复杂。实际上,以我自身经验,这通常是学完了就可以使用的知识。
View.body 是 MainActor,搞定。
没有推断的情形
我想要通过再研究一下SwiftUI的View,来帮助你们巩固这个过程是如何运行的。但是,这次我将考虑两个棘手的案例。
// 3 - 遵守协议
// 2 - 类型
struct MyView: View {
var body: some View {
// we know this is MainActor
Text("\(formalGreeting) \(self)")
}
// 1 - 定义
func formalGreeting() -> String {
// 这里是什么情况?
return "hello"
}
func lessFormalGreeting() async -> String {
// 这里呢?
return "sup"
}
}
因为SwiftUI只隔离body,跟踪这两个函数的隔离状态是非常直接的。
对于formalGreeting
- 定义没有隔离
- 包装的类型没有隔离
View协议没有影响到这个函数
对的,这里没有隔离,实际无隔离情形是非常普遍的。但是,规则2仍然适用。同步代码不会改变隔离。所以,这个方法没有隔离。幸运的是,编译器已经在这里提供安全保证,防止我们做一些不安全的操作。
现在,lessFormalGreeting情况如何?我们唯一的改动就是将函数改为异步。但是算法仍然起作用。这个函数仍然是非隔离的。使函数成为异步函数不会改变函数的隔离状态
闭包
还有最后一个关键点需要讨论。对于隔离,闭包和函数行为非常接近。规则1和规则2仍然适用。规则3没有意义,这是因为闭包不能遵守协议。但是我们还有一个问题。当使用并发的时候,你可能需要使用Task API。作为并发系统的组成部分,需要特殊对待闭包。
让我们修改下上面的视图。我们想要弄清楚在Task体内隔离是如何起作用的。
struct MyView: View {
var body: some View {
// 我们已经知道 MainActor
Text("\(formalGreeting) \(self)")
}
func formalGreeting() -> String {
Task {
// ...ummmm...
}
}
}
弄清楚Task的隔离机制,我们得先了解调用处的隔离状态。我们知道怎么做,我们只不过再次使用了算法。
- formalGreeting 没有隔离
MyView没有隔离- 应用了
body的隔离了吗?
我们可以看到formalGreeting是从body里调用的,并且我们也知道这是MainActor隔离。但是,谨记隔离是编译时结构,这一点非常重要。这和运行时无关。这意味着formalGreeting是无隔离的,即便实际上他会运行在MainActor。但是,我们得了解更多。
Task的参数定义使用了一些魔法。Task使用的闭包使用了一个特殊的属性-@_inheritActorContext。此修改使Task闭包体继承外部作用域的隔离状态。那意味着在Task外部的隔离不会改变内部的隔离。非常棒,但是它并没有违反我们的算法。
Task的隔离和包含它的函数的隔离是一样的。在这个例子中,Task体内无隔离。
然而,同样的事情对于Task.detached不适用。
truct MyView: View {
var body: some View {
// Main Actor
Text("\(formalGreeting) \(self)")
}
@MainActor
func formalGreeting() -> String {
Task.detached {
// ...soooo...
}
}
}
我重新修改了代码。我加函数formalGreeting加了一个显示隔离,并且我使用了Task.detached。这个API将阻止隔离继承。
尽管包含它的函数使用了@MainActor, 这个Task仍然是无隔离的。
显示闭包隔离
关于闭包,我还得提到另一个事。由于闭包本质上是函数+定义的复合体,它们还具备另一项能力:可以直接内联声明隔离状态。
考察这段代码:
struct MyView: View {
var body: some View {
// MainActor
Text("\(formalGreeting) \(self)")
}
func formalGreeting() -> String {
Task { @MainActor in
// ...okaaay...
}
}
}
在这个例子中,formalGreeting 回到了无隔离的状态。并且通过继承,我们知道Task将会是无隔离。但是我们可以顶部添加显示隔离。这里起作用吗?
规则2:隔离只有通过异步函数调用才发生改变。
这里并没有违反这个规则,因为Task引入一个异步上下文。但是,强调一下,看一个不使用Task的例子:
// 无隔离函数
func takesAClosure(_ block: () -> Void) {
// 不会运行在MainActor
block()
}
func usesClosure() {
takesAClosure { @MainActor in
}
}
我们闭包的定义说明它是MainActor隔离,但是参数的定义说它不是。编译器正确的捕捉到了不一致情况。这里没有异步参与,所以隔离不会发生改变。
关键结论在于:Task API 的设计确实独特,但这种独特性最终会带来令人惊喜的直觉化使用体验。
需要特别说明的是:在 Swift 6 中,闭包可能会变得更强大且更符合直觉。
实践出真知
在这篇博客中我提到了一些知识点。它的篇幅比我想象的要长。在偶然发现这个与self类比后,我确信它能有效帮助开发者建立对隔离机制的直觉理解。和所有精妙的类比一样,这个模型也会深究时失效。实际上二者在多数场景下表现截然不同。
尽管它有缺陷,我认为概念依然有用。掌握 self 的工作原理往往是开发者能够最终形成本能认知的知识点。这对于认知隔离机制仍然适用。它需要一些实践。我非常希望这个方法有用。但是你如果感到没有用或者困惑的话,请告诉我。