Actors
- 提议: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 关键点时间线:
解决方案
Actors
本提议把 actor 引入到 Swift 中。actor 是引用类型,它保护对其可变状态的访问,使用actor
关键字声明:
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
上直接访问。
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
上引用。报错信息表明balance
是actor-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:)
函数的正确版本:
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
:
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引用需要异步调用。transfer(amount:to:)
函数异步(在另一个上)调用上述函数,下列的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
操作,因为在 "get" 和 "set" 间有一个隐式挂起点,可以引入有效的竞争条件。此外,如果需要同时更新两个属性来维护一个不变量,则异步设置属性可能更容易无意中破坏不变量。
从模块外引用,必须从 actor 外部异步引用不可变 let 声明。例如:
// 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
属性;现有客户程序已经使用异步访问,并且不会受到影响。
跨actor引用和Sendable
类型
SE-0302 引入了Sendable
协议。遵守Sendable
协议的类型值可以安全在并发执行的代码中共享(跨并发代码执行)。现在有许多类型通过该协议工作:值语义类型比如Int
和String
,值语义集合比如[String]
或[Int: String]
,不可变类,在内部执行自己同步的类(比如并发 hash 表),等等。
由于 actor 保护它们的可变状态,所以 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
}
即使是非可变的访问也容易出问题,因为当原始调用尝试访问它的同时,在 actor 内部可以修改 person 类的name
属性。为了防止这种 actor-isolated 状态并发可变性的可能,所有跨actor的引用只能包含遵守Sendable
协议的类型。对于某个跨actor的异步调用,其参数和结果类型都必须遵守Sendable
协议。对于某个跨actor的不可变属性引用,该属性类型必须遵守Sendable
协议。通过坚持所有跨actor引用只能使用Sendable
类型(遵守该协议的类型),我们可以确保对共享可变状态的引用只会在 actor 隔离域之内。另外,编译器会为这类问题提供诊断错误信息。例如,对account.primaryOwner()
的调用会出现如下错误:
error: cannot call function returning non-Sendable type 'Person?' across actors
注意primaryOwner()
函数仍然可以用 actor-isolated 代码使用。例如我们定义一个获取所有者名字的函数:
extension BankAccount {
func primaryOwnerName() -> String? {
return primaryOwner()?.name
}
}
primaryOwnerName()
在 actors 间异步调用很安全,因为String
(包括String?
)遵守Sendable
协议。
闭包
只有当我们能确保可能与 actor-isolated 代码发生并发执行操作的代码是非隔离的时候,对跨actor引用的限制才有效。例如,下面例子中的函数是调度月底的生成报告:
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
,会同步调用它提供的闭包:
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 示例,它描述的是"某人想出办法,告诉朋友后返回"。
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 同时开始做思考(异步执行其他 think 方法)的操作,那么它将返回一个任意的意见(Decision 枚举)。
下面代码举例说明这一点:
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>
// suspend: await friend.tell(...) // <2>
opinion = .badIdea // | <4> (!)
// suspend: await friend.tell(...) // | <5>
// resume: await friend.tell(...) // <2>
return opinion // <3>
// resume: await friend.tell(...) // <5>
return opinion // <6>
但它也可能按代码本地顺序执行,即没有交叉行为,这意味着问题只会间歇性出现,就像并发代码中的许多竞争条件一样。
在挂起点可能发生交叉执行,这是要求源代码中的每个挂起点 使用await
标记 的主要原因。它是一个指示符,表明任何共享状态都可能在await
之间发生变化,所以应该避免破坏await
之间的不变量,或取决于"之前"的状态与"之后"的状态相同。
一般来说,避免破坏await
间不变量的最简单方法是把状态更新封装到同步 actor 函数中。实际上,actor 中的同步代码提供一个关键部分,而await
会中断该关键部分。在上面例子中,我们可以通过将"意见形成"与"告诉朋友你的意见"分开来实现这种改变。事实上,告诉你朋友你的观点可能会导致你改变一个更合理的观点!
非重入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
}
}
但是,如果某个任务引入回调(比如call back)到 actor 中,非重入性会带来死锁。我们继续扩展上面例子,比如让我们朋友尝试说服我们改变坏主意:
extension DecisionMaker {
func tell(_ opinion: Judgment, heldBy friend: DecisionMaker) async {
if opinion == .badIdea {
await friend.convinceOtherwise(opinion)
}
}
}
在非重入actor中,thinkOfGoodIdea()
将会按预期执行,因为tell
本质上对.goodIdea
场景不处理(这里也不会形成互相异步等待的环)。但是,thinkOfBadIdea()
会死锁,因为原始 DecisionMaker 实例(称为A)在调用另一个 DecisionMaker 实例时(称为B)被占用锁定。然后B试着说服A(调用 convinceOtherwise 方法),但是A已经死锁了,所以该调用无法执行。
这里讨论的死锁指 actor 之间互相异步等待,不需要线程阻塞表明该问题(通常我们碰见的场景有比如,同步阻塞造成互相等待)。
理论上,一个完全不可重入的 actor 在self
上调用异步函数也会死锁。但是因为这些调用可以在self
上静态确定,它们会被直接执行,不会阻塞。
在非重入actor上一旦发生死锁,能够使用检测循环调用图的运行时工具检测。这些工具与在运行时查找数据结构中循环引用的工具非常相似。尽管能检测,但是无法静态识别这些死锁(比如编译器识别,或者静态分析),因为获取调用图需要完整的程序结构,并且能够随着程序数据动态改变。
死锁的 actor 像不活跃的僵尸,一直存在。一些运行时会给每个 actor 调用增加超时来解决这类死锁问题。这意味着await
可以潜在进行throw
。不管是通过超时还是死锁检测,都有效。我们觉得这个代价非常高,因为可以预想到,actor 会在绝大多数 Swift 并发应用中使用。而且它还会破坏取消操作的结构,因为取消操作的设计原则是取消状态明确且上下协同。因此,自动消除死锁的方式不是很好适合 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]
}
}
不管是否可重入,该函数功能看起来逻辑没问题。如果不可重入,它会完全同步图片的下载操作:一旦某个程序请求获取图片,在该程序完成图片下载和解码之前,所有其他的程序在请求之前都会被阻塞。即使请求的图片可能会命中缓存,或者请求不同的图片 url。
在可重入 actor 中,多个程序可以独立拉取图片,所以说这个程序都可能在下载和解码图片的不同阶段。在 actor 上序列化执行可以确保缓存本身永远不会破坏。在最差的情况下,两个程序同时访问相同的图片 url,这会造成重复的工作。
现有做法
有许多现有的 actor 实现都考虑了可重入性的概念:
- Erlang/Elixir(gen_server)了一个简单的"循环/死锁"场景和怎么去检测并修复它;
- Akka(Persistence persist/persistAsync)实际上是不可重入行为,而且一些特定的API被设计成允许程序员在需要时选择可重入。链接文档中
persistAsync
是可重入API版本,实际当中很少使用。Akka persistence 和此API已用于实现银行事务和流程管理器,它依赖persist()
的不可重用性作为一个致命特性,让实现简单易懂且安全。注意Akka是建立在Scala基础上,Scala没有提供async/await
。这意味着邮箱处理方法本质上更加同步,它们不会在等待响应时阻塞actor,而是将响应作为单独的消息接受来处理。 - Orleans grains 默认也是不可重用的,但是它围绕可重入性提供扩展广泛的配置。Grains和特定的方法可以标识为可重入,甚至还有一种动态机制,通过它实现运行时断言,来确定调用是否可以交叉。Orleans 应该是离本篇谈论的Swift方式最接近的,因为它也是建立在提供
async/await
的语言之上(C#)。注意Orleans有个特性叫调用链重入,我们认为这是一个很有前途的潜在方向:我们将在本提案后面关于任务链重用的部分中介绍它。
可重入性总结
本篇提议仅提供可重入的 actor。但是,"未来方向"一节描述了可能添加可选不可重入性的未来设计方向。
原因:默认情况下,重入性消除了死锁的可能性。而且,它能帮忙确保 actor 在并发系统内按时地处理任务进度,一些特殊的 actor 不会被长时间运行的异步任务阻塞(比如说下载文件任务)。确保安全交叉执行的机制,比如在操作可变状态时使用同步代码,在
await
调用过程中谨慎地防止破坏不变量,都已经在本篇提议体现。
协议一致性
所有 actor 类型都隐式遵循新协议,Actor
:
protocol Actor : AnyObject, Sendable { }
注意:
Actor
协议的定义有意为空。自定义执行器提议 将会引入要求到Actor
协议中。这些需求在没有显式提供时,将由实现隐式合成,但可以显式提供,用来允许 actor 控制它们的同步序列化执行。
Actor
协议可以用来编写跨actor的通用操作,包括为所有的actor类型扩展新操作。与 actor 类型一样,所有定义在Actor
协议中(包括其扩展)的实例属性,函数和下标都跟当前self
是 actor-isolated。例如:
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
协议,像 class, enum, struct 等,因为它们没法定义 actor-isolated 操作。
Actor 还可以遵守具有异步要求的协议,因为所有的实现协议的程序必须跟这些要求异步交互,让 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隔离 的提议,当它们可以通过不引用任何可变 actor 状态的方式实现时,允许这样的协议一致性。
设计细节
Actors
使用actor
关键字声明 actor 类型:
/// Declares a new type BankAccount
actor BankAccount {
// ...
}
每个 actor 实例代表唯一的 actor。术语 "actor" 即可以表示实例,也可以表示类型;必要时,可以引用actor实例
或actor类型
来消除歧义。
Actor 跟其他 Swift 类型(enum,struct,class)很相似。Actor 类型可以用静态方法和实例方法,属性以及下标。也有跟 struct 和 class 一样的存储属性和初始化方法。它们是引用类型,跟 class 一样,但是不支持继承,自然也没有required
和convenience
初始化方法,重载,class
成员,open
以及final
。actor 类型在行为上与其他类型不同的地方主要由 actor 隔离驱动,下面继续描述这一点。
默认情况下,actor 的实例方法,属性和下标有个隔离的self
参数。通过 extension 加在 actor 上的方法也是一样,这跟其他 Swift 类型一样。静态方法,属性以及下标没有作为 actor 实例的self
参数,因为它们不是 actor-isolated。
extension BankAccount {
func acceptTransfer(amount: Double) async { // actor-isolated
balance += amount
}
}
Actor 隔离检查
程序中已有的声明要么是 actor-isolated,要么是 non-isolated。如果某个函数定义在 actor 类型(遵循Actor
的协议,或者它的扩展)上,那么这个函数是 actor-isolated。如果可变实例属性或者实例下标定义在 actor 类型上,那么这些可变实例属性或下标也是 actor-isolated。非 actor-isolated 声明我们称它们为 non-isolated 声明。
在很多地方会检测 actor 隔离规则,在这些地方会比较两个不同的声明以确定它们一起使用是否可以保持 actor 隔离。这样的地方有:
- 当某个声明(比如函数体)的定义引用另一个声明,例如调用函数,访问属性,或者计算下标。
- 当一个声明满足某个协议要求。
我们下面具体讨论这两个场景。
引用和actor隔离
某个 actor-isolated 但非async
声明只能被另一个在相同actor的 actor-isolated 声明同步访问。要同步访问 actor-isolated 函数,必须从另一个 actor-isolated 函数中调用该函数。要同步访问 actor-isolated 实例属性或实例下标,实例本身必须是 actor-isolated。
任何声明都能异步访问一个 actor-isolated 声明,不管该声明对于另一个actor是不是隔离的。这些访问都是异步操作,必须使用await
声明。从上下文语义上来说,程序将切换到 actor 来执行同步操作,然后再切换回调用方的执行器上。
例如:
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
}
}
协议一致性(协议实现)
当给定声明遵守某个协议时,该协议的要求满足以下两点后可以在声明中实现。
- 协议要求是
async
, 或者 - 协议要求和给定声明都是 actor-isolated。
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-Sendable,才允许隔离函数部分应用。例如:
func runLater<T>(_ operation: @Sendable @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: cannot convert value of non-sendable function type () -> Double to sendable function type
}
}
这些限制是从部分应用程序"去糖化"的 actor 隔离规则到闭包。上述两种错误情况都是由于在执行调用的闭包中,闭包是非隔离的。所以对 actor 隔离函数g
的访问必须是异步的。下面是部分应用程序"去糖化"的形式:
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
key path 不能包括 actor-isolated 声明:
actor A {
var storage: Int
}
let kp = \\A.storage // error: key path would permit access to actor-isolated storage
原因:允许 key path 引用 actor-isolated 属性或下标,也会允许从 actor 隔离域之外访问 actor 的受保护状态。作为该规则的另一种选择,我们可以从 key path 中删除
Sendable
,这样可以形成 actor-isolated 状态的 key path,但是它们不能共享。
inout parameters
通过inout
参数,可以把 actor-isolated 存储属性传给同步函数,但如果传给异步函数是不规范的。看个例子:
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-isolated
balance
属性的修改是通过将其作为inout
参数传给随后挂起的调用来启动的,然后同一个参与者上执行的另一个任务尝试访问balance
属性。这样的访问将导致同时读取同一属性(违反排他性),进而程序 Crash。但是该inout
限制不是内存安全所必需的(因为运行时会检测到错误)但 actor 默认重入性会很容易引入不确定的排他性冲突。所以,我们引入该限制是为了消除这类竞争可能引发排他性冲突的问题。
Actor 与 Objective-C 交互
actor 类型可以使用@objc
声明,表明它隐式提供了与NSObjectProtocol
的一致性:
@objc actor MyActor { ... }
如果 actor 成员是async
,或者是 non-isolated,那么它只能是@objc
。在 actor 隔离域内的同步函数是只能在self
上触发(在 Swift 中),所以同步函数成员不能是@objc
。Objective-C 没有 actor-isolated 概念,所以不允许这些@objc
成员导入到 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 或 actor隔离特性的新代码会有影响。
对 ABI 稳定性影响
此更改纯粹是对 ABI 的补充。actor隔离本身是一个静态概念,不属于 ABI 的一部分。
对 API 扩展性影响
在 actor 中几乎所有的更改都是破坏性的更改,因为 actor 隔离要求在声明和它的使用者之间保持一致:
- 类不能转换为 actor, 反之亦然;
- 不能修改公开声明的 actor 隔离状态;
未来方向
不可重入性
我们可以引入@reentrant
属性,加到任何一个 actor-isolated 函数,actor,或者 actor 的 extension 来描述它们怎么重入。这个特性将会有以下构成:
@reentrant
: 表明在函数体内的每个潜在挂起点是可重入的;@reentrant(never)
: 表明在函数体内的每个潜在挂起点是不可重入的;
某个不可重入的潜在挂起点在其本身完成之前,阻止在actor上其他任何异步调用。请注意,直接在self
对不可重入异步函数的异步调用可以不用上述检查,所以 actor 异步调用它自身也不会产生死锁。
原因:允许直接在
self
上调用消除一组明显的死锁现象,并且只需要与actor隔离检查相同的静态知识,就可以同步访问 actor-isolated 状态。
在 non-isolated 函数,非 actor 类型,或者非 actor 类型的扩展上使用@reentrant
属性是错误的。在给定声明中只能出现一个@reentrant
属性。non-isolated 非类型声明的可重入性通过找到合适的@reentrant
属性来确定。搜索结果如下:
- 声明本身;
- 如果声明是 extension 的非类型成员,此时是 extension;
- 如果声明是某个类型的非类型成员(或该类型的 extension),此时是该类型定义。
如果这里没有找到合适的@reentrant
属性,则 actor-isolated 声明是可重入的。
下面这个例子演示@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
:
await(blocking) friend.tell(opinion, heldBy: self)
任务链重入(task-chain reentrancy)
对重入和非重入 actor 的讨论把可重入性看成二元选择,重入性的所有形式都被看作同样可能引入难以理解的数据竞争(data race)。但是,actor 之间频繁且通常相当易于理解的交互方式,也就是多个 actor 之间的对话,为了满足某些初始请求。在同步代码中,两个或者多个类使用同步调用来相互回调是很常见的。例如,下面是关于isEven
的实现,它实现方式很笨,在两个类之间互相递归实现:
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)
}
}
这段代码依赖这两个类的方法在同一个调用栈中高效重入,因为其中一个方法把另一个作为计算的一部分。现在仍以这个例子为例,使用 actors 让它异步化:
@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)
}
}
这段代码将会死锁,因为从EvanEvan.isEven
到OddOddy.isOdd
的调用将依赖另外一个对EvanEvan.isEven
的调用,直到源调用完成,否则无法处理该依赖的调用。需要这些调用可重入以消除死锁。
随着 Swift 将结构化并发作为其并发的核心构建块,我们可能做得比完全禁止重入更好。在 Swift 中,每个异步操作是Task
任务的一部分,Task
封装了正在发生的通用计算,并且从这个任务中派生的每个异步操作又成为当前任务的子任务。因此,我们有可能知道一个给定的异步调用是否是同一任务层次结构中的一部分,这大致相当于同步代码处于同一调用堆栈中。
我们可以引入可重入的新类型,任务链可重入。它允许代表给定的任务或者任意一个其子任务进行可重入调用。这即解决了在死锁章节convinceOtherwise
例子中碰到的死锁问题,也解决上面isEven
手动递归导致死锁的问题,同时防止无关任务的重入。任务链可重入性跟同步代码最接近,消除了许多死锁,不允许无关的交叉执行(重入就会带来交叉,这在上面介绍过)破坏 actor 的高级不变量。
关于在本提议中包含任务链可重入,当前我们不满意的原因有几个:
- 基于任务的可重入方式看起来没有大规模的使用。Orleans 文档中支持 调用链中的重入性,但是具体实现起来相当克制,以至于最后它从支持中 移除 了。从 Orleans 的经验来看,很难评估问题是在于相遇还是具体显现。
- 我们至今没有一个有效的实现技术用来在 actor 运行时中实现它。
如果我们解决了上述问题,我们会在 actor 模型中引入任务链重入。我们可能会使用重入属性比如@reentrant(task)
,并可能提供最佳默认值。
备选方案
Actor 继承
Actor 早期的讨论和本提议的第一个 review 版本允许 actor 继承。Actor 继承遵循类继承的规则,尽管需要特定的规则来维护actor隔离:
- actor 不能与类相互继承;
- 准备重载的声明不能比它被重载的声明更加隔离。这句话可以理解成:如果 A 重载 B,如果 B 是 actor-isolated,那么 A 可以是 actor-isolated 和 non-isolated;如果 B 是 non-isolated,那么 A 不能是 actor-isolated。
后续的讨论确定 actor 继承在概念成本上超过了它的用途,所以最终从本篇提议中移除了。actor 继承在 Swift 语言中所采用的形式从本方案及其实现的先前迭代中都非常易于理解,因此后续会重新引入该特性。
跨 actor let 属性(Cross-actor lets)
这个提议允许在 actor 定义的同一模块内,同步访问 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
}
在 actor 定义的模块之外,必须异步访问它:
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
}
从模块外异步访问的条件为库开发者提供了长期的编程自由,因为这个条件允许一个公有的let
属性在不破坏任何程序的情况下,重构成var
属性。这和 Swift 尽最大可能让库开发者改变库的实现方式而不破坏任何程序的准则是一致的。如果不要求在模块外异步访问,上面otherPrint(account:)
函数可以同步引用accountNumber
属性。然后如果BankActors
模块的作者把该属性改为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
}
还有许多其他语言也采用上述同样的方式来减少模版样例代码,简化模块内的语言。然后从模块外使用实体时需要使用其他语言的特性。比如:
- 访问控制默认是
internal
,所以在模块内我们可以直接使用声明,但是在模块外,我们必须显式指定它的模块(比如通过public
)。换句话说,如果不是提供给另一个模块使用的public
属性,此时你可以忽略访问控制。 - struct 的默认初始化函数是
internal
。为了允许struct可以从指定参数初始化,你需要自定义public
初始化函数。 - 当父、子类在同一个模块中,默认允许直接继承。如果要继承其他模块的类,该类必须显式标记为
open
。 - 当重载的两个对象在同一个模块时,默认允许重载。如果要重载其他模块的类,被重载的声明必须显式标记为
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
}
通过允许在模块内同步访问 actor 的let
声明,我们为actor隔离提供了一条更平滑的学习曲线,并拥抱(而不是颠覆)长期以来普遍存在的观点,即不可变数据对并发来说是安全的。同时仍然解决了第二次 review 中的问题:对 actor-lets 的无限制同步访问 隐含地像库作者承诺永远不要让状态可变。这也遵循 Swift 语言的现有先例:模块内通信比模块间通信更简单。
版本历史
- 对提议审查修订后的变更:
- 在不同模块之间对实例
let
属性的交叉引用必须异步执行;同一个模块内同步执行。
- 在不同模块之间对实例
- 最终版本改动:
- 对实例
let
属性交叉引用必须是异步的。
- 对实例
- 第二次 review 的改动:
- 逃逸闭包可以是 actor-isolated;只有
@Sendable
会阻止隔离。 - 移除 actor 继承。会在后面在引入。
- 在备选方案小节增加"cross-actor lets"。这节不改变当前已经提议的方向,解释的问题也是为后续方向讨论。
- 使用
detach
代替Task.runDetached
,匹配 结构化并发提议 中的更新。
- 逃逸闭包可以是 actor-isolated;只有
- 第七次提议改动:
- 从本提议中移除隔离参数和
nonisolated
。它们会放到 控制 actor 隔离 提议中。
- 从本提议中移除隔离参数和
- 第六次提议改动:
- 让
Actor
协议实例的要求条件对self
是 actor-isolated,并允许 actor 类型通过实现协议条件来遵守这些协议。 - 重排"提议的解决方案"小节。
- 移除
nonisolated(unsafe)
。
- 让
- 第五次提议改动:
- 取消对多
isolated
参数的禁止,我们没必要禁止。 - 加回
Actor
协议,并作为一个空协议实现,它的细节会在 自定义执行器 提议中实现。 - 使用
Sendable
代替ConcurrentValue
,@Sendable
代替@concurrent
与 Sendable与 @Sendable闭包 保持一致。 - 描述清楚actor隔离检查。
- 为非隔离声明添加更多例子。
- 增加一节,用于描述隔离或同步 actor 类型。
- 取消对多
- 第四次提议改动:
- 允许对 actor 属性进行跨 actor 引用,只要他们可读(不是写,或
inout
引用)。 - 添加
isolated
参数,用来概括之前actor里self
上的特殊行为,且让nonisolated
的语义表达更清晰。 - 将
nonisolated(unsafe)
限制为存储实例属性,先前的定义太宽泛。 - 明确如果
self
是 actor-isolated,则super
也是 actor-isolated。 - 禁止在 key paths 中引用 actor-isolated 声明。
- 明确部分程序的行为Partial applications。
- 增加
未来方向
章节,描述隔离的协议一致性。
- 允许对 actor 属性进行跨 actor 引用,只要他们可读(不是写,或
- 第三次提议改动:
- 将提议的范围缩小到只支持可重入 actors。在"备选方案"章节中选择几个有潜力的非重入设计作为后续扩展。
- 使用
nonisolated
修饰符代替@actorIndependent
属性,该属性遵循nonmutating
的方式,并与"actor isolation"术语联系更紧密(这里要感谢 Wu Xiaodi 的建议)。 - 使用更传统的"mailbox"术语来代替"queue"术语,目的是为了尽量避开与 Dispatch queue 混淆。
- 引入"跨actor引用"术语,以及跨actor引用始终以
@Sendable
类型来传输的要求。 - 从独立提议中引用
@concurrent
函数类型。 - 移动 Objective-C 可交互性到对应章节。
- 阐述 actor 类型中跟类相似的行为,比如满足
AnyObject
。
- 第二次提议改动:
- 添加了关于 actor 可重入性,性能和死锁之间权衡的讨论,以及各种示例,并添加了新属性
@reentrant(never)
,以在 actor 或者函数级别禁用可重入性。 - 移除 global actor,放到单独提议中。
- 分开谈论引用类型的数据竞争。
- 允许从 actor 外部对同步的 actor 方法进行异步调用。
- 移除
Actor
协议,放到自定义 actor 和执行器提议中。 - 阐述 actor 独立性的角色和行为。
- 在"备选方案"中增加一小节用来讨论 actor 继承。
- 使用 "actor" 代替 "actor class"。
- 添加了关于 actor 可重入性,性能和死锁之间权衡的讨论,以及各种示例,并添加了新属性
- 原始提议
