Actors

2022-01-19 10:48:25 浏览数 (3)

  • 提议:SE-0306
  • 作者:John McCall, Doug Gregor, Konrad Malawski, Chris Lattner
  • 审核主管:Joe Groff
  • 状态: 在 Swift 5.5 已实现 验收链接
  • 关键点:采纳提议, 第一次 Review, 第二次 Review
  • 实现:在标记-Xfrontend -enable-experimental-concurrency后的 最近主快照 中可以找到

介绍

Swift 并发模型旨在提供一种安全编程模型,可以静态检测数据竞争和其他常见的并发错误。结构化并发 提议引入了一种定义并发任务的方法,并为函数和闭包提供数据竞争(data-race)安全性。此模型适用于许多常见的设计模式,包括并行映射和并发回调模式,但仅限于处理闭包里捕获的状态。

Swift中的类提供一种机制来声明可变状态,并可以在整个程序中共享该状态。但是类要通过易出错的手动同步方式来避免数据竞争,这很难在并发程序内正确使用。我们希望能够使用共享可变状态的能力,同时仍然提供对数据竞争和其他常见并发错误的静态检测。

参与者模型 定义名为 actors 的实体, 这些实体非常适合上述任务。Actors 允许程序员在并发作用域内声明一堆状态,并可以在这些状态上定义多个操作。每个 actor 通过数据隔离来保护自身数据。确保在指定时间内只有单个线程访问数据,哪怕有很多程序并发请求 actor。作为 Swift 并发模型的一部分,actors 提供与结构化并发相同的竞争和内存安全属性,但也提供了 Swift 其他显式声明类型中熟悉的抽象和重用特性。

Swift-evolution 关键点时间线:

  • 节点1
  • 节点2
  • 节点3
  • 节点4
  • 节点5
  • 节点6
  • 第一次审核

解决方案

Actors

本提议把 actor 引入到 Swift 中。actor 是引用类型,它保护对其可变状态的访问,使用actor关键字声明:

代码语言:swift复制
actor BankAccount {
  let accountNumber: Int
  var balance: Double

  init(accountNumber: Int, initialDeposit: Double) {
    self.accountNumber = accountNumber
    self.balance = initialDeposit
  }
}

跟其他 Swift 类型类似, actor 也可以有初始化函数,方法,属性以及下标。而且可以扩展遵守协议,可以是泛型的,也可以与泛型一起使用。

最主要不同是 actor 保护它们的状态不受数据竞争的影响。Swift 编译器通过一组对 actor 及其实例成员使用方式的限制,静态强制执行此操作。这种限制统称为 actor isolation

Actor 隔离(Actor isolation)

Actor 隔离是关于 actor 如何保护它们的可变状态。对 actor 来说,该保护的主要机制是通过仅允许其存储的实例属性在self上直接访问。

代码语言:swift复制
extension BankAccount {
  enum BankError: Error {
    case insufficientFunds
  }
  
  func transfer(amount: Double, to other: BankAccount) throws {
    if amount > balance {
      throw BankError.insufficientFunds
    }

    print("Transferring (amount) from (accountNumber) to (other.accountNumber)")

    balance = balance - amount
    other.balance = other.balance   amount  // error: actor-isolated property 'balance' can only be referenced on 'self'
  }
}

如果BankAccount是类,transfer(amount:to:)方法会正确运行,但是如果在并发代码运行,没有额外加锁机制的话,该方法存在数据竞争问题。

对于 actor,尝试引用other.balance会触发编译器错误,因为balance只能在self上引用。报错信息表明balanceactor-isolated,意味着它只能直接从它绑定或"被隔离的"的特定 actor 内部访问。在当前情况下,它是被self引用的BankAccount实例。在 actor 实例中所有的声明,包括存储和计算实例属性(比如balance),实例方法(比如transfer(amount:to:))和实例下标默认都是 actor-isolated。同一 actor 实例中的 actor-isolated 声明可以自由互相引用。任何非 actor-isolated 声明都是非隔离态,不能同步访问任何 actor-isolated 声明。

从 actor 外部对 actor-isolated 声明进行引用称为跨actor引用。这种引用可以通过两种方式之一进行。第一种,在定义 actor 的同一模块中,允许对某个不可变状态进行跨actor引用,因为一旦 actor 初始化完成,该不可变状态永远不会改变(无论从外部还是内部调用),所以这里在定义时就杜绝了数据竞争。基于这个规则可以引用other.accountNumber,因为accountNumber是通过 let 声明,并且具有值语义类型 Int

第二个形式的允许跨actor引用是通过异步函数调用执行。这种异步函数调用被转化为消息,请求 actor 在安全的情况下执行相应的任务。这些消息被存储在 actor 的"邮箱",发起异步函数调用的调用方可能会挂起,直到 actor 能够处理邮箱中对应的消息。actor 有序处理它邮箱中的消息,所以某个给定的 actor 永远不会存在两个并发执行的任务运行 actor-isolated 代码。这确保在 actor-isolated 可变状态上不会存在数据竞争,因为在能够访问 actor-isolated 状态的任何代码中,不存在并发。例如,如果我们想把存款存到账户account,我们可以在另一个 actor 中调用deposit(amount:),在另一个 actor 中,该调用会变成一条消息存在它的邮箱里,并且调用方会挂起。当 actor 处理消息时,它最终会处理存款相关的消息,当在 actor 隔离域内没有其他代码可执行,actor 会执行对应的调用。

实现过程注意:在实现级别上,消息是异步调用的部分任务(在 结构化并发 中描述),并且每个 actor 实例包含自己的串行执行器。串行执行器负责有序运行这部分任务。这跟串行 DispatchQueue 概念相似,但 actor 运行时中实际源码实现使用了更轻量级的实现,该实现利用了 Swift 的async函数。

编译期间 actor 隔离检查行为确定对 actor-isolated 各声明的引用是否是跨actor引用,并且确保这些引用使用上面提到两种允许的机制之一。最终确保 actor 外的代码不影响 actor 的可变状态。

基于上述,我们可以实现一个transfer(amount:to:)函数的正确版本:

代码语言:swift复制
extension BankAccount {
  func transfer(amount: Double, to other: BankAccount) async throws {
    assert(amount > 0)

    if amount > balance {
      throw BankError.insufficientFunds
    }

    print("Transferring (amount) from (accountNumber) to (other.accountNumber)")

    // Safe: this operation is the only one that has access to the actor's isolated
    // state right now, and there have not been any suspension points between
    // the place where we checked for sufficient funds and here.
    balance = balance - amount
    
    // Safe: the deposit operation is placed in the `other` actor's mailbox; when
    // that actor retrieves the operation from its mailbox to execute it, the
    // other account's balance will get updated.
    await other.deposit(amount: amount)
  }
}

deposit(amount:)操作需要涉及不同 actor 状态,所以必须异步触发该函数。此方法本身可以实现为async:

代码语言:swift复制
extension BankAccount {
  func deposit(amount: Double) async {
    assert(amount >= 0)
    balance = balance   amount
  }
}

但是实际上该方法没有必要是async:它没有异步调用(缺少await)。因此这里定义为同步函数会更好:

代码语言:swift复制
extension BankAccount {
  func deposit(amount: Double) {
    assert(amount >= 0)
    balance = balance   amount
  }
}

同步 actor 函数可以在自身同步调用,但是对该方法的跨actor引用需要异步调用。transfer(amount:to:)函数异步(在另一个上)调用上述函数,下列的passGo同步(在隐式self上)调用它:

代码语言:swift复制
extension BankAccount {
  // Pass go and collect $200
  func passGo() {
    self.deposit(amount: 200.0)  // synchronous is okay because `self` is isolated
  }
}

允许把对 actor 属性跨actor引用当作异步调用,只要引用是只读访问:

代码语言:swift复制
func checkBalance(account: BankAccount) async {
  print(await account.balance)   // okay
  await account.balance = 1000.0 // error: cross-actor property mutations are not permitted
}

原因:可以支持跨actor属性设置操作。但是,无法合理支持跨actor的inout操作,因为在 "get" 和 "set" 间有一个隐式挂起点,可以引入有效的竞争条件。此外,如果需要同时更新两个属性来维护一个不变量,则异步设置属性可能更容易无意中破坏不变量。

从模块外引用,必须从 actor 外部异步引用不可变 let 声明。例如:

代码语言:swift复制
// From another module
func printAccount(account: BankAccount) {
  print("Account #(await account.accountNumber)")
}

这保留了定义BankAccount的模块在不中断客户程序的情况下将let演变为var的能力,这是 Swift 一直维护的特性:

代码语言:swift复制
actor BankAccount { // version 2
  var accountNumber: Int
  var balance: Double  
}

只有模块内的代码需要改变账户的accountNumber属性;现有客户程序已经使用异步访问,并且不会受到影响。

跨actor引用和Sendable类型

SE-0302 引入了Sendable协议。遵守Sendable协议的类型值可以安全在并发执行的代码中共享(跨并发代码执行)。现在有许多类型通过该协议工作:值语义类型比如IntString,值语义集合比如[String][Int: String],不可变类,在内部执行自己同步的类(比如并发 hash 表),等等。

由于 actor 保护它们的可变状态,所以 actor 实例可以在并发执行代码之间自由共享,actor 自身会在内部保持同步操作。因此,每个 actor 类型隐式遵守Sendable协议。

所有跨actor引用必须使用在不同并发执行代码之间共享的类型值。举个例子,BankAccount包括一个拥有者列表,每个拥有者使用Person类来模型化:

代码语言:swift复制
class Person {
  var name: String
  let birthDate: Date
}

actor BankAccount {
  // ...
  var owners: [Person]

  func primaryOwner() -> Person? { return owners.first }
}

primaryOwner函数能够从其他 actor 异步调用,并且也可以从任何地方修改Person实例:

代码语言:swift复制
if let primary = await account.primaryOwner() {
  primary.name = "The Honorable "   primary.name  // problem: concurrent mutation of actor-isolated state
}

即使是非可变的访问也容易出问题,因为当原始调用尝试访问它的同时,在 actor 内部可以修改 person 类的name属性。为了防止这种 actor-isolated 状态并发可变性的可能,所有跨actor的引用只能包含遵守Sendable协议的类型。对于某个跨actor的异步调用,其参数和结果类型都必须遵守Sendable协议。对于某个跨actor的不可变属性引用,该属性类型必须遵守Sendable协议。通过坚持所有跨actor引用只能使用Sendable类型(遵守该协议的类型),我们可以确保对共享可变状态的引用只会在 actor 隔离域之内。另外,编译器会为这类问题提供诊断错误信息。例如,对account.primaryOwner()的调用会出现如下错误:

代码语言:txt复制
error: cannot call function returning non-Sendable type 'Person?' across actors

注意primaryOwner()函数仍然可以用 actor-isolated 代码使用。例如我们定义一个获取所有者名字的函数:

代码语言:swift复制
extension BankAccount {
  func primaryOwnerName() -> String? {
    return primaryOwner()?.name
  }
}

primaryOwnerName()在 actors 间异步调用很安全,因为String(包括String?)遵守Sendable协议。

闭包

只有当我们能确保可能与 actor-isolated 代码发生并发执行操作的代码是非隔离的时候,对跨actor引用的限制才有效。例如,下面例子中的函数是调度月底的生成报告:

代码语言:swift复制
extension BankAccount {
  func endOfMonth(month: Int, year: Int) {
    // Schedule a task to prepare an end-of-month report.
    // detach 已经遗弃了,使用 Task.detached 代替。这里保留原官网的样例模版
    detach {
      let transactions = await self.transactions(month: month, year: year)
      let report = Report(accountNumber: self.accountNumber, transactions: transactions)
      await report.email(to: self.accountOwnerEmailAddress)
    }
  }
}

使用detach创建的任务与所有其他代码同时运行。如果我们传进detach的闭包是 actor-isolated,此时将给BankAccount的可变状态引入数据竞争。actor 通过指定一个@Sendable的 closure(在 Sendable and @Sendable closures 中提及,在 Structured Concurrency 中detach的定义中使用)始终是非隔离。因此,需要异步访问任何 actor-isolated 声明。

@Sendable的闭包无法逃逸它形成的并发域。因此,如果闭包内部由 actor-isolated 上下文形成,它也是 actor-isolated。这点很有用,比如当我们调用序列算法像forEach,会同步调用它提供的闭包:

代码语言:swift复制
extension BankAccount {
  func close(distributingTo accounts: [BankAccount]) async {
    let transferAmount = balance / accounts.count

    accounts.forEach { account in                        // okay, closure is actor-isolated to `self`
      balance = balance - transferAmount            
      await account.deposit(amount: transferAmount)
    }
    
    await thief.deposit(amount: balance)
  }
}

某个非@Sendable的闭包如果通过 actor-isolated 上下文组成,则它也是 actor-isolated。反之,则是非隔离的。上面例子可以理解为:

  • 传给detach的闭包是非隔离的,因为函数传入一个@Sendable函数。
  • 传给forEach的闭包对self是 actor-isolated,因为它传了非@Sendable函数。

Actor 可重入性

actor-isolated 函数是可重入的。当 actor-isolated 函数挂起时,重入性允许 actor-isolated 函数恢复之前,在其上执行其他工作。我们称之为交叉。重入性消除两个 actor 互相依赖的死锁现象,通过不阻塞在 actor 中的工作,为更好的调度高优先级任务提供机会,来提高整体性能。然而,这意味着当交叉的任务改变状态时, actor-isolated 状态可以在await中改变,这意味着开发人员必须确保在等待中不破坏不变量。通常来说,这就是异步调用需要await的原因,因为当调用挂起时,各种不同的状态(比如全局状态)都可能被改变。

本节通过示例探讨可重入性问题,这些示例说明了可重入和不可重入 actor 的优点和问题,并解决了可重入 actor 的问题。备选方案提供了对可重入性提供更多控制的潜在未来方向,包括非重入性 actor 和任务链可重入性(在下面"未来方向"一节会讨论)。

与可重入 actor 交叉执行

可重入性意味着异步 actor-isolated 函数的执行可能会在挂起点上发生交叉行为,这会导致在用这些 actor 编程时会增加复杂度,因为如果他后面的代码依赖于一些在挂起前可能已经改变的不变量,那我们必须仔细检查每个挂起点。

交叉执行仍然遵守 actor 的"单线程概念",即,在任何给定 actor 上,都不会同时执行两个函数。但是它们可能在某个挂起点交叉。从广义上这意味着可重入 actor 是线程安全的,但不会自动防止仍然可能发生的高级竞争,这可能会使执行异步函数所依赖的不变量失效。为了进一步描述该实现,可以看下面这个 actor 示例,它描述的是"某人想出办法,告诉朋友后返回"。

代码语言:swift复制
actor Person {
  let friend: Friend
  
  // actor-isolated opinion
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                       // <1>
    await friend.tell(opinion, heldBy: self)  // <2>
    return opinion // 


	

0 人点赞