这份文档介绍了一些新增与更改提案,通过异步函数和 actor 实现来达成上述目标。这些新增内容会各自分别提案,但在许多情况下它们会相互依赖。本文档则会将它们结合起来介绍。与宣言(可能描述多个可能的方向,在某些情况下会是不太可能的方向)不同,本文档描述了在 Swift 中解决并发需求的一整份计划。
这些更改最终会:
让异步编程用起来方便且清晰易懂;
提供 Swift 开发人员可以遵循的一套标准语言工具和技术;
通过更好地了解编译时的知识来提高异步代码的性能;
用 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 个问题:
仪式太多了。从根本上讲,这个函数只是调用了一个函数,转换结果并将其分配给一个属性而已。但是,队列和完成处理程序(completion handler)带来了很多额外工作,因此很难看清楚代码的核心部分。
这个额外的仪式 更容易引入错误。在完成处理程序中直接分配了 self.players 属性。它在什么线程上?不清楚。这是潜在的数据争用:这个回调可能需要在执行分配之前分派回正确的队列。也许这是由 allPlayers 处理的,但是我们无法在本地推理这段代码是否是线程安全的。
这段代码 效率低下,本来不该这样。几个函数对象需要分别分配。这些函数使用的诸如 self 之类的引用必须复制到它们里面,这需要额外的引用计数操作。这些函数可能会运行多次或根本不会运行,通常会阻止编译器避开这些副本。
此外,这些问题不可避免地纠缠在了一起。异步回调最终总是只运行一次,这意味着它们无法参与一个永久的引用周期。由于 Swift 不知道这一点,因此它要求 self 在闭包中是显式的。一些程序员通过反射性地添加 [weak self] 来回应这一点,结果增加了运行时开销和回调的仪式,因为它现在必须处理 self 为 nil 的可能性。通常,当 self 为 nil 时,此类函数会立即返回,由于可能跳过了任意数量的代码,因此更难推理其正确性。
因此,这里展示的模式是很好,但是在 Swift 中表达它们会丢失重要的结构并产生问题。解决方案是将这些模式带入语言本身。这会减少样板,并让语言来加强模式的安全性、消除错误,使程序员更有信心且更广泛地使用并发。它还会让我们能够提高并发代码的性能。
internal func refreshPlayers() async {
players = await gameSession.allPlayers().map(\.nickname)
}
关于这个示例需要注意的有:
refreshPlayers 现在是一个 async 函数。
allPlayers 也是一个 async 函数,它返回其结果而不是将其传递给一个完成处理程序。
因此,我们可以使用表达式组合直接在返回值上调用 map 函数。
await 关键字出现在调用 allPlayers 的表达式之前,表示此时的 refreshPlayers 函数可以挂起。
await 与 try 的工作原理类似,因为它只需要在可以暂停的表达式的开头出现一次,而不是直接出现在该表达式中可以挂起的每个调用之前。
显式的 self. 已从属性访问中删除,因为不需要逃逸闭包来捕获 self。
现在,对属性 allPlayers 和 players 的访问不能存在数据争用。
要了解如何实现最后一点,我们必须走出一层,研究如何使用队列来保护状态。
class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
var refreshQueue = DispatchQueue(label: "PlayerRefresh")
func refreshPlayers(completion: (() -> Void)? = nil) {
...
}
}
actor class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
func refreshPlayers() async { ... }
}
关于这个示例我们应该注意:
声明一个类为 actor,类似于给一个类一个私有队列,并通过该队列同步所有对其私有状态的访问。
因为编译器现在可以理解这种同步,所以你不能忘记使用队列来保护状态:编译器将确保你正在类的方法中的队列上运行,并且将阻止你访问这些方法之外的状态。
因为编译器负责这部分操作,所以它可以更智能地优化同步,例如当方法开始在其他 actor 上调用异步函数时。
actor 及其函数和属性之间有了这种静态关系后,我们就能够将数据强制隔离到 actor 并避免数据争用。我们静态地知道我们是否处于可以安全地访问 actor 属性的上下文中,如果不能,编译器将负责切换到这种上下文中。
在上面,我们展示了一个 actor 类,其中包含一组紧密封装的属性和代码。但是,当今我们进行 UI 编程的方式,通常会将代码分布在(你应该在单个主线程中使用的)很多类中。这个主线程仍然是一种 actor——这就是我们所谓的全局 actor。
@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 上的方法)执行异步操作,而无需进行繁琐的设置。
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 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 和异步函数是新特性。
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