【译】SE-0306 Actors

原文链接:SE-0306 Actors

简介

Swift 并发模型计划提供一个安全的编程模型,静态地检测 data-races 和其他常见的并发错误。结构化并发提案引入了一种定义并发任务的方法,并为函数和闭包提供了安全的 data-race。这个模型适用于许多常见的设计模式,包括像 parallel maps 和并发回调模式,但只限于处理由闭包捕获的状态。

Swift 包含了 class,它提供了一种机制来声明整个程序共享的 mutable 状态。然而,class 在并发程序中难以正确使用,需要手动同步来避免 data race,很容易出错。我们希望提供这么一种功能,在使用共享的 mutable 状态的同时,仍然提供对 data race 和其他常见并发 bug 的静态检测。

Actor 模型定义了称为 actors 的实体,这些实体非常适合这项任务。Actor 允许你,作为一个程序员,声明一系列的状态,这些状态由一个 concurrency domain 持有,并且可以定义多个对其的操作。每个 actor 通过 data isolation 来保护自己的数据,确保在给定时间内只有一个线程访问它的数据,即使许多用户同时向 actor 发出请求。作为 Swift 并发模型的一部分,actor 提供了与结构化并发相同的 race 和内存安全属性,但也提供了 Swift 中其他显式声明的类型所享有的熟悉的抽象和复用的特性。

Swift-evolution threads:

解决方案

Actors

这项提案将引入 actors。Actor 是一种引用类型,可以保护它的 mutable state 的访问,通过关键字 actor 引入:

actor BankAccount {
  let accountNumber: Int
  var balance: Double

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

就像其它的 Swift 类型,actor 可以有构造器、方法、属性和下标。它们可以被扩展并遵循协议,可以是泛型,也可以与泛型一起使用。

最主要的区别是,actor 会保护其状态不受 data-race 影响。这一点会由 Swift 编译器静态地强制执行,通过对 actor 及其实例成员的使用方式进行一系列限制,这些限制统称为 actor isolation

Actor 隔离

Actor isolation 是 actor 保护其可变状态的方式。对于 actor 来说,这种保护的主要机制是只允许实例的存储属性通过 self 访问。例如,这里有一个方法,试图将钱从一个账户转到另一个账户:

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 是一个 class,transfer(amount:to:) 方法就是定义正确的,但在没有外部加锁的并发代码中会出现 data race。

使用 actor 时,试图访问 other.balance 会引发编译错误,因为 balance 只能通过 self 访问。错误信息指出 balance 是隔离在 actor 里的,意味着它只能从它被绑定或”隔离”的特定 actor 中直接访问。在这个例子中,它是由 self 引用的 BankAccount 的实例。所有关于 actor 实例的声明,包括存储和计算的实例属性(如 balance)、实例方法(如transfer(amount:to:))和实例下标,默认都是被 actor 隔离的。Actor 隔离的声明可以自由地引用同一 actor 实例上的其他被 actor 隔离的声明(通过 self)。任何没有被 actor 隔离的的声明都是不被隔离的,不能同步访问任何被 actor 隔离的声明。

在一个 actor 之外对该 actor 隔离的声明的引用被称为跨 actor 引用,这种引用有两种方式是合法的。

第一,对不可变的状态的跨 actor 引用是合法的,因为一旦初始化,该状态就不能被修改(无论是从 actor 内部还是外部),所以从定义上来说没有数据竞争。根据这个规则,对 other.accountNumber 的引用是允许的,因为 accountNumber 是通过一个 let 声明的,并且具有值语义类型 Int

第二种合法的跨 actor 引用的形式是用异步函数调用。这种异步函数调用会被转化为”消息”,actor 会在可以安全地执行的时候,执行相应的任务。这些消息会存储在 actor 的”邮箱”中,启动异步函数调用的调用者可能被暂停,直到 actor 能够处理其邮箱中的相应消息。Actor 按顺序处理其邮箱中的消息,因此,一个给定的 actor 将永远不会有两个同时执行的任务,运行 actor 隔离的代码。这确保了在 actor 隔离的可变状态上不会有数据竞赛,因为在任何可以访问 actor 隔离的状态的代码中都没有并发性。例如,如果我们想给一个给定的银行账户 account 存款,我们可以调用另一个 actor 上的方法 deposit(amount:),这个调用将成为一个放在该 actor 邮箱中的消息,调用者将暂停。当该 actor 处理消息时,它最终会处理与存款相对应的消息,当没有其他代码在该 actor 的隔离域中执行时,就在该 actor 的隔离域中执行该调用。

实现说明。在实现层面上,消息是异步调用的 partial task(由结构化并发提案描述),每个 actor 实例包含自己的 serial executor(也在结构化并发提案中)。Serial executor 负责按顺序运行部分任务。这在概念上类似于一个串行的 DispatchQueue,但在 actor 运行时的实际实现中使用了一个更轻量级的实现,利用了 Swift 的 async 函数。

编译时 actor-isolation 检查会确定哪些对被 actor 隔离的声明引用是跨 actor 的引用,并确保这种引用使用上述两种允许的机制之一。这确保了 actor 之外的代码不会干扰 actor 的 mutable 状态。

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

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)")

    // 安全:这是当前唯一一个访问被 actor 隔离的状态的操作,并且
    // 在我们检查是否有足够的资金之后,执行到这里的逻辑之前,中间没有
    // 任何暂停点。
    balance = balance - amount
    
    // 安全:存款的操作会被放到 `other` actor 的邮箱里;
    // actor 会从邮箱里取出这个操作并且执行它,other 的
    // 账户余额将会被更新。
    await other.deposit(amount: amount)
  }
}

deposit(amount:) 操作需要涉及不同 actor 的状态,所以必须异步调用。这个方法本身可以被实现为 async

extension BankAccount {
  func deposit(amount: Double) async {
    assert(amount >= 0)
    balance = balance + amount
  }
}

然而,这个方法其实并不需要是 async:它没有进行异步调用(注意这里没有 await)。因此,它最好被定义为一个同步函数:

extension BankAccount {
  func deposit(amount: Double) {
    assert(amount >= 0)
    balance = balance + amount
  }
}

同步的 actor 函数可以在 actor 的 self 上同步调用,但是跨 actor 引用这个方法需要异步调用。transfer(amount:to:) 函数异步调用它(在 other 上),而下面的函数 passGo 同步调用它(在隐式 self 上)。

extension BankAccount {
  // Pass go and collect $200
  func passGo() {
    self.deposit(amount: 200.0)  // synchronous is okay because `self` is isolated
  }
}

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

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 操作,因为在 getset 之间会有一个隐含的暂停点,可能会引入有效的竞赛条件。此外,异步设置属性可能会使其更容易无意中破坏不变性,例如,两个属性需要同时更新以保持不变性。

从 module 外,不可变的 let 必须从 actor 外异步引用。比如说:

// From another module
func printAccount(account: BankAccount) {
  print("Account #\(await account.accountNumber)")
}

这保留了定义 BankAccount 的模块将 let 演变为 var 而不破坏客户端的能力,这是 Swift 一直保持的特性:

actor BankAccount { // version 2
  var accountNumber: Int
  var balance: Double  
}

accountNumber 改为 var 之后,只有 module 内的代码需要改变;现有的客户端使用已经是异步访问,不受影响。

跨 actor 引用和 Sendable 类型

SE-0302 介绍了 Sendable 协议。符合 Sendable 协议的类型的值可以在并发执行的代码中安全地共享。有各种各样的类型以这种方式工作:像 IntString 这样的值语义类型,像 [String][Int: String] 这样的值语义集合,immutable 的 class,内部执行自己同步的类(例如 ConcurrentHashMap),等等。

Actor 会保护它的 mutable 状态,所以 actor 实例可以在并发执行的代码中自由共享,而且 actor 本身将在内部保持同步。因此,每个 actor 类型都隐式遵循 Sendable 协议。

所有的跨 actor 引用都,必须,与在并发执行的代码中共享的值一起使用。例如,假设我们的 BankAccount 包括一个所有者列表,每个所有者都建模为 Person 类:

class Person {
  var name: String
  let birthDate: Date
}

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

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

primaryOwner 函数可以从另一个 actor 中异步调用,然后可以从任何地方修改 Person 实例。

if let primary = await account.primaryOwner() {
  primary.name = "The Honorable " + primary.name  // problem: concurrent mutation of actor-isolated state
}

即使是 non-mutating 的访问也是会有问题的,因为在原始调用试图访问这个 Person 的时候,这个 Person 的 name 也可能从 actor 内部被修改。为了防止这种被 actor 隔离的状态的并发 mutate 的可能性,所有的跨 actor 引用只能涉及符合 Sendable 的类型。对于一个跨 actor 的异步调用,参数和返回值类型必须符合 Sendable。对于一个跨 actor 的不可变属性引用,属性类型必须符合 Sendable。通过坚持所有的跨 actor 引用只使用 Sendable 类型,我们可以确保对共享 mutable 状态的引用不会流入或流出 actor 的隔离域。编译器会对这类问题进行诊断。例如,对 account.primaryOwner() 的调用会产生类似以下的错误。

error: cannot call function returning non-Sendable type 'Person?' across actors

请注意,上面定义的 primaryOwner() 函数仍然可以用于被 actor 隔离的代码。例如,我们可以定义一个函数来获取 primary owner 的名字,像这样:

extension BankAccount {
  func primaryOwnerName() -> String? {
    return primaryOwner()?.name
  }
}

primaryOwnerName() 函数可以安全地跨 actor 异步调用,因为 String(因此也包括String?)符合Sendable

闭包

对跨 actor 引用的限制,只有在我们能确保,可能与被 actor 隔离的代码同时执行的代码,被视为不被隔离的情况下才有效。例如,一个计划生成月末报告的函数:

extension BankAccount {
  func endOfMonth(month: Int, year: Int) {
    // Schedule a task to prepare an end-of-month report.
    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 隔离的,我们将在访问 BankAccount 的 mutable 状态时引入 data race。actor 通过指定 @Sendable 闭包总是不被隔离的,来防止这种数据竞赛(在 Sendable@Sendable 闭包中描述,并在结构化并发提案中用于 detach 的定义)。因此,它需要异步访问任何被 actor 隔离的声明。

一个不是 @Sendable 的闭包不能脱离它所形成的并发域。因此,如果这样的闭包是在一个被 actor 隔离的上下文中形成的,那么它将是被 actor 隔离的。这一点很有用,例如,当应用在 forEach 这样的 Sequence 算法时,所提供的闭包将被连续调用:

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)
  }
}

在一个被 actor 隔离的上下文中形成的闭包,如果它是非 @Sendable,则是被 actor 隔离的,如果它是 @Sendable,则是不被隔离的。对于上面的例子:

  • 传递给 detach 的闭包是不被隔离的,因为该函数需要传递一个 @Sendable 闭包给它。
  • 传递给 forEach 的闭包是与 self 隔离的,因为它需要一个非 @Sendable 函数。

Actor 重入

被 actor 隔离的函数是可重入的。当一个被 actor 隔离的函数暂停时,可重入性允许其他工作在原本被 actor 隔离的函数恢复之前,在该 actor 上执行,我们称之为 interleaving(交织执行)。重入消除了一个死锁的来源,即两个 actor 相互依赖,可以通过减少 actor 非必要的阻塞,来提高整体性能,并为更好地调度(例如)优先级更高的任务提供机会。然而,这意味着当交织的任务修改状态时,被 actor 隔离的状态可能会跨 await 发生变化,这意味着开发者必须确保不打破跨 await 的不变性。一般来说,这是在异步调用中要求 await原因,因为当调用暂停时,各种状态(例如全局状态)会发生变化。

本节会通过例子探讨重入问题,阐述重入 actor 和非重入 actor 的好处和问题,并最终确定使用重入 actor。Alternatives Considered 提供了潜在的未来方向,以提供对重入的更多控制,包括非重入 actor任务链重入

可重入 actor 的交织执行

重入意味着异步被 actor 隔离的函数的执行可能会在暂停点上交织,导致用这种 actor 编程的复杂度增加,因为如果暂停点之后的代码依赖于一些可能在暂停前发生变化的不变量,则必须仔细检查每个暂停点。

交织执行仍然尊重 actor 的”单线程幻觉”,也就是说,没有两个函数会在任何给定的 actor 上并发地执行。然而,它们可以在暂停点上交织。从广义上讲,这意味着可重入 actor 是线程安全的,但无法自动保护状态,因为”上层”的数据竞赛仍然可能发生,有可能使执行中的异步函数所依赖的不变性失效。为了进一步说明这个问题的含义,让我们看看下面这个 actor,它想到了一个想法,然后在告诉它的朋友之后将其返回:

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 // 🤨                      // <3>
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea                       // <4>
    await friend.tell(opinion, heldBy: self) // <5>
    return opinion // 🤨                     // <6>
  }
}

在上面的例子中,Person 可以想到一个好的或坏的想法,与一个朋友分享这个意见,并返回它所存储的意见。由于 actor 是可重入的,所以这段代码是错误的,如果 actor 同时开始思考几个想法,就会返回一个任意的意见:

以下是典型的 decisionMaker actor 调用代码,可以说明这一点:

let goodThink = detach { await person.thinkOfGoodIdea() }  // runs async
let badThink = detach { await person.thinkOfBadIdea() } // runs async

let shouldBeGood = await goodThink.get()
let shouldBeBad = await badThink.get()

await shouldBeGood // could be .goodIdea or .badIdea ☠️
await shouldBeBad

这个片段 可能 会产生(取决于恢复的时间)以下的执行顺序:

opinion = .goodIdea                // <1>
// 暂停: await friend.tell(...) // <2>
opinion = .badIdea                 // | <4> (!)
// 暂停: await friend.tell(...) // | <5>
// 恢复: await friend.tell(...)  // <2>
return opinion                     // <3>
// 恢复: await friend.tell(...)  // <5>
return opinion                     // <6>

但它也 可能 导致 “naively expected” 的执行,即没有交织,这意味着问题只会间歇性地出现,就像并发代码中的许多竞赛条件。

在暂停点上有可能出现交织执行,这是要求每个暂停点在源代码中await 标记的主要原因,尽管 await 本身没有语义作用。这是一个提示,表明任何共享状态都可能在 await 中发生变化,所以应该避免在 await 中破坏不变性,或者依赖 “之前”的状态与”之后”的状态一致。

一般来说,避免跨 await 破坏不变性的最简单方法是将状态更新封装在同步 actor 函数中。实际上,actor 中的同步代码提供了一个临界区,而 await 中断了一个临界区。对于我们上面的例子,我们可以通过将 “optinion fomration” 与 “telling a friend your opinion” 分开来实现这个修改。事实上,告诉你的朋友你的观点可能会合理地导致你改变你的观点!

不可重入的 actor 的死锁

与可重入的 actor 函数相反的是”不可重入”的函数和 actor。这意味着当一个actor 正在处理一个传入的 actor 函数调用(消息)时,它将不会处理来自其邮箱的任何其他消息,直到它完成运行这个初始函数。本质上,整个 actor 都会被阻塞,直到该任务完成。

如果我们以上一节的例子为例,使用一个非重入 actor,它将正确执行,因为在 friend.tell 完成之前,不能给 actor 安排工作:

// assume non-reentrant
actor DecisionMaker {
  let friend: DecisionMaker
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                                   
    await friend.tell(opinion, heldBy: self)
    return opinion // ✅ always .goodIdea
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea
    await friend.tell(opinion, heldBy: self)
    return opinion // ✅ always .badIdea
  }
}

然而,如果一个任务涉及到回调 actor,非重入就会导致死锁。例如,让我们进一步延伸这个例子,让我们的朋友试图说服我们改变一个坏主意:

extension DecisionMaker {
  func tell(_ opinion: Judgment, heldBy friend: DecisionMaker) async {
    if opinion == .badIdea {
      await friend.convinceOtherwise(opinion)
    }
  }
}

对于非重入的 actor,thinkOfGoodIdea() 在这种实现下会成功,因为 tell 基本上什么都不做。然而,thinkOfBadIdea() 将陷入僵局,因为原来的决策者(称为 A)在对另一个决策者(称为 B)调用 tell 时被锁定。然后,B 试图说服 A,但该调用无法执行,因为 A 已经被锁定。因此,actor 自身进入死锁,无法进行下去。

这些讨论中使用的术语”死锁”是指 actor 异步地等待”彼此”或”自己的未来工作”。对于这个问题来说,线程阻塞不是必要条件。

理论上,一个完全不重入的模型在调用 self 上的异步函数时也会出现死锁。然而,由于这种调用可以静态地确定是在 self 上,它们会立即执行,因此不会阻塞。

可以用运行时工具来检测非重入 actor 的死锁,这些工具可以在死锁发生后检测循环调用图,就像在运行时检测数据结构中的引用循环一样。然而,这种死锁通常不能被静态地识别(例如,用编译器或静态分析),因为调用图需要整个程序的知识,并且会根据提供给程序的数据动态变化。

死锁的 actor 将永远作为不活跃的僵尸呆在那里。一些运行时为了解决这样的死锁问题,让每个 actor 的调用都有一个超时时间(分布式 actor 系统已经证实这种超时机制确实行之有效)。这意味着每个 await 都有可能 throw,而超时或死锁检测都必须始终被启用。我们认为这个代价太过昂贵,因为我们设想在绝大多数并发的 Swift 应用中都会使用 actor。这也会搅浑取消机制,取消机制是有意设计成明确和可合作的。因此,我们觉得自动取消死锁的方法并不符合 Swift 并发的方向。

不可重入的 actor 非必要的阻塞

想想看一个处理各种图片下载的 actor,并保存对已下载内容的缓存,以使后续访问更快:

// assume non-reentrant
actor ImageDownloader { 
  var cache: [URL: Image] = [:]

  func getImage(_ url: URL) async -> Image {
    if let cachedImage = cache[url] {
      return cachedImage
    }
    
    let data = await download(url)
    let image = await Image(decoding: data)
    return cache[url, default: image]
  }
}

这个 actor 在功能上是正确的,不管它是否是可重入的。然而,如果它是非重入的,它将逐个执行图片的下载:一旦一个客户端请求了一张图片,所有其他的客户端将被阻止开始任何请求 – 即使是那些会击中缓存或在不同的 URL 上请求图片的请求 – 直到第一个客户端将其图片完全下载和解码。

而使用可重入的 actor 时,多个客户端可以独立地获取图像,这样(比如)他们都可以处于下载和解码图像的不同阶段。actor 上部分任务的串行执行确保了缓存本身不会被破坏。在最坏的情况下,两个客户可能会同时要求获得相同的图像URL,其中会有一些多余的工作。

已有的实践

有一些现有的 actor 实现已经考虑了重入的概念:

  • Erlang/Elixir(gen_server)展示了一个简单的 “loop/deadlock” 场景以及如何检测和修复它。
  • Akka(Persistence persist/persistAsync)实际上是默认 非重入的 ,特定的 API 被设计为允许程序员在需要时 选择 重入。在链接的文档中,persistAsync 是 API 的可重入版本,它在实践中很少被使用。Akka 持久化和这个 API 已经被用来实现银行交易和流程管理,依靠 persist() 的非重入性作为杀手锏,使得实现简单易懂且 安全 。注意,Akka 是建立在 Scala 之上的,Scala 不提供 async/await。这意味着邮箱处理方法在本质上更具有同步性,与其在等待响应时阻塞 actor,不如将响应作为一个单独的消息接收来处理。
  • Orleans(grains)默认也是非重入的,但围绕重入性提供了可拓展的配置。Grains 和特定的方法可以被标记为可重入,甚至还有一种动态机制,可以实现一个运行时的断言来确定一个调用是否可以交织执行。Orleans 也许最接近这里描述的 Swift 方法,因为它是建立在提供 async/await 的语言之上的(C#)。请注意,Orleans 一个叫做调用链重入的功能,我们认为这是一个很有前途的潜在方向:我们在本提案后面的任务链重入一节中会介绍它。

Reentrancy 总结

本提案只提供了可重入的 actor。然而,未来方向小节里阐述了潜在的未来设计方向,可以增加可选的非重入性。

理由。默认情况下,重入可以消除死锁的可能性。此外,它还有助于确保 actor 在并发系统中能及时取得进展,并确保特定的 actor 不会在长期运行的异步操作(例如,下载文件)中出现不必要的阻塞。确保安全交织的机制,例如在执行突变时使用同步代码,并注意不破坏跨 await 调用的不变性,已经存在于该提案中。

Protocol conformances

所有的 actor 类型都默认遵循一个新的协议,Actor

protocol Actor : AnyObject, Sendable { }

注意Actor 协议的定义是故意留白的。Custom executor 提案将在 Actor 协议中引入实现要求。当没有明确提供时,这些要求将被实现隐含地合成,但可以明确地提供,以允许 actor 控制自己的串行执行。

Actor 协议可以用来编写适用于所有 actor 的通用操作,包括用新的操作扩展所有 actor 类型。与 actor 类型一样,在 Actor 协议上定义的实例属性、函数和下标(包括其扩展)是与 self actor 隔离的。例如:

protocol DataProcessible: Actor {  // only actor types can conform to this protocol
  var data: Data { get }           // actor-isolated to self
}

extension DataProcessible {
  func compressData() -> Data {    // actor-isolated to self
    // use data synchronously
  }
}

actor MyProcessor : DataProcessible {
  var data: Data                   // okay, actor-isolated to self
  
  func doSomething() {
    let newData = compressData()   // okay, calling actor-isolated method on self
    // use new data
  }
}

func doProcessing<T: DataProcessible>(processor: T) async {
  await processor.compressData() // not actor-isolated, so we must interact asynchronously with the actor
}

其他类型的具体类型(类、枚举、结构等)都不能遵循 Actor 协议,因为它们不能定义被 actor 隔离的操作。

Actor 也可以遵循具有 async 要求的协议,因为所有的客户都已经不得不与这些实现要求进行异步交互,使得 actor 有能力保护它隔离的状态。比如说:

protocol Server {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply
}

actor MyActor: Server {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply { // okay: this method is actor-isolated to 'self', satisfies asynchronous requirement
  }
}

Actor 不能被用来遵循具有同步要求的非 Actor 协议。然而,有一个关于控制 actor 隔离的单独提议,当它们能够以不引用任何 mutable actor 状态的方式实现时,允许这种遵循。

具体设计

Actors

Actor 类型可以用 actor 关键字来声明:

/// Declares a new type BankAccount
actor BankAccount {
  // ...
}

每个 actor 的实例都代表一个唯一的 actor。术语 “actor” 可以用来指代实例或类型;在必要时,人们可以使用 ”actor 实例” 或 “actor 类型 “来消除歧义。

Actor 类似于 Swift 中的其他具体名义类型(enum, struct 和 class)。Actor 类型可以有 static 和实例方法、属性和下标。它们像 stuct 和 class 一样有存储属性和初始化器。它们像 class 一样是引用类型,但不支持继承,因此没有(或不需要)诸如 requiredconvenience 初始化器、override 、或 class 成员、openfinal 等功能。Actor 类型在行为上与其他类型的不同之处主要是由 actor isolation 的规则驱动的,如下所述。

默认情况下,actor 的实例方法、属性和下标有一个隔离的 self 参数。即使是通过扩展在 actor 上追加的方法也是如此,就像其他 Swift 类型一样。静态方法、属性和下标没有一个作为 actor 实例的 self 参数,所以它们不是与被 actor 隔离的。

extension BankAccount {
  func acceptTransfer(amount: Double) async { // actor-isolated
    balance += amount
  }
}  

Actor 隔离检查

程序中的任何给定的声明要么是被 actor 隔离的,要么是不被隔离的。如果一个函数(包括访问器)定义在一个 actor 类型上(包括 Self 符合 Actor 的协议,以及其扩展),那么它就是被 actor 隔离的。如果一个 mutable 的实例属性或实例下标是定义在一个 actor 类型上的,那么它就是被 actor 隔离的。不被 actor 隔离的声明被称为不被隔离的(non-isolated)。

Actor isolation 规则会在很多地方被检查,在这些地方,需要对两个不同的声明进行比较,以确定它们在一起的使用是否保持了 actor 的隔离。有几个这样的地方:

  • 当一个声明的定义(例如,一个函数的主体)引用另一个声明时,例如,调用一个函数,访问一个属性,或执行一个下标。
  • 当一个声明满足了一个协议要求时。

我们将详细地描述每种情况。

引用和 actor 的隔离

一个被 actor 隔离的非 async 声明只能从另一个与同一 actor 隔离的声明中被同步访问。对于同步访问一个被 actor 隔离的函数,该函数必须从另一个被 actor 隔离的函数中调用。对于同步访问一个被 actor 隔离的实例属性或实例下标,该实例本身必须是被 actor 隔离的。

一个被 actor 隔离的声明可以从任何声明中被异步访问,无论它是被隔离到另一个 actor 还是不被隔离。这样的访问是异步操作,因此必须用 await 来注释。从语义上讲,程序将切换 actor 来执行同步操作,之后再切换回调用者的 executor。

例如:

actor MyActor {
  let name: String
  var counter: Int = 0
  func f()
}

extension MyActor {
  func g(other: MyActor) async {
    print(name)          // okay, name is non-isolated
     print(other.name)    // okay, name is non-isolated
     print(counter)       // okay, g() is isolated to MyActor
     print(other.counter) // error: g() is isolated to "self", not "other"
     f()                  // okay, g() is isolated to MyActor
     await other.f()      // okay, other is not isolated to "self" but asynchronous access is permitted
  }
}

Protocol conformance

当某项声明(”witness”)满足某项协议要求(”requirement”)时,在以下情况下,witness 可以满足该协议要求:

  • Requirement 是 async,或
  • Requirement 和 witness 都是被 actor 隔离的。

一个 actor 可以满足异步的要求,因为对该要求的任何使用都是异步的,因此可以暂停,直到该 actor 可以执行它们。请注意,一个 actor 可以用一个同步的实现来满足一个异步的实现需求,在这种情况下,异步访问一个 actor 上的同步声明的正常规则是适用的。比如说:

protocol Server {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply
}

actor MyServer : Server {
  func send<Message: MessageType>(message: Message) throws -> Message.Reply { ... }  // okay, asynchronously accessed from clients of the protocol
}

Partial applications

只有当表达式是一个直接的参数,其对应的参数是 non-escaping 和 non-Sendable 时,才允许隔离函数的 partial applications。例如:

func runLater<T>(_ operation: @escaping () -> T) -> T { ... }

actor A {
  func f(_: Int) -> Double { ... }
  func g() -> Double { ... }
  
  func useAF(array: [Int]) {
    array.map(self.f)                     // okay
    detach(operation: self.g)             // error: self.g has non-sendable type () -> Double that cannot be converted to a @Sendable function type
    runLater(self.g)                      // error: self.g has escaping function type () -> Double
  }
}

这些限制来自于 actor 对闭包的 partial applications “desugaring” 后生效的隔离规则。上面两个错误的例子是由于在执行调用的闭包中,闭包将是不被隔离的,所以对被 actor 隔离的函数 g 的访问必须是异步的。下面是 partial application 的 “desugared” 形式:

extension A {
  func useAFDesugared(a: A, array: [Int]) {
    array.map { f($0) } )      // okay
    detach { g() }             // error: self is non-isolated, so call to `g` cannot be synchronous
    runLater { g() }           // error: self is non-isolated, so the call to `g` cannot be synchronous
  }
}

Key paths

一个 keypath 不能涉及对被 actor 隔离声明的引用:

actor A {
  var storage: Int
}

let kp = \A.storage  // error: key path would permit access to actor-isolated storage

理由。允许生成引用 actor isolation 的属性或下标的 keypath,将允许从 actor isolation 域之外访问 actor 的受保护状态。作为这一规则的替代方案,我们可以从 keypath 中移除 Sendable 的遵循,这样人们就可以形成指向被 actor 隔离状态的 keypath,但它们不能被共享。

inout 参数

被 actor 隔离的存储属性可以通过 inout 参数传递到同步函数中,但通过 inout 参数传递到异步函数中是不符合规定的。比如说:

func modifiesSynchronously(_: inout Double) { }
func modifiesAsynchronously(_: inout Double) async { }

extension BankAccount {
  func wildcardBalance() async {
    modifiesSynchronously(&balance)        // okay
    await modifiesAsynchronously(&balance) // error: actor-isolated property 'balance' cannot be passed 'inout' to an asynchronous function
  }
}  

class C { var state : Double }
struct Pair { var a, b : Double }
actor A {
  let someC : C
  var somePair : Pair

  func inoutModifications() async {
    modifiesSynchronously(&someC.state)        // okay
    await modifiesAsynchronously(&someC.state) // not okay
    modifiesSynchronously(&somePair.a)         // okay
    await modifiesAsynchronously(&somePair.a)  // not okay
  }
}

理由:这个限制可以防止违反独占性,在这种情况下,对被 actor 隔离的 balance 的修改是通过把它作为 inout 传递给一个调用而开始的,这个调用被暂停,然后在同一个 actor 上执行的另一个任务试图访问 balance。这样的访问将导致违反独占性,从而终止程序。虽然 inout 的限制对于内存安全来说是不需要的(因为错误会在运行时被检测到),但 actor 的默认重入性使得它很容易引入非确定性的独占性违规。因此,我们引入了这个限制,以消除这类问题,即竞赛会触发独占性的违规。

Actor 与 Objective-C 的交互

一个 actor 类型可以被声明为 @objc,它隐式地提供了对 NSObjectProtocol 的遵循:

@objc actor MyActor { ... }

Actor 的成员只有在 async 或不被 actor 隔离的情况下才能标注为 @objc。在 actor 的隔离域内的同步代码只能在 self 上调用(在 Swift 中)。Objective-C 并不了解 actor 的隔离,所以这些成员不允许暴露在 Objective-C 中。比如说:

@objc actor MyActor {
    @objc func synchronous() { } // error: part of actor's isolation domain
    @objc func asynchronous() async { } // okay: asynchronous, exposed to Objective-C as a method that accepts a completion handler
    @objc nonisolated func notIsolated() { } // okay: non-isolated
}

代码兼容性

这个建议主要是补充性的,不应该会破坏源代码的兼容性。引入 Actor 的 actor 上下文关键字是对解析器的改变,不会破坏现有的代码,其他的改变也是精心设计的,所以它们不会改变现有的代码。只有引入 actors 或 actor-isolation 属性的新代码会受到影响。

对于 ABI 稳定性的影响

这纯粹是对 ABI 的补充。Actor isolation 本身是一个静态概念,不是 ABI 的一部分。

对于 API 稳定性的影响

几乎所有 actor isolation 的改变都是破坏性的改变,因为 actor isolation 规则要求声明和其用户之间的一致性。

  • 一个 class 不能变成一个 actor,反之亦然。
  • 一个公共声明的 actor isolation 不能被改变。

未来方向

Non-reentrancy

我们可以引入一个 @reentrant 注解,它可以被添加到任何被 actor 隔离的函数、actor 或 actor 的扩展中,以描述它是如何重入的。该属性将有几种形式:

  • @reentrant: 表示被注解的函数体中的每个潜在暂停点是可重入的。
  • @reentrant(never): 表示被注解的函数体中的每个潜在暂停点都是不可重入的。

一个不可重入的潜在暂停点会阻止任何其他异步调用在 actor 上执行,直到它完成。请注意,直接在 self 上对非重进异步函数的异步调用不受这个检查的影响,所以一个 actor 可以异步调用自己而不产生死锁。

理由。允许直接调用 self 可以消除一系列明显的死锁,并且只需要与被 actor 隔离检查相同的静态信息,就可以同步访问被 actor 隔离的状态。

在一个不被隔离的函数、非 actor 类型或非 actor 类型的扩展上使用 @reentrant 注解是错误的。只有给定的声明中可以使用 @reentrant 注解。一个被 actor 隔离的非类型声明的可重入性是通过寻找一个合适的 @reentrant 属性来确定的。搜索的路径如下:

  1. 声明本身。
  2. 如果声明是一个扩展的非类型成员,则是该扩展。
  3. 如果声明是一个类型(或其扩展)的非类型成员,则是类型定义。

如果没有合适的 @reentrant 注解,被 actor 隔离的声明就是可重入的。

下面有一个例子,说明如何在不同地方使用 @reentrant 注解:

actor Stage {
  @reentrant(never) func f() async { ... }    // not reentrant
  func g() async { ... }                      // reentrant
}

@reentrant(never)
extension Stage {
  func h() async { ... }                      // not reentrant
  @reentrant func i() async { ... }           // reentrant

  actor InnerChild {                          // reentrant, not affected by enclosing extension
    func j() async { ... }                    // reentrant
  }

  nonisolated func k() async { .. }     // okay, reentrancy is uninteresting
  nonisolated @reentrant func l() async { .. } // error: @reentrant on non-actor-isolated
}

@reentrant func m() async { ... } // error: @reentrant on non-actor-isolated

注解不是这里唯一可能的设计方向。在实现层面上,实际的阻塞将在每个异步调用点处理。我们可以引入一个不同形式的 await 来进行阻塞,而不是一个可能影响许多异步调用的注解,例如:

await(blocking) friend.tell(opinion, heldBy: self)

Task-chain reentrancy

关于可重入和不可重入 actor 的讨论都将重入视为一种二元选择,所有形式的重入都被认为是同样可能引入难以解释的数据竞赛。然而,常见的、通常很容易理解的 actor 之间的交互方式,它只是两个或多个 actor 之间的”对话”,以实现一些原始的请求。在同步代码中,让两个或多个不同的 class 以同步调用的方式相互回调是很常见的。例如,这里是 isEven 的一个愚蠢的实现,它在两个 class 之间使用了相互递归:

class OddOddySync {
  let evan: EvenEvanSync!

  func isOdd(_ n: Int) -> Bool {
    if n == 0 { return true }
    return evan.isEven(n - 1)
  }
}

class EvenEvanSync {
  let oddy: OddOddySync!

  func isEven(_ n: Int) -> Bool {
    if n == 0 { return false }
    return oddy.isOdd(n - 1)
  }
}

这段代码依赖于这些 class 的两个方法在同一个调用堆栈中有效的”可重入”,因为其中一个会调用到另一个(反之亦然)作为计算的一部分。现在,以这个例子为例,用 actor 使其成为异步的:

@reentrant(never)
actor OddOddy {
  let evan: EvenEvan!

  func isOdd(_ n: Int) async -> Bool {
    if n == 0 { return true }
    return await evan.isEven(n - 1)
  }
}

@reentrant(never)
actor EvenEvan {
  let oddy: OddOddy!

  func isEven(_ n: Int) async -> Bool {
    if n == 0 { return false }
    return await oddy.isOdd(n - 1)
  }
}

@reentrant(never) 下,这段代码会出现死锁,因为从 EvanEvan.isEvenOddOddy.isOdd 的调用将依赖于对 EvanEvan.isEven 的另一次调用,在原始调用完成之前,无法继续。我们需要使这些方法成为可重入的,以消除死锁。

随着 Swift 将结构化并发作为其并发功能的核心组件,我们可能会比直接禁止重入做得更好。在 Swift 中,每个异步操作都是 Task 的一部分,Task 封装了正在进行的一般计算,从这种任务中产生的每个异步操作都成为当前任务的一个子任务。因此,可以知道某个异步调用是否属于同一个任务层次,这大致相当于在同步代码中处于同一个调用栈。

我们可以引入一种新的重入,任务链重入,它允许代表给定任务或其任何子任务的重入调用。这既解决了我们在 deadlocks 一节中的 convinceOtherwise 例子中遇到的死锁,也解决了上面 isEven 例子中的相互递归,同时还能防止不相关任务的再入。因此,这种重入更接近于模仿同步代码,消除了许多死锁,而不需要允许无关的交织执行破坏 actor 的高层不变性。

我们目前没有把任务链重入性纳入到提案中有这几个原因:

  • 基于任务的重入方法似乎还没有被大规模地尝试过。Orleans 记录了对调用链中的重入的支持,但实施相当有限,最终被删除。从 Orleans 的经验来看,很难评估问题是出在想法上还是具体的实现上。
  • 我们还不知道这种方法在 actor 运行时中的有效实现方式。

如果我们能够解决上述问题,任务链重入可以通过重入属性的另一种参数,如 @reentrant(task),引入到 actor 模型中,并可能提供最佳的默认值。

其它替代方案

Actor 继承

早期的草案和本提案的第一个版本允许 actor 继承。Actor 的继承遵循 class 的继承规则,尽管有特定的额外规则来维持 actor 的隔离:

  • 一个 actor 不能继承于一个 class ,反之亦然。
  • 一个 override 的声明不能比被 override 的声明的隔离等级高。

随后的审查讨论确定,actor 继承的概念成本超过了它的实用性,所以它已经从这个提案中删除。actor 继承在语言中采取的形式,在本提案的先前迭代和实施中已被充分理解,所以这一特性可以在以后重新引入。

跨 actor lets

这个提案允许从定义 actor 的同一 module 的任何地方同步访问 actor 实例上的 let 属性:

// in module BankActors
public actor BankAccount {
  public let accountNumber: Int
}

func print(account: BankAccount) {
  print(account.accountNumber) // okay: synchronous access to an actor's let property
}

在 module 之外的访问必须是异步的:

import BankActors

func otherPrint(account: BankAccount) async {
  print(account.accountNumber)         // error: cannot synchronously access immutable 'let' outside the actor's module
  print(await account.accountNumber)   // okay to asynchronously access
}

对 module 之外必须异步访问的要求,为库实现者提供了长期的自由度,使得 public 的 let 可以被重构为 var 的同时,不会破坏任何客户端。这与 Swift 的原则一致,即在不破坏客户端的情况下,最大限度地增加库实现者改变实现的自由。如果不要求其他 module 必须异步访问,上述的 otherPrint(account:) 函数将会被允许同步引用 accountNumberBankActors 的作者随后将账号改为 var,就会破坏现有的客户端代码。

public actor BankAccount {
   public var accountNumber: Int     // version 2 makes this mutable, but would break clients if synchronous access to 'let's were allowed outside the module
 }

还有一些其他的语言功能也采取了同样的方法,即在一个 module 内减少模板和简化语言,然后当一个实体从 module 外使用时要求使用额外的语言功能。比如说:

  • 访问控制默认为 internal,所以你可以在你的整个 module 中使用一个声明,但必须明确选择在你的 module 之外使用它(例如,通过 public)。换句话说,你可以忽略访问控制,直到你需要把某个东西 public 给其他 module 使用。
  • struct 默认生成的构造器是 internal 的。你需要自己写一个 public 的构造器,以承诺允许该 struct 被初始化为一组确切的参数。
  • 当父类在同一个 module 中时,默认允许从 class 中继承。要继承一个定义在不同 module 中的父类,该父类必须被明确标记为 open。你可以忽略 open,直到你想向 module 外的用户保证这种能力。
  • 当被 override 的声明在同一个 module 中时,默认允许覆盖一个 class 中的声明。要从不同 module 的声明中覆盖,该覆盖的声明必须明确标记为 open

SE-0313 “改进对 actor 隔离的控制“提供了一种显式的方式,让客户通过 nonisolated 关键字同步访问不可变的 actor 状态的自由,例如:

// in module BankActors
public actor BankAccount {
  public nonisolated let accountNumber: Int  // can be accessed synchronously from any module due to the explicit 'nonisolated'
}

这个提案最初通过的版本要求所有对不可变 actor 存储属性的访问都是异步的,并将任何同步的访问留给 SE-0313 中所阐述的显式 nonisolated 注解。然而,尝试使用该模型的过程表明,它有很多问题,影响了该模型的上手难度:

  • 开发人员在编写 actor 代码时,几乎会立即需要使用 nonisolated。这违背了 Swift 试图让高级功能遵循的渐进式披露原则。除了 nonisolated let 之外,nonisolated 的使用相当少。
  • 不可变的状态是编写安全并发代码的一个关键工具。Sendable 类型的 let 在概念上是安全的,可以从并发代码中引用,并在其他情况下工作(例如,局部变量)。使一些不可变的状态成为并发安全的,而其他状态则不是,这就使数据安全的并发编程的故事变得复杂。下面是一个围绕 @Sendable 的现有限制的例子,这些限制是在 SE-0302 中定义的:
    func test() {
      let total = 100
      var counter = 0
     
      asyncDetached {
        print(total) // okay to reference immutable state
        print(counter) // error, cannot reference a `var` from a @Sendable closure
      }
      
      counter += 1
    }

通过允许在 module 内同步访问 actor 的 let 属性,我们为 actor 隔离提供了一个更平滑的学习曲线,并接纳(而不是颠覆)长期以来普遍存在的想法,即不可变的数据对并发是安全的,同时仍然解决了第二次审查中提出的担忧,即对 actor let 的无限制的同步访问隐含了一个库作者对该状态永不变的承诺。它遵循 Swift 语言中现有的先例,使 module 内的交互比跨 module 的交互更简单。

Revision history

  • Changes in the post-review amendment to the proposal:
    • Cross-after references to instance let properties from a different module must be asynchronous; within the same module they will be synchronous.
  • Changes in the final accepted version of the proposal:
    • Cross-actor references to instance let properties must be asynchronous.
  • Changes in the second reviewed proposal:
    • Escaping closures can now be actor-isolated; only @Sendable prevents isolation.
    • Removed actor inheritance. It can be considered at some future point.
    • Added “cross-actor lets” to Alternatives Considered. While there is no change to the proposed direction, the issue is explained here for further discussion.
    • Replaced Task.runDetached with detach to match updates to the Structured Concurrency proposal.
  • Changes in the seventh pitch:
  • Changes in the sixth pitch:
    • Make the instance requirements of Actor protocols actor-isolated to self, and allow actor types to conform to such protocols using actor-isolated witnesses.
    • Reflow the “Proposed Solution” section to get the bigger ideas out earlier.
    • Remove nonisolated(unsafe).
  • Changes in the fifth pitch:
    • Drop the prohibition on having multiple isolated parameters. We don’t need to ban it.
    • Add the Actor protocol back, as an empty protocol whose details will be filled in with a subsequent proposal for custom executors.
    • Replace ConcurrentValue with Sendable and @concurrent with @Sendable to track the evolution of SE-0302.
    • Clarify the presentation of actor isolation checking.
    • Add more examples for non-isolated declarations.
    • Added a section on isolated or “sync” actor types.
  • Changes in the fourth pitch:
    • Allow cross-actor references to actor properties, so long as they are reads (not writes or inout references)
    • Added isolated parameters, to generalize the previously-special behavior of self in an actor and make the semantics of nonisolated more clear.
    • Limit nonisolated(unsafe) to stored instance properties. The prior definition was far too broad.
    • Clarify that super is isolated if self is.
    • Prohibit references to actor-isolated declarations in key paths.
    • Clarify the behavior of partial applications.
    • Added a “future directions” section describing isolated protocol conformances.
  • Changes in the third pitch:
    • Narrow the proposal down to only support re-entrant actors. Capture several potential non-reentrant designs in the Alternatives Considered as possible future extensions.
    • Replaced @actorIndependent attribute with a nonisolated modifier, which follows the approach of nonmutating and ties in better with the “actor isolation” terminology (thank you to Xiaodi Wu for the suggestion).
    • Replaced “queue” terminology with the more traditional “mailbox” terminology, to try to help alleviate confusion with Dispatch queues.
    • Introduced “cross-actor reference” terminology and the requirement that cross-actor references always traffic in Sendable types.
    • Reference @concurrent function types from their separate proposal.
    • Moved Objective-C interoperability into its own section.
    • Clarify the “class-like” behaviors of actor types, such as satisfying an AnyObject conformance.
  • Changes in the second pitch:
    • Added a discussion of the tradeoffs with actor reentrancy, performance, and deadlocks, with various examples, and the addition of new attribute @reentrant(never) to disable reentrancy at the actor or function level.
    • Removed global actors; they will be part of a separate document.
    • Separated out the discussion of data races for reference types.
    • Allow asynchronous calls to synchronous actor methods from outside the actor.
    • Removed the Actor protocol; we’ll tackle customizing actors and executors in a separate proposal.
    • Clarify the role and behavior of actor-independence.
    • Add a section to “Alternatives Considered” that discusses actor inheritance.
    • Replace “actor class” with “actor”.
  • Original pitch document