译:Swift 隔离机制的直观理解

142 阅读10分钟

原文:Swift Isolation Intuition

作者: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

在这里,我得小心挑选使用的词语。对于同步代码,隔离不会发生改变,但是当你使用awaitasync 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)") 
	}
}

此处逻辑较为复杂,我们需要逐步拆解分析。

  1. 首先我们检查下var定义。没有隔离。
  2. 接下来,我们检查下类型定义。也没有隔离。
  3. var遵守了协议吗。我们得检查。
  4. 协议定义了一个MainActor隔离的body

全局Actor推断

整个过程较为复杂,根源在于全局Actor推断机制。这边文章深层次解析了这些规则。但我提炼出一个简单的规则。

规则3:协议指定隔离

View的例子中,协议指定了一个特定成员的隔离。但是把隔离完整的应用到协议中是可能的。我想要强调这点,开始的时候,我也很困惑如何去使用它。

@MyCustomNonMainGlobalActor
protocol CustomActorStuff {
	...
}

extension UIViewController: CustomActorStuff {
  // 但是在这里所有都被推断成MyCustomNonMainGlobalActor
}

这里是规则1的结果:定义胜出。尽管协议可以指定隔离,但是你不能使用它改变隔离。

算法

这部分是来帮助你培养一些对于隔离的直观感受。我们需要学习编译器使用的算法。在继承和协议下,它变得很复杂。但是实际的算法可能非常简单。

  1. 检查定义

  2. 检查封装类型

  3. 有没有协议参与

    3.1 检查它的定义

    3.2 检查它的封装类型

我发现全局Actor推断只是听起来很复杂。实际上,以我自身经验,这通常是学完了就可以使用的知识。

View.bodyMainActor,搞定。

没有推断的情形

我想要通过再研究一下SwiftUIView,来帮助你们巩固这个过程是如何运行的。但是,这次我将考虑两个棘手的案例。

// 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

  1. 定义没有隔离
  2. 包装的类型没有隔离
  3. 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 的工作原理往往是开发者能够最终形成本能认知的知识点。这对于认知隔离机制仍然适用。它需要一些实践。我非常希望这个方法有用。但是你如果感到没有用或者困惑的话,请告诉我。