前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Swift 发布路线图:更便捷、更高效且更安全

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

作者头像
深度学习与Python
发布2020-11-23 10:32:46
7600
发布2020-11-23 10:32:46
举报

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


本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-11-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 InfoQ 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档