一、 前言
Swift 5.5 引入的结构化并发(Structured Concurrency)让我们告别了 Completion Handler 的地狱。但你以为用了 Task 和 Actor 就万事大吉了吗?最近在处理一个"账户存款"的 Demo 时,我发现即便代码在编译器看来是"内存安全"的,业务逻辑却依然会崩塌。
二、 概念对撞:Task vs MainActor
- Task: 异步任务的最小单位。它就像一个轻量级线程,可以挂起和恢复。
- MainActor : 一个全局独占的执行器,确保代码在主线程运行。它是
DispatchQueue.main.async的现代声明式替代品。
三、 踩坑演进:一个存款功能的"翻车"现场
第一阶段:初生牛犊(直接加锁思维)
我们想实现一个余额上限为 100 的存款功能。最初的代码长这样:
swift
actor BankAccount {
private var balance: Double = 0
func deposit(amount: Double) async {
guard balance < 100 else { return } // 初始检查
let rate = await fetchExchangeRate() // 模拟联网挂起
balance += amount * rate
print("✅ 存款完成,余额: \(balance)")
}
}
运行结果: 当并发 5 个存款任务时,最终余额竟然变成了 150.0 ! 原因分析 :这是典型的 Actor Reentrancy(重入性) 。当第一个任务在 await 处挂起时,锁被释放,后续 4 个任务全部通过了 guard balance < 100 的检查。
第二阶段:进阶防御(双重检查锁)
意识到重入性后,我在 await 之后加了二次检查:
swift
func deposit(amount: Double) async {
guard balance < 100 else { return }
let rate = await fetchExchangeRate() // 挂起点
// 💡 二次检查:防止挂起期间被别人偷跑
guard balance < 100 else {
print("🚨 余额已达上限,取消存款")
return
}
balance += amount * rate
}
运行结果: 虽然拦截了一部分,但最终余额依然可能出现 130.0 ! 原因分析 :balance < 100 只能保证当前没满,但不能保证"加完之后"不满。如果当前余额 90,任务 A 存 40,检查通过,结果就变成了 130。内存安全不等于业务原子性。
第三阶段:终极方案(预判式原子检查)
要实现绝对的业务一致性,必须在加法前进行"结果预判":
swift
func deposit(amount: Double) async {
let rate = await fetchExchangeRate()
// 💡 架构师思维:计算预期结果并校验
let projectedBalance = balance + (amount * rate)
guard projectedBalance <= 100 else {
print("🚨 严格拦截:存入后将达 \(projectedBalance),已超限!")
return
}
balance = projectedBalance
print("✅ 存款成功,最终余额: \(balance)")
}
四、 深度总结:避坑指南
- Actor 不是万能锁 :它只保证同一时间只有一个任务修改变量(内存安全),不保证跨越
await的逻辑连贯性(逻辑原子性)。 - await 之后是"新世界" :永远记住,
await回来后,self的所有状态都可能已经被改过了。 - 不要在 await 之前做关键决策 :如果业务逻辑依赖于状态,务必在
await恢复后的第一行重新获取快照或校验。 - MainActor 的边界 :在
Task中调用MainActor方法必须await,这是编译器在帮你强制执行线程切换。
五、 结语
Swift 并发模型将很多运行时崩溃变成了编译期错误,这是巨大的进步。但作为开发者,我们不能丧失对并发逻辑的敬畏。Actor 重入性是每一位 iOS 工程师进阶路上的必修课。