Swift 发布路线图:更便捷、更高效且更安全

2020-11-23 10:32:46 浏览数 (1)

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


0 人点赞