- 提议: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
关键字声明:
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引用当作异步调用,只要引用是只读访问:
代码语言: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 一直维护的特性:
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引用的限制才有效。例如,下面例子中的函数是调度月底的生成报告:
代码语言: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
,会同步调用它提供的闭包:
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 //