在Swift中使用Actors防止数据竞争
hudson 译 原文
数据竞争------所有开发人员最糟糕的噩梦!它们很难检测,非常不可预测,而且极难修复。苹果为开发人员提供了各种工具集,如NSLock
和串行队列,以防止在运行时发生数据竞争,然而,它们都无法在编译时捕获竞争条件。随着Swift 5.5的发布,情况将不再如此!
Swift 5.5引入了Actor,这是新的Swift语言功能,可以帮助开发人员在开发期间捕捉任何可能的竞争条件。在本文中,我们将首先了解在使用调度队列(DispatchQueue)和异步任务时如何发生数据竞争。然后,我们将看看Actors如何帮助我们识别代码中的竞争条件,并一劳永逸地防止它们发生!
不要再浪费时间了,让我们直接开始吧。
数据竞争是如何发生的?
当2个或更多线程试图同时异步访问(读/写)相同的内存位置时,就会发生数据竞争。在Swift的上下文中,当我们尝试使用调度队列修改对象的状态时,通常会发生这种情况。我这是什么意思?
考虑以下Counter
类,该类具有count
计数变量,每次调用addCount()
函数时,它都会增加1:
swift
class Counter {
var count = 0
func addCount() {
count += 1
}
}
现在,假设我们有一个按钮,可以触发以下代码:
swift
let totalCount = 1000
let counter = Counter()
let group = DispatchGroup()
// Call `addCount()` asynchronously 1000 times
for _ in 0..<totalCount {
DispatchQueue.global().async(group: group) {
counter.addCount()
}
}
group.notify(queue: .main) {
// Dispatch group completed execution
// Show `count` value on label
self.statusLabel.text = "\(counter.count)"
}
基本上,上述代码的作用是使用调度组异步调用Counter
的addCount()
函数1000次。一旦调度组执行完成,我们将在一个标签控件上显示Counter
的count
的值。
理想情况下,每次点击按钮时,我们都应该看到标签上显示1000,但情况并非如此。我们得到的结果非常不一致,我们可能会偶尔得到1000,但我们得到的值通常小于1000。
正如您可能已经猜到的,这种不一致是由数据竞争引起的。当多个线程(由调度队列生成)尝试异步访问计数时,不能保证每个线程会一个接一个地更新计数值。因此,导致我们获得的最终结果非常不一致,很难预测。
专业提示:
Xcode有一个Thread Sanitizer ,可帮助开发人员以更一致的方式检测数据竞争。您可以通过导航到Product > Scheme > Edit Scheme... 来启用它。 之后,在编辑方案对话框中选择 运行>诊断> ,勾选Thread Sanitizer复选框。
现在您已经知道数据竞争是如何发生的,如果我们使用异步/等待和异步任务做同样的事情,会发生数据竞争吗?让我们来了解一下!
异步任务和数据竞争
在Swift并发领域,任务和任务组的工作原理与调度队列和调度组类似。我们可以通过创建一个父任务来实现之前的数据竞争条件,该任务生成一组异步执行addCount()
函数的子任务。方法如下:
swift
let totalCount = 1000
let counter = Counter()
// Create a parent task
Task {
// Create a task group
await withTaskGroup(of: Void.self, body: { taskGroup in
for _ in 0..<totalCount {
// Create child task
taskGroup.addTask {
counter.addCount()
}
}
})
statusLabel.text = "\(counter.count)"
}
在上面的代码中,我们使用withTaskGroup(of:body:)
方法来创建任务组。在任务组中,我们创建1000个子任务,以异步执行addCount()
函数。值得一提的是,withTaskGroup(of:body:)
方法是可等待的,因此它将暂停,直到所有子任务完成。一旦发生这种情况,我们将在标签上显示计数值。
当我尝试运行上面的代码时,我得到的结果出乎意料地一致!每次代码完成执行时,我都可以看到标签上显示1000。这是否意味着当我们使用任务和任务组时,数据竞争不会发生?🤔
不幸的是,答案是否定的!
当我尝试在启用Thread sanitizer 的情况下运行上述代码时,我仍然会收到Thread sanitizer 警告,这表明确实发生了数据竞争。
如果是这样,那么我们为什么能够获得如此一致的结果呢?我的猜测是,苹果在优化整个Swift并发模块方面做得很好,因此它能够解决简单的数据竞争条件,就像我们在示例代码中一样。
使用调度队列时,我们可以通过使用串行调度队列来防止并发写入来避免数据竞争。如果我们使用异步任务,我们应该使用什么来防止并发写入?这就是引进Actor的原因。
充当救援队的Actor
Actor是Swift 5.5中引入的一项新语言功能,主要用于帮助开发人员在开发期间识别任何可能的数据竞争条件。正如您稍后将看到的,每当我们尝试编写可能导致数据竞争的代码时,编译器都会给我们一个编译错误。如果你不熟悉Actor的工作方式,你可以参考我之前的文章,其中谈到了Actor的基础知识。
现在让我们试着把Counter
类换成一个actor。我们需要做的是用actor
取代class
,仅此而已!
swift
actor Counter {
private(set) var count = 0
func addCount() {
count += 1
}
}
在这个阶段,我们的示例代码将在我们尝试访问count
变量的地方给出2个编译错误。
错误消息"Expression is 'async' but is not marked with 'await'"到底是什么意思?这意味着我们不能像这样简单地访问count
变量!
由于Counter
现在是一个actor ,它一次只允许1个异步任务访问其可变状态(count
变量)。因此,如果我们要访问count
变量,我们必须将两个访问点标记为await
,指示如果有另一个任务访问count
变量,这些访问点可能会暂停。
swift
let totalCount = 1000
let counter = Counter()
// Create a parent task
Task {
// Create a task group
await withTaskGroup(of: Void.self, body: { taskGroup in
for _ in 0..<totalCount {
// Create child task
taskGroup.addTask {
// Marked with await
await counter.addCount()
}
}
})
// Marked with await
statusLabel.text = "\(await counter.count)"
}
值得一提的是,actors将保护其可变状态免受[多线索]读写访问。这就是为什么我们的示例代码中的两个访问点上都出现了编译错误。
情况就是这样,我们介绍了如何通过使用Actor来防止数据竞争赛。如果你想亲自尝试示例代码,请在这里 随时获取。
小结
在Swift中引入Actors绝对是值得欢迎的。它使我们能够用很少的编码工作来编写更安全的异步代码。事实上,它是一个语言功能,使它能够在编译时捕获任何可能的竞争条件,从而防止我们意外地将数据竞争引起的错误发送给我们心爱的应用程序用户。
感谢您的阅读。👨🏻💻