概述
伴随着 Swift 5.5(WWDC21)推出的新结构化并发到今年的 WWDC 24 已经有 3 个多年头了。想必大家都对其中 async/awiat、async let、TaskGroup、Actor 等各种概念都了然于胸了吧?
不过小伙伴们可能不知道的是:新结构化并发(或叫现代结构化并发)中还有一个“隐藏宝藏”,它就是 isolated 参数。
在本篇博文中,您将学到如下内容:
- 概述
- 1. isolated 参数(isolated parameters)简介
- 2. isolated parameters 的作用
- 3. isolated parameters 的应用场景
- 总结
Swift 现代结构化并发中 isolated parameters 的存在对于某些应用场景有着不可或缺的重要意义!不信?且看分晓!
Let‘s go!!!😉
1. isolated 参数(isolated parameters)简介
在 Swift 5.5 新并发模型中有一个 isolated parameters 的概念。它很好理解:isolated 其实是一个关键字,isolated 可以用在修饰方法或闭包的参数上。
空说无凭,撸码为证!
在下面的代码中我们将 isolated 关键字应用在了 run 方法的 god 参数上:
actor GodActor {}
func run(_ god: isolated GodActor) {}
与此类似,isolated 关键字同样可以应用在闭包中的参数上:
actor GodActor {
func handler<R>(
_ action: @Sendable (_ god: isolated GodActor) throws -> R
) rethrows -> R {
try action(self)
}
}
值得注意的是:isolated 关键字修饰的参数类型必须是一个 Actor。
如果违反这一条,编译器就会立即毫不留情的“勃然大怒”:
‘isolated’ parameter type ‘Int’ does not conform to ‘Actor’ or ‘DistributedActor’
在了解 isolated parameters 的用法之后,满腹狐疑的小伙伴们肯定要问了:那么它到底是干嘛滴的呢?
2. isolated parameters 的作用
isolated 参数的作用非常简单:无论它被用来修饰方法或是闭包中的 Actor 参数,都意味着该方法或闭包在运行时都会限定在此 Actor 所在的上下文中。
An isolated parameter means the function runs on whatever actor is passed in.
所以任何方法或闭包都只能有一个被 isolated 修饰的 Actor 参数,否则天知道它要被用在哪个 Actor 上了。
还拿之前的 run() 方法来说,它有点像下面代码的意思:
extension MyActor {
func run() { ... }
}
不过,包含 isolated 参数方法的重要意义在于:我们可以将特定 Actor 的同步上下文传递到任何方法或闭包的执行中去了。
不知小伙伴们发现了没有,上面包含 isolated 参数的 run() 或 handler() 方法本身都没有再被 async 所修饰,这是有意而为之的!因为像其它异步并发隔离(isolation)一样,它们在调用时要不要加上 await 关键字取决于当时的执行语境。
这有点像某种“隐式异步”方法。更多关于“隐式异步”方法的介绍请小伙伴们移步如下链接观赏精彩的内容:
- Swift 警惕“隐式异步(implicitly asynchronous)”方法的执行陷阱
说了上面这么一大堆,可能有的小伙伴还是搞不清 isolated 参数存在的真谛吧?
别急,下面我们就用一个活灵活现的例子让大家彻底茅塞顿开!
3. isolated parameters 的应用场景
假设我们要绕过 CoreData 直接写一个 SQLite 数据库的包装器。
我们会用该包装器来执行 SQLite 数据库中的一些 SQL 命令,比如查询、插入、修改和删除等等。为了处理好数据库操作中的同步问题,我们新创建一个 Connection Actor 来排忧解难:
public actor Connection {
public func execute(_ query: String) throws {
//...
}
}
比如,当我们想向数据库中插入对象时可以这么写:
let conn = Connection()
await conn.execute("INSERT INTO table1 VALUES ('a', 'b', 'c')")
但是,随后我们可能发现每次执行单个 SQL 语句是效率极其低下的,我们更希望能够以“原子的”方式一次性执行多条 SQL 语句。
所谓以“原子的”方式意思是:
- 要么所有 SQL 语句都执行成功,数据库被正确更新;
- 若其中有任何一条 SQL 语句出错,那么就好像所有语句都没有被执行一样——数据库保持原封不动;
这种以“原子的”执行方式称为事务(Transactions),SQLite 数据库或任何其它现代数据库都对其提供了更好的支持。我们利用这一点可以很轻松的在 Connection 中实现一个 transaction 方法来“拔刀相助”:
public actor Connection {
...
@discardableResult
func transaction<R>(
_ action: @Sendable (_ connection: isolated Connection) throws -> R
) throws -> R {
try execute("BEGIN TRANSACTION")
do {
let result = try action(self)
try execute("COMMIT TRANSACTION")
return result
} catch {
try execute("ROLLBACK TRANSACTION")
throw error
}
}
}
现在,我们可以这样调用 transaction 方法来实现 SQLite 包装器对事务的支持了:
let conn = Connection()
conn.transaction {
$0.execute("INSERT INTO table1 VALUES ('a', 'b', 'c')")
$0.execute("INSERT INTO table2 VALUES ('d', 'e', 'f')")
}
可能眼尖的小伙伴们已经发现了:上面 transaction 方法闭包中的 connection 参数是被 isolated 关键字所修饰着的。
按照之前的解释,这说明 transaction 方法闭包会在 connection Actor 的上下文中执行,它由此引来的重要推论是:transaction 闭包中所有代码的执行都不会被打断!
这一点非常关键!
如果我们不用 isolated 来修饰 transaction 方法闭包中的 connection 参数,那么它就会是下面这个样子:
public actor Connection {
...
@discardableResult
func transaction<R>(
_ action: @Sendable (_ connection: Connection) async throws -> R
) throws -> R {
...
}
}
connection.transaction {
await $0.execute("INSERT INTO table1 VALUES ('a', 'b', 'c')")
await $0.execute("INSERT INTO table2 VALUES ('d', 'e', 'f')")
}
如上代码所示,现在 transaction 方法中的闭包必须被 async 所修饰,这带来的直接后果就是:其内部的所有 execute() 方法的调用前面都要加上 await 关键字!
回忆一下,任何用 await 关键字所修饰方法的执行都有可能被挂起!大家知道在并发执行中挂起意味着指令流可能会被打断,从而引起重入(Reentrancy)问题。
重入问题会导致隔离一致性被打破。更多关于 Actor 重入问题的讨论请小伙伴们移步如下链接观赏:
- 深入理解 Swift 新并发模型中 Actor 的重入(Reentrancy)问题
回到上面的例子,执行用 await 修饰的两条 execute() 方法是非常危险的!因为这可能会导致我们的事务执行到一半被挂起(suspend),如果此时相同的 Connection 对象中有另一个任务开始执行就会发生嵌套(nested)事务的错误(在调用 COMMIT TRANSACTION / ROLLBACK TRANSACTION 之前又调用了 BEGIN TRANSACTION)。
而使用 isolated 关键字则恰恰可以避免这种情况!因为这时 transaction 方法中任何与 Connection 相关方法的调用都无需用 await 修饰,从而不会发生潜在的挂起行为。
这就是 isolated parameters 存在的真谛啊!棒棒哒!
更多关于 Swift 新结构化并发中同步问题的例子,请小伙伴们到下面的博文中观赏精彩的内容:
- SwiftUI async/await 并发代码提示 Non-sendable type cannot cross actor boundary 警告的解决
- Swift新async/await并发中利用Task防止指定代码片段执行的数据竞争(Data Race)问题
总结
在本篇博文中,我们介绍了 Swift 现代并发模型中少有人知的 isolated parameters 机制,并用了一个非常通俗易懂的“栗子”让大家豁然开朗!
虽然 isolated parameters 不是那种我们在撸码中天天都会用到的解决方案,但在某些场景下它的确能够为我们扶危拯溺,雪中送炭!
感谢观赏,再会!😎