概览
actor 是 Swift 5.5+ 中一个“不可思议”的新类型,可以把它看做成一个数据同步器。actor 中所有属性和方法都会被自动“串行”(serializes)访问和执行,从而有效避免了数据竞争的发生。
不过,在一些微妙的情境下使用 actor 仍然可能出现数据竞争的潜在风险,这得从“隐式异步”方法谈起了…
在本篇博文中,您将学到以下内容:
- 概览
- 1. 编译器的神助攻!
- 2. 谁说 actor 就不会发生数据竞争?
- 3. 没有 async 修饰的也可能是“异步”方法?
- 4. 如何让“异步”代码在同步上下文中执行?
- 5. 让暴风雨来的更猛烈些:使用更严格的并发检查
- 总结
1. 编译器的神助攻!
首先,我们创建一个简单的 Asyncor 累加器 actor,可以看到它的 value 属性和 inc() 方法都被要求在 MainActor 上执行,这样貌似可以确保数据的同步行为(真的吗?)。
actor Asyncor {
@MainActor
var value = 0
@MainActor
func inc() {
value += 1
}
}
如果我们尝试在同步上下文中调用它,编译器则会立即发现其中的“不和谐”:
struct Invoker {
static func invoke() {
let asyncor = Asyncor()
asyncor.inc()
}
}
这时,有两种修复方法:
- 在 Task 环境中使用 await 调用 Asyncor#inc() 方法;
- 或者将 Invoker.invoke() 方法也用 @MainActor 来修饰;
struct Invoker {
// 解决方法1:
static func invoke1() {
Task {
let asyncor = Asyncor()
await asyncor.inc()
}
}
// 解决方法2:
@MainActor
static func invoke2() {
let asyncor = Asyncor()
asyncor.inc()
}
}
有了编译器的“火眼金睛”,我们可以立即找到异步代码中的问题,并迅速修正它们。
那么,编译器能否始终保持“滴水不漏”、“明察秋毫”呢?
2. 谁说 actor 就不会发生数据竞争?
我们再来看一个“栗子”,还拿上面的 Asyncor 说事。
现在,我们在两个后台队列中累加 Asyncor 的值 10000 次:
let group = DispatchGroup()
let queue_0 = DispatchQueue.global()
let queue_1 = DispatchQueue.global()
// 共累加 5000 * 2 = 10000 次
for i in 0..<5000 {
queue_0.async(group: group) {
print("q0: \(i)")
asyncor.inc()
}
queue_1.async(group: group) {
print("q1: \(i)")
asyncor.inc()
}
}
group.notify(queue: DispatchQueue.global()) {
Task {@MainActor in
print("10000 次累加的总和为:\(asyncor.value)")
}
}
默认情况下,以上代码在编译时不会有任何错误。按道理来说,在 Asyncor actor 中的 value 属性和 inc() 方法都受 @MainActor 的保护,所以上面代码最后累加的结果一定是 10000!
真是这样么?
理想很丰满,现实却啪啪打脸!
从上面运行结果可以看到:最终的累加结果并不等于 10000。What’s wrong with it!?
我们经过分析发现 inc() 方法竟然不是在主线程而是在其它线程中执行的,这不禁令人“大跌眼镜”:难怪会出现数据竞争!
那么问题来了:为什么 @MainActor 修饰的 inc() 方法没有在主线程上执行呢?
3. 没有 async 修饰的也可能是“异步”方法?
细心的小伙伴们可能发现了一些蛛丝马迹:Asyncor actor 中的 inc() 方法并没有被 async 修饰!那么它到底是不是异步方法呢?
虽然 Asyncor#inc() 实例方法没有被 async 所修饰,但它前面赫然在列的 @MainActor 却强烈暗示我们它需要在主线程上执行。
我们称这种在 actor 内却未被 async 修饰的方法称为“隐式”异步方法。
何谓“隐式”呢?在 actor 中的方法如果未用 async 修饰,当从 actor 外部调用该方法时,它就变成一个“隐式(implicitly asynchronous)”异步方法。
“隐式”异步方法有如下特点:
- 若它在异步上下文中调用需要加上 await 修饰,否则无法通过编译;
- 若它在某些非异步环境(比如 DispatchQueue )中调用,可以通过编译但其异步约束(@MainActor)实际不会起作用;
相反的,对于一个“显式”异步方法(即被 async 修饰的方法),不用 await 修饰是无法过编译器这一关的:
actor Asyncor {
@MainActor
var value = 0
@MainActor
func inc() {
value += 1
}
// async_inc() 是一个“显式”异步方法
@MainActor
func async_inc() async {
value += 1
}
}
Text("Hello Swift")
.onAppear {
DispatchQueue.global().async {
let asyncor = Asyncor()
// “隐式”异步方法可以不加 await 修饰,但结果可能不是我们想要的
asyncor.inc()
// “显式”异步方法必须要 await 修饰,以下一行代码无法通过编译
asyncor.async_inc()
}
}
所以小伙伴们知道上面代码中产生数据竞争的原因了吗?虽然 Asyncor#inc() 被 @MainActor 修饰,但无奈何它是一个“隐式”异步方法且在非异步的上下文中执行,所以 @MainActor 没有起到实际的作用。
因为没有用 await 修饰,所以另一个隐含的意味是它的执行不会被挂起。
要想修复这个问题很简单,我们只需将 Asyncor#inc() 方法放在异步上下文中就可以遵守 @MainActor 的约束了:
actor Asyncor {
@MainActor
var value = 0
@MainActor
func inc() {
value += 1
}
}
let asyncor = Asyncor()
let group = DispatchGroup()
let queue_0 = DispatchQueue.global()
let queue_1 = DispatchQueue.global()
// 共累加 5000 * 2 = 10000 次
for i in 0..<5000 {
queue_0.async(group: group) {
print("q0: \(i)")
Task {
await asyncor.inc()
}
}
queue_1.async(group: group) {
print("q1: \(i)")
Task {
await asyncor.inc()
}
}
}
group.notify(queue: DispatchQueue.global()) {
Task {@MainActor in
print("10000 次累加的总和为:\(asyncor.value)")
}
}
4. 如何让“异步”代码在同步上下文中执行?
如果是真·异步方法则是无论如何也不能在同步上下文中执行的。不过对于“隐式异步”方法来说,我们可以让其绕过编译器的检查。
比如,在下面的 invoke() 方法闭包中默认是无法执行“隐式”异步方法的:
func invoke(_ block: () -> Void) {
block()
}
struct Invoke {
func test() {
invoke {
let asyncor = Asyncor()
asyncor.inc()
}
}
}
不过,我们可以用 @preconcurrency 修饰器来“骗取”Swift 编译器的信任:
@preconcurrency
func invoke(_ block: () -> Void) {
block()
}
@preconcurrency 修饰的 invoke() 方法闭包中可以直接调用“隐式”异步方法。
在 SwiftUI 视图中也可以直接调用 invoke() 方法而无需 @preconcurrency 的修饰:
func invoke(_ block: () -> Void) {
block()
}
struct ContentView: View {
var body: some View {
Text("Hello Swift")
.onAppear {
invoke {
let asyncor = Asyncor()
asyncor.inc()
}
}
}
}
这是因为 SwiftUI 视图的 body 本身就被 @MainActor 修饰着:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {
associatedtype Body : View
@ViewBuilder @MainActor var body: Self.Body { get }
}
不过强烈不推荐大家这样做!
因为这样一来,那些“隐式”异步方法的执行环境可能不是我们想要的。
5. 让暴风雨来的更猛烈些:使用更严格的并发检查
其实,在最新的 Xcode 中使用如上伎俩还是会被编译器有所察觉:
如果我们希望让编译器变得更加“严厉”,可以在项目的编译设置中选择更加严格的并发代码检查选项:
如果大家喜欢自我挑战,可以选择最严格的 Complete
并发检查选项,这是 Swift 6 中默认的“味道”。
所以,这样我们就可以提前感受和拥抱 Swift 6 的降临了,棒棒哒!💯。
更多 Swift 语言中并发编程的相关知识,请小伙伴们移步到我的专题专栏中系统的学习吧:
- Swift 语言开发精讲(文章平均质量分 97)
总结
在本篇博文中,我们讨论了被 @MainActor 修饰的“隐式”异步方法也有可能不在主线程上下文中执行这一隐藏的陷阱,并对其原因和解决办法做了详细的说明。
感谢观赏,再会!😎