作者 | Ben Cohen
译者 | 王强
策划 | 李俊辰
Swift 团队的目标是让 Swift 中的并发编程更加便捷、高效和安全。
这份文档介绍了一些新增与更改提案,通过异步函数和 actor 实现来达成上述目标。这些新增内容会各自分别提案,但在许多情况下它们会相互依赖。本文档则会将它们结合起来介绍。与宣言(可能描述多个可能的方向,在某些情况下会是不太可能的方向)不同,本文档描述了在 Swift 中解决并发需求的一整份计划。
这些更改最终会:
- 让异步编程用起来方便且清晰易懂;
- 提供 Swift 开发人员可以遵循的一套标准语言工具和技术;
- 通过更好地了解编译时的知识来提高异步代码的性能;
- 用 Swift 消除内存不安全性的相同手段来消除数据争用和死锁。
这些特性的引入过程将跨越多个 Swift 版本。它们将大致分为两个阶段引入。第一阶段引入 async 语法和 actor 类型。这将让用户围绕 actor 组织他们的代码,从而减少(但非消除)数据争用。第二阶段将强制执行 actor 的完全隔离、消除数据争用,并提供大量特性,以实现实施隔离所需的高效且流畅的 actor 互操作。
作为一份路线图,本文档不会像这些提案的文档那样细致。文档还讨论了第二阶段的特性,但是这一部分的详细提案将等到第一阶段得到更好的定义之后再说。
本文档没有涉及其他多个相关主题,例如异步流、并行 for 循环和分布式 actor。这些特性中有许多都是对本路线图中描述的特性的补充,且随时可能会引入。
动机:一个案例
我们今天鼓励并发的基本模式是很好的:我们告诉人们使用队列而不是锁来保护其数据,并通过异步回调而不是阻塞线程来返回慢速操作的结果。
但是手动执行这些操作是很麻烦的,且容易出错。考虑一个演示这些模式的代码片段:
代码语言:javascript复制internal func refreshPlayers(completion: (() -> Void)? = nil) {
refreshQueue.async {
self.gameSession.allPlayers { players in
self.players = players.map(.nickname)
completion?()
}
}
}
我们可以从这段代码中观察到 3 个问题:
- 仪式太多了。从根本上讲,这个函数只是调用了一个函数,转换结果并将其分配给一个属性而已。但是,队列和完成处理程序(completion handler)带来了很多额外工作,因此很难看清楚代码的核心部分。
- 这个额外的仪式 更容易引入错误。在完成处理程序中直接分配了 self.players 属性。它在什么线程上?不清楚。这是潜在的数据争用:这个回调可能需要在执行分配之前分派回正确的队列。也许这是由 allPlayers 处理的,但是我们无法在本地推理这段代码是否是线程安全的。
- 这段代码 效率低下,本来不该这样。几个函数对象需要分别分配。这些函数使用的诸如 self 之类的引用必须复制到它们里面,这需要额外的引用计数操作。这些函数可能会运行多次或根本不会运行,通常会阻止编译器避开这些副本。
此外,这些问题不可避免地纠缠在了一起。异步回调最终总是只运行一次,这意味着它们无法参与一个永久的引用周期。由于 Swift 不知道这一点,因此它要求 self 在闭包中是显式的。一些程序员通过反射性地添加 [weak self] 来回应这一点,结果增加了运行时开销和回调的仪式,因为它现在必须处理 self 为 nil 的可能性。通常,当 self 为 nil 时,此类函数会立即返回,由于可能跳过了任意数量的代码,因此更难推理其正确性。
因此,这里展示的模式是很好,但是在 Swift 中表达它们会丢失重要的结构并产生问题。解决方案是将这些模式带入语言本身。这会减少样板,并让语言来加强模式的安全性、消除错误,使程序员更有信心且更广泛地使用并发。它还会让我们能够提高并发代码的性能。
这是使用我们提案的新语法重写的代码:
代码语言:javascript复制internal func refreshPlayers() async {
players = await gameSession.allPlayers().map(.nickname)
}
关于这个示例需要注意的有:
- refreshPlayers 现在是一个 async 函数。
- allPlayers 也是一个 async 函数,它返回其结果而不是将其传递给一个完成处理程序。
- 因此,我们可以使用表达式组合直接在返回值上调用 map 函数。
- await 关键字出现在调用 allPlayers 的表达式之前,表示此时的 refreshPlayers 函数可以挂起。
- await 与 try 的工作原理类似,因为它只需要在可以暂停的表达式的开头出现一次,而不是直接出现在该表达式中可以挂起的每个调用之前。
- 显式的 self. 已从属性访问中删除,因为不需要逃逸闭包来捕获 self。
- 现在,对属性 allPlayers 和 players 的访问不能存在数据争用。
要了解如何实现最后一点,我们必须走出一层,研究如何使用队列来保护状态。
原始代码是使用 refreshQueue 保护其内部状态的类上的一个方法:
代码语言:javascript复制class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
var refreshQueue = DispatchQueue(label: "PlayerRefresh")
func refreshPlayers(completion: (() -> Void)? = nil) {
...
}
}
这是一种常见的模式:一个类,具有一个私有队列和仅应在队列上访问的某些属性。我们用一个 actor 类代替这里的手动队列管理:
代码语言:javascript复制actor class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
func refreshPlayers() async { ... }
}
关于这个示例我们应该注意:
- 声明一个类为 actor,类似于给一个类一个私有队列,并通过该队列同步所有对其私有状态的访问。
- 因为编译器现在可以理解这种同步,所以你不能忘记使用队列来保护状态:编译器将确保你正在类的方法中的队列上运行,并且将阻止你访问这些方法之外的状态。
- 因为编译器负责这部分操作,所以它可以更智能地优化同步,例如当方法开始在其他 actor 上调用异步函数时。
actor 及其函数和属性之间有了这种静态关系后,我们就能够将数据强制隔离到 actor 并避免数据争用。我们静态地知道我们是否处于可以安全地访问 actor 属性的上下文中,如果不能,编译器将负责切换到这种上下文中。
在上面,我们展示了一个 actor 类,其中包含一组紧密封装的属性和代码。但是,当今我们进行 UI 编程的方式,通常会将代码分布在(你应该在单个主线程中使用的)很多类中。这个主线程仍然是一种 actor——这就是我们所谓的全局 actor。
你可以使用一个属性将类和函数标记为与该 actor 绑定。编译器将允许你从任何地方引用这个类,但是要实际调用这个方法,你需要位于 UI actor 上。因此,如果在全局 UI actor 上执行 PlayerRefreshController 的所有动作是合适的做法,我们将这样表示:
代码语言:javascript复制@UIActor
class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
func refreshPlayers() async { ... }
}
第一阶段的提案
为了支持第一阶段,我们将在接下来的几周内提出以下提案:
- async/await:向 Swift 引入了基于协程的 async/await 模型。函数可以被定为 async,然后可以 await 其他 async 函数的结果,从而允许异步代码以更自然的“直线”形式表示。
- Task API 和结构化并发:将任务的概念引入标准库。这将涵盖用于创建分离的任务的 API、用于动态创建子任务的任务“nurseries”,以及用于取消和确定任务优先级的机制。它还基于结构化并发原理引入了基于范围的机制,以 await 来自多个子任务的值。
- Actor 和 Actor 隔离:描述了 actor 模型,该模型为并发程序提供状态隔离。这为 actor 隔离提供了基础,通过该机制可以消除潜在的数据争用。第一个阶段的提案将引入部分 actor 隔离,而将完全隔离的实现留给后续提案。
- 与 Objective-C 的并发互操作性:在 Swift 的并发特性(例如 async 函数)和 Objective-C 中基于约定的异步函数表达之间引入了自动桥接。提供了一个被选的,将 API 翻译为一个 async 函数的 Swift 版本,以及基于回调的版本,从而允许现有的异步 Objective-C API 直接用于 Swift 的并发模型。
- Async handlers:引入了将同步 actor 函数声明为异步处理程序的功能。这些函数在外部的行为类似于同步函数,但在内部的处理则类似于异步函数。这允许用传统的“通知”方法(如 UITableViewDelegate 上的方法)执行异步操作,而无需进行繁琐的设置。
actor 隔离和第二阶段
Swift 的目标是默认防止数据在突变状态下争用。实现这一目标的系统称为 actor 隔离,这是因为 actor 是该系统工作机制的核心,也是因为这一系统主要是防止受 actor 保护的状态在 actor 外部被访问。但是,即使在没有直接涉及 actor 的情况下,当并发状态的系统需要确保正确性时,actor 隔离也会限制代码。
我们打算分两个阶段引入本路线图中描述的特性:首先,引入创建异步函数和 actor 的能力;然后,强制执行 actor 完全隔离。
actor 隔离的基本思想类似于对内存独占访问的思想,并以此为基础。Swift 的并发设计旨在从 actor 的自然隔离开始,再将所有权作为补充工具,来提供一种易于使用且可组合的安全并发方法。
actor 隔离把并发面临的问题,缩小到了“确保所有普通可变内存仅由特定 actor 或任务访问”这个问题上。进一步来说就是要分析内存访问方式,以及确定谁可以访问内存。我们可以将内存分为几类:
- actor 的属性将受到该 actor 的保护。
- 不可变的内存(例如 let 常量)、本地内存(例如从未捕获的本地变量)和值组件内存(例如 struct 的属性或 enum case)已受到保护,免于数据争用。
- 不安全的内存(例如 UnsafeMutablePointer 引用的任意分配)与不安全的抽象关联。试图强制这些抽象被安全地使用是不太现实的,因为这些抽象意味着可以在必要时绕过安全的语言规则。相反,我们必须相信程序员可以正确使用它们。
- 原则上,任何地方的任何代码都可以访问全局内存(例如全局变量或静态变量),因此会受到数据争用的影响。
- 也可以从保存有对该类引用的任何代码中访问类组件内存。这意味着,尽管对该类的引用可能受到 actor 的保护,但在 actor 之间传递该引用却将其属性暴露给了数据争用。当在 actor 之间传递值时,这还包括对值类型中包含的类的引用。
actor 完全隔离 的目标是确保默认保护最后这两个类别。
第一阶段:基本 actor 隔离
第一阶段引入一些安全增强。用户将能够使用全局 actor 来保护全局变量,并将类成员转换为 actor 类来保护它们。需要访问特定队列的框架可以定义全局 actor 及其默认协议。
在此阶段将强制执行一些重要的 actor 隔离用例:
代码语言:javascript复制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,全局变量和引用类型的值仍然可能存在争用的情况:
代码语言:javascript复制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 之间传递该类型。取而代之的是,在通过边界之前,必须以某种方式克隆 / 取消共享引用。
反过来,这将允许更改默认值:
- 全局变量将需要由全局 actor 保护,或标记为“actor unsafe”。
- 类(和包含类引用的类型)将从默认的“actor unsafe”更改为“actor local”。
默认情况下,此更改将导致 源代码中断(source break),并且需要通过语言模式进行控制。从根本上并不能证明触及可变全局变量,或跨 actor 边界共享类引用的代码是安全的,并且需要进行更改以确保它(以及将来编写的代码)是安全的。希望这种中断不会造成麻烦:
- 预计应该尽量少使用全局变量,并且大多数全局变量可以由全局 actor 来保护;
- 只要没有跨 actor 边界共享类,“actor local”注释就不会影响 actor 内的代码;
- 在必须跨越边界传递引用的地方,语言应让它变得显而易见,并且简化解决方案;
- 通过进一步鼓励和简化值类型的使用,应当能减少跨 actor 边界共享类的需求;
- 两个阶段之间的过渡期会给用户时间将其代码重构为 actor 和异步函数,为完全隔离做好准备。
与第一阶段的 pitch 不同,第二阶段所需的语言特性将首先被放到 Swift 论坛的“进化讨论”部分进行讨论。这种两阶段方法的主要动力之一是,希望在迁移到完全隔离模型之前,让 Swift 用户有时间习惯异步函数和 actor。可以预期,将大型生产代码库移植到 actor 和异步函数的经验,将为强制执行完全 actor 隔离提供功能需求参考。这里的反馈会有助于第二阶段特性的讨论。
预期将在第二阶段讨论的特性包括:
- 引入类型上的 actorlocal 限制;
- 编译器支持通过 mutableIfUnique 类类型,保证正确的“写时复制”类型;
- 在通过其他某种方式处理线程安全之类的情况下,可以选择取消 actor 隔离。
概念词汇表
以下是将在整个设计中使用的基本概念,此处简述其定义。
- 同步函数 是 Swift 程序员已经习惯的一种函数:它在单个线程上运行完成,除了它调用的任何同步函数外,没有交织代码。
- 线程 是指底层平台的线程概念。平台各不相同,但是基本特征大致是一样的:真正的并发需要创建一个平台线程,但是创建和运行平台线程的开销很大。C 函数调用和普通的同步 Swift 函数都需要使用平台线程。
- 异步函数 是一种新函数,无需一路运行下去直到完成。中断会导致该函数被 挂起。异步函数可能放弃其线程的位置是 挂起点。
- 任务 是异步运行的操作。所有异步函数都作为某些任务的一部分运行。当异步函数调用另一个异步函数时,即使该调用必须更改 actor,该调用仍然是同一任务的一部分。任务是异步函数线程的近似。
- 异步函数可以创建一个 子任务。子任务继承其父任务的某些结构,包括其优先级,但可以与其并行运行。但这种并发性是有限的:创建子任务的函数必须等待其结束才能返回。
- 程序希望使用 独立任务 而不是有界子任务来发起独立的并发工作,这种并发可以维持其 spawning 上下文。
- 部分任务 是可计划的工作单元。当任务中当前执行的函数被挂起时(即这个部分任务结束),将创建一个新的部分任务以继续整个任务的工作。
- 执行器(executor) 是一种服务,它接受部分任务的提交并安排一些线程来运行它们。当前正在运行的异步函数一直都知道其正在运行的执行器。如果执行器所提交的部分任务永远不会同时运行,则称为 exclusive(排他) 执行器。
- actor 是程序的一个独立部分,可以运行代码。它一次只能运行一段代码,也就是说,它充当排他执行器。但它运行的代码可以与其他 actor 运行的代码同时执行。一个 actor 可以具有只能由该 actor 访问的保护状态。实现此目标的系统称为 actor 隔离。Swift 的长期目标是让 Swift 默认保证 actor 隔离。
- 一个 actor 类 是一个引用类型,其每个实例都是一个单独的 actor。它的受保护状态是其实例属性,其 actor 函数是它的实例方法。
- 全局 actor 是全局对象。它的受保护状态和 actor 函数可能分布在许多不同的类型上。它们可以标记一个 actor 特定的属性,Swift 在很多情况下都可以推断出该属性。
延伸阅读
https://forums.swift.org/t/swift-concurrency-roadmap/41611