将 Swift 模型迁移到 actor 时,要保留原子比较并替换操作,请将比较和赋值放在同一个同步的 actor 隔离方法中。调用方使用 await,但方法本身在检查和替换之间不得挂起。
本指南面向一位 Mac 开发者,他正在迁移一个此前由锁保护的小型桌面模型。具体任务是:仅当合成形状标签的当前值仍与预期值匹配时,才替换该标签。示例完全在内存中运行,不使用网络请求、外部记录或持久化。
更改模型前先定义值契约
在本示例中,compareAndReplace(expected:replacement:) 有三条规则:将当前标签与 expected 比较;如果相等,则赋值为 replacement 并返回 true;否则,返回 false,且不更改标签。比较使用 Swift 的值相等性。标准库的 Equatable 文档通过 == 定义这种比较,并区分值相等性与类实例标识。
比较必须针对操作执行时的当前值。较早的快照可以提供预期值,但不能据此确定稍后应当成功替换。这个决定应由负责该变更的操作在内部作出。
| 当前值 | 预期值 | 替换值 | 结果 | 操作后的值 |
|---|---|---|---|---|
"Circle" |
"Circle" |
"Square" |
true |
"Square" |
"Square" |
"Circle" |
"Triangle" |
false |
"Square" |
"Square" |
"Square" |
"Square" |
true |
"Square" |
最后一行是有意这样设计的:成功意味着比较匹配且赋值已被接受,并不表示可见值发生了变化。迁移时改变这一返回约定,就会改变契约。
在基于锁的实现中,整个比较和赋值都必须处于同一个受保护的临界区。Apple 将 Foundation 的 NSLock说明为一种保护临界区、使其原子运行的方式。迁移到 actor 时,要保留这一操作边界:将整个判断移入 actor,而不是把各个部分分散给调用方。
这里的“原子”是指 actor 上的其他操作不能在比较和赋值之间插入。本示例不声称使用了硬件原子指令或无锁执行。
为什么调用方的 await 不会中断该操作
从 actor 隔离域之外调用同步 actor 方法时,可能需要 await。调用方可能需要挂起,等待进入 actor。一旦方法开始执行,其普通同步方法体内没有挂起点,因此其他任务无法进入并更改同一个 actor 的状态。Swift 在 SE-0306:Actors 中说明了这一区别。
语言指南建议将更新封装在同步 actor 方法中,因为这类方法不会包含潜在的挂起点。Apple 的 actor 专题也解释说,同步 actor 代码会执行完毕,不会有其他任务中断其 actor 隔离执行。这些保证是推导本实现的依据。请参阅 Concurrency 和 使用 Swift actors 保护可变状态。
因此,有用的边界是一次 actor 调用,其中同时包含条件判断及其产生的变更。调用方可以先读取快照、执行本地计算,然后提交条件替换。actor 处理该提交时必须再次检查条件。
分开读取和写入会出什么问题?
假设迁移后提供了独立的快照读取方法和无条件替换方法。某个调用方读取到 "Circle"。另一个调用方将标签改为 "Square"。第一个调用方随后基于先前的读取结果写入 "Triangle"。每个独立方法都可能遵守 actor 隔离,但整个操作序列仍会违反预期的比较并替换契约。
这个示意执行顺序解释了问题,不依赖特定任务调度器。actor 隔离保护每个操作;程序员仍需选择足够大的操作范围,以保留应用程序的条件。
在 Mac 上构建完整的本地示例
请使用已安装的 Swift 6 工具链。Swift 官方的 macOS 安装指南介绍了通过 Swiftly 安装以及使用 Xcode 进行开发。在终端中,先检查当前选用的编译器:
swiftc --version
将以下完整程序保存到工作文件夹中的 CompareReplace.swift。它只使用 Swift 标准库,无需显式导入即可使用。该文件定义了 actor、初始状态、所有示例输入以及异步入口点。
actor ShapeLabelModel {
private var label: String
init(initial: String) {
label = initial
}
func snapshot() -> String {
label
}
func compareAndReplace(
expected: String,
replacement: String
) -> Bool {
guard label == expected else {
return false
}
label = replacement
return true
}
}
@main
struct CompareReplaceDemo {
static func main() async {
let model = ShapeLabelModel(initial: "Circle")
let matched = await model.compareAndReplace(
expected: "Circle", replacement: "Square"
)
let afterMatch = await model.snapshot()
precondition(matched && afterMatch == "Square")
let stale = await model.compareAndReplace(
expected: "Circle", replacement: "Triangle"
)
let afterStale = await model.snapshot()
precondition(!stale && afterStale == "Square")
let unchanged = await model.compareAndReplace(
expected: "Square", replacement: "Square"
)
precondition(unchanged)
let contested = ShapeLabelModel(initial: "Circle")
async let first = contested.compareAndReplace(
expected: "Circle", replacement: "Square"
)
async let second = contested.compareAndReplace(
expected: "Circle", replacement: "Triangle"
)
let (firstWon, secondWon) = await (first, second)
let finalLabel = await contested.snapshot()
precondition(firstWon != secondWon)
precondition(
finalLabel == (firstWon ? "Square" : "Triangle")
)
let cycle = ShapeLabelModel(initial: "Circle")
let oldSnapshot = await cycle.snapshot()
let movedAway = await cycle.compareAndReplace(
expected: "Circle", replacement: "Square"
)
let movedBack = await cycle.compareAndReplace(
expected: "Square", replacement: "Circle"
)
let matchedAgain = await cycle.compareAndReplace(
expected: oldSnapshot, replacement: "Triangle"
)
let afterCycle = await cycle.snapshot()
precondition(movedAway && movedBack && matchedAgain)
precondition(afterCycle == "Triangle")
print("Matching replacement: \(matched)")
print("Stale replacement: \(stale)")
print("Equal replacement: \(unchanged)")
print("Exactly one competing replacement succeeded.")
print("A restored value matches the old snapshot.")
}
}
在该文件夹中编译并运行程序。-parse-as-library 选项用于适配显式的 @main 入口点,而 -swift-version 6 则选择 Swift 6 语言模式。
swiftc -swift-version 6 -parse-as-library CompareReplace.swift -o compare-demo
./compare-demo
根据程序中的检查,预期输出如下:
匹配的替换:true
过期的替换:false
相同值的替换:true
恰好有一个竞争替换成功。
恢复后的值与旧快照匹配。
我已根据文档检查代码,但未在此处执行,因为当前环境没有 Swift 编译器。运行程序时,其中的前置条件会提供本地检查;这并不意味着代码已实际执行。
竞争调用说明了什么
两个 async let 声明会创建子任务,不会等第一个结果返回后才启动第二个任务。这种结构化并发的用法遵循语言指南中关于并发异步调用的说明。两个调用方都期望值为 "Circle",并且各自提出不同的替换值。
针对这种特定设置,恰好只有一个调用成功:先执行的调用会将值改为不再是 "Circle" 的值;随后另一个调用的比较便会失败。测试允许任一调用获胜。正如 SE-0306 所述,Swift 不保证等待 actor 的任务会按最初开始等待的顺序执行。
本示例还将已等待的调用与 precondition 表达式分开处理。它先将每个结果存入局部常量,再检查该常量。这样,异步工作就不会出现在同步断言表达式中。
将异步准备工作放在最终比较之外
未来的版本可能会使用其他合成的本地工作异步计算拟议标签。请在调用同步的比较并替换方法之前完成准备工作。将原始预期值和计算出的替换值一并传入;将 false 视为一次被拒绝的尝试。
如果某个 actor 方法先检查相等性、等待准备工作,然后无条件赋值,就会产生空档。挂起期间,另一个任务可以更改 actor 的状态。方法恢复后,就会依赖一个可能已经不再成立的条件。Swift 文档中的 actor 可重入模型明确允许这种交错执行。
如果准备工作位于异步 actor 方法中,请在准备工作完成并返回后调用同步比较方法。这个最终方法必须重新执行比较。挂起前的检查可能有助于避免不必要的工作,但不能授权之后的赋值。
如果不匹配,且桌面界面需要显示拟议结果,就保留该结果,获取新的快照,再决定重新计算还是报告冲突。自动替换预期值并重试,会把请求从“替换我观察到的值”改成“针对更新后的值持续重试”。应将这种行为作为明确的产品决策。
不要在 await 期间一直持有旧的 Foundation 锁。Apple 要求 NSLock 必须由加锁时的同一线程解锁。这里展示的 actor 设计通过一个短暂的同步隔离操作实现所需效果,无需在异步工作期间继续持有锁。
保留相等性语义并认识其局限
循环检查展示了一个微妙的边界。调用方取得 "Circle" 的快照;模型先改为 "Square",再改回 "Circle"。由于契约比较的是当前值,而不是导致当前值的变更序列,因此与旧快照的比较会成功。
如果你的实际需求是“只有自快照以来尚未发生任何已接受的变更时才替换”,那么仅比较值是不够的。一种可行的设计是返回一个同时包含值和修订令牌的快照,然后在同一个同步 actor 方法中检查令牌并更新值。这是一个更强、也不同的契约。实现之前应定义令牌递增及耗尽时的行为;不要在保留值语义的迁移中悄悄引入它。
对于较大的模型,请使用一个快照值,并确保其相等性涵盖替换所涉及的所有字段。根据 Equatable 参考文档,当结构体的所有存储属性都遵循 Equatable 时,Swift 可以为其合成 Equatable。如果相等性实现忽略了相关字段,就可能在该字段已更改的情况下仍允许替换。
跨 actor 边界传递的值应适用于并发使用。Sendable 文档说明,可发送值可以在并发域之间传递,而可发送结构体必须包含可发送成员。将任意可变引用包装在结构体中,并不能证明它满足这一属性。
对于这个本地模型,迁移在以下条件满足时即告完成:actor 拥有可变标签,所有条件替换都通过组合操作进行,且调用方按照原契约解释其布尔结果。让最终比较和赋值保持在一起,再由外围桌面工作流程决定如何处理被拒绝的尝试。
来源
- NSLock — Apple Developer Documentation。
- Equatable — Apple Developer Documentation。
- Sendable — Apple Developer Documentation。
- Concurrency — The Swift Programming Language,Swift 项目。
- Actors — SE-0306 — Swift Evolution,Swift 项目。
- 使用 Swift actors 保护可变状态 — Apple Developer,WWDC21。
- 安装 Swift — macOS — Swift.org。