作者 | Ben Cohen
译者 | 王强
策划 | 李俊辰
Swift 团队的目标是让 Swift 中的并发编程更加便捷、高效和安全。
这份文档介绍了一些新增与更改提案,通过异步函数和 actor 实现来达成上述目标。这些新增内容会各自分别提案,但在许多情况下它们会相互依赖。本文档则会将它们结合起来介绍。与宣言(可能描述多个可能的方向,在某些情况下会是不太可能的方向)不同,本文档描述了在 Swift 中解决并发需求的一整份计划。
这些更改最终会:
这些特性的引入过程将跨越多个 Swift 版本。它们将大致分为两个阶段引入。第一阶段引入 async 语法和 actor 类型。这将让用户围绕 actor 组织他们的代码,从而减少(但非消除)数据争用。第二阶段将强制执行 actor 的完全隔离、消除数据争用,并提供大量特性,以实现实施隔离所需的高效且流畅的 actor 互操作。
作为一份路线图,本文档不会像这些提案的文档那样细致。文档还讨论了第二阶段的特性,但是这一部分的详细提案将等到第一阶段得到更好的定义之后再说。
本文档没有涉及其他多个相关主题,例如异步流、并行 for 循环和分布式 actor。这些特性中有许多都是对本路线图中描述的特性的补充,且随时可能会引入。
动机:一个案例
我们今天鼓励并发的基本模式是很好的:我们告诉人们使用队列而不是锁来保护其数据,并通过异步回调而不是阻塞线程来返回慢速操作的结果。
但是手动执行这些操作是很麻烦的,且容易出错。考虑一个演示这些模式的代码片段:
internal func refreshPlayers(completion: (() -> Void)? = nil) {
refreshQueue.async {
self.gameSession.allPlayers { players in
self.players = players.map(\.nickname)
completion?()
}
}
}
我们可以从这段代码中观察到 3 个问题:
此外,这些问题不可避免地纠缠在了一起。异步回调最终总是只运行一次,这意味着它们无法参与一个永久的引用周期。由于 Swift 不知道这一点,因此它要求 self 在闭包中是显式的。一些程序员通过反射性地添加 [weak self] 来回应这一点,结果增加了运行时开销和回调的仪式,因为它现在必须处理 self 为 nil 的可能性。通常,当 self 为 nil 时,此类函数会立即返回,由于可能跳过了任意数量的代码,因此更难推理其正确性。
因此,这里展示的模式是很好,但是在 Swift 中表达它们会丢失重要的结构并产生问题。解决方案是将这些模式带入语言本身。这会减少样板,并让语言来加强模式的安全性、消除错误,使程序员更有信心且更广泛地使用并发。它还会让我们能够提高并发代码的性能。
这是使用我们提案的新语法重写的代码:
internal func refreshPlayers() async {
players = await gameSession.allPlayers().map(\.nickname)
}
关于这个示例需要注意的有:
要了解如何实现最后一点,我们必须走出一层,研究如何使用队列来保护状态。
原始代码是使用 refreshQueue 保护其内部状态的类上的一个方法:
class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
var refreshQueue = DispatchQueue(label: "PlayerRefresh")
func refreshPlayers(completion: (() -> Void)? = nil) {
...
}
}
这是一种常见的模式:一个类,具有一个私有队列和仅应在队列上访问的某些属性。我们用一个 actor 类代替这里的手动队列管理:
actor class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
func refreshPlayers() async { ... }
}
关于这个示例我们应该注意:
actor 及其函数和属性之间有了这种静态关系后,我们就能够将数据强制隔离到 actor 并避免数据争用。我们静态地知道我们是否处于可以安全地访问 actor 属性的上下文中,如果不能,编译器将负责切换到这种上下文中。
在上面,我们展示了一个 actor 类,其中包含一组紧密封装的属性和代码。但是,当今我们进行 UI 编程的方式,通常会将代码分布在(你应该在单个主线程中使用的)很多类中。这个主线程仍然是一种 actor——这就是我们所谓的全局 actor。
你可以使用一个属性将类和函数标记为与该 actor 绑定。编译器将允许你从任何地方引用这个类,但是要实际调用这个方法,你需要位于 UI actor 上。因此,如果在全局 UI actor 上执行 PlayerRefreshController 的所有动作是合适的做法,我们将这样表示:
@UIActor
class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
func refreshPlayers() async { ... }
}
第一阶段的提案
为了支持第一阶段,我们将在接下来的几周内提出以下提案:
actor 隔离和第二阶段
Swift 的目标是默认防止数据在突变状态下争用。实现这一目标的系统称为 actor 隔离,这是因为 actor 是该系统工作机制的核心,也是因为这一系统主要是防止受 actor 保护的状态在 actor 外部被访问。但是,即使在没有直接涉及 actor 的情况下,当并发状态的系统需要确保正确性时,actor 隔离也会限制代码。
我们打算分两个阶段引入本路线图中描述的特性:首先,引入创建异步函数和 actor 的能力;然后,强制执行 actor 完全隔离。
actor 隔离的基本思想类似于对内存独占访问的思想,并以此为基础。Swift 的并发设计旨在从 actor 的自然隔离开始,再将所有权作为补充工具,来提供一种易于使用且可组合的安全并发方法。
actor 隔离把并发面临的问题,缩小到了“确保所有普通可变内存仅由特定 actor 或任务访问”这个问题上。进一步来说就是要分析内存访问方式,以及确定谁可以访问内存。我们可以将内存分为几类:
actor 完全隔离 的目标是确保默认保护最后这两个类别。
第一阶段:基本 actor 隔离
第一阶段引入一些安全增强。用户将能够使用全局 actor 来保护全局变量,并将类成员转换为 actor 类来保护它们。需要访问特定队列的框架可以定义全局 actor 及其默认协议。
在此阶段将强制执行一些重要的 actor 隔离用例:
actor class MyActor {
let immutable: String = "42"
var mutableArray: [String] = []
func synchronousFunction() {
mutableArray += ["syncFunction called"]
}
}
extension MyActor {
func asyncFunction(other: MyActor) async {
// allowed: an actor can access its internal state, even in an extension
self.mutableArray += ["asyncFunction called"]
// allowed: immutable memory can be accessed from outside the actor
print(other.immutable)
// error: an actor cannot access another's mutable state
otherActor.mutableArray += ["not allowed"]
// error: either reading or writing
print(other.mutableArray.first)
这些强制不会破坏源码,因为 actor 和异步函数是新特性。
第二阶段:完全 actor 隔离
即使引入了 actor,全局变量和引用类型的值仍然可能存在争用的情况:
class PlainOldClass {
var unprotectedState: String = []
}
actor class RacyActor {
let immutableClassReference: PlainOldClass
func racyFunction(other: RacyActor) async {
// protected: global variable protected by a global actor
safeGlobal += ["Safe access"]
// unprotected: global variable not in an actor
racyGlobal += ["Racy access"]
// unprotected: racyProperty is immutable, but it is a reference type
// so it allows access to unprotected shared mutable type
other.takeClass(immutableClassReference)
}
func takeClass(_ plainClass: PlainOldClass) {
plainClass.unprotectedState += ["Racy access"]
}
}
在第一阶段,我们打算保留 Swift 当前的默认行为:全局变量和类组件内存不受数据争用的影响。因此,“actor unsafe”是该内存的默认。因为这是当前 Swift 的默认设置,所以启用第一阶段是不会破坏源代码的。
在第二阶段,引入更多特性后将提供处理完全隔离 actor 的全套工具。其中最重要的是将类型限制为“actor local”的能力。当类型标记为 actor local 时,编译器将阻止在 actor 之间传递该类型。取而代之的是,在通过边界之前,必须以某种方式克隆 / 取消共享引用。
反过来,这将允许更改默认值:
默认情况下,此更改将导致 源代码中断(source break),并且需要通过语言模式进行控制。从根本上并不能证明触及可变全局变量,或跨 actor 边界共享类引用的代码是安全的,并且需要进行更改以确保它(以及将来编写的代码)是安全的。希望这种中断不会造成麻烦:
与第一阶段的 pitch 不同,第二阶段所需的语言特性将首先被放到 Swift 论坛的“进化讨论”部分进行讨论。这种两阶段方法的主要动力之一是,希望在迁移到完全隔离模型之前,让 Swift 用户有时间习惯异步函数和 actor。可以预期,将大型生产代码库移植到 actor 和异步函数的经验,将为强制执行完全 actor 隔离提供功能需求参考。这里的反馈会有助于第二阶段特性的讨论。
预期将在第二阶段讨论的特性包括:
概念词汇表
以下是将在整个设计中使用的基本概念,此处简述其定义。
延伸阅读
https://forums.swift.org/t/swift-concurrency-roadmap/41611