Swift 并发入门
hudson 译 原文
编写异步代码对开发人员来说一直是一项具有挑战性的任务。多年来,苹果提供了各种工具,如GCD(Grand Central Queue )、操作(operations )和调度队列(Dispatch Queue),帮助开发人员编写异步代码。所有这些工具都很棒,但每个工具都有自己的利弊。
在今年的WWDC中,苹果将这一点提升到了一个新的水平,并引入了Swift并发性,这是一种语言内置支持的异步特性,编写简单,易于理解,最重要的是,没有竞争条件。
在本文中,我想快速概述这些技术,async/await , 结构化并发, 以及actor,这是Swift并发的3个主要功能。希望在本文结束时,您将很好地了解什么是Swift并发性,以及如何在自己的项目中开始使用它。
Async/await
那么什么是 async/await ?我们总是听到人们说我们可以使用async/awaits来使我们的代码同时运行,但这不是100%正确。仅使用async/await 不会使您的代码同时运行,它们只是Swift 5.5中引入的关键字,告诉编译器某个代码块应该异步运行。
假设我们有一个执行一些繁重任务的函数,需要一段时间才能完成,我们可以像这样将该函数标记为异步:
swift
func performHeavyTask() async {
// Run some heavy tasks here...
}
异步函数是一种特殊类型的函数,可以在执行期间暂停。然而,就像正常函数一样,异步函数也可以返回一个值并抛出错误。
swift
func performThrowingHeavyTask() async throws -> String {
// Run some heavy tasks here...
return ""
}
如果一个函数被标记为async
,那么我们必须像这样使用await
关键字调用它:
swift
await performHeavyTask()
await
关键字表示 performHeavyTask()
函数由于其异步特性可能被暂停。如果我们尝试像正常(同步)函数一样调用 performHeavyTask()
函数,我们会收到一个编译错误,说------不支持并发的函数中的"async"调用。
我们之所以收到这个错误,是因为我们试图在同步上下文中调用异步函数。为了在同步和异步世界之间架起桥梁,我们必须创建一个Task
。
Task
在Swift 5.5中引入。根据苹果的说法,Task
是一个异步工作单元。在任务的上下文中,代码可以暂停并异步运行。因此,我们可以创建一个任务,并用它来调用我们的 performHeavyTask()
函数。方法如下:
swift
func doSomething() {
Task {
await performHeavyTask()
}
}
上面的代码将为我们提供类似于使用全局调度队列的行为:
swift
func doSomethingElse() {
DispatchQueue.global().async {
self.performAnotherHeavyTask()
}
}
// Note that this function is not marked as async
func performAnotherHeavyTask() {
// Run some heavy task here...
}
然而,他们在幕后的工作方式实际上大不相同。
Async/await vs. Dispatch Queue
当我们创建任务时,任务将在任意线程上运行。当线程到达暂停点(代码标记为await
等待)时,系统将暂停代码并不再阻塞线程,以便线程可以在等待 performHeavyTask()
完成时继续进行其他工作。一旦执行HeavyTask()
完成,任务将重新控制线程,代码将恢复。
就像任务一样,全局调度队列也在任意线程上运行。然而,在等待 performAnotherHeavyTask()
完成时,线程被阻塞。因此,在执行AnotherHeavyTask()
返回之前,被阻塞的线程将无法执行任何其他操作。与async/await方法相比,它的效率降低。
下图说明了DispatchQueue
和Task
的程序流程:
模拟长时间运行的任务
如果您想尝试async/await关键字并亲眼看到它们的运行情况,您可以使用Task.sleep(nanoseconds:)
方法来模拟长时间运行的任务。这种方法不做任何工作,只是等待给定的纳秒数后再返回。这是一个可等待的方法,因此您可以这样称呼它:
swift
func performHeavyTask() async {
// Wait for 5 seconds
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000)
}
请注意,在调用Task.sleep(_:)
方法时,您不需要创建任务,因为 performHeavyTask()
被标记为异步,这意味着它将在异步上下文中运行,因此不需要创建任务。
这就是async/await ,接下来我们将看看什么是结构化并发。
结构化并发
假设我们有2个异步函数,返回一个整数值,如下所示:
swift
func performTaskA() async -> Int {
// Wait for 2 seconds
await Task.sleep(2 * 1_000_000_000)
return 2
}
func performTaskB() async -> Int {
// Wait for 3 seconds
await Task.sleep(3 * 1_000_000_000)
return 3
}
如果我们想得到这两个函数返回的值之和,我们可以这样做:
swift
func doSomething() {
Task {
let a = await performTaskA()
let b = await performTaskB()
let sum = a + b
print(sum) // Output: 5
}
}
上述代码需要5秒钟才能完成,因为 performTaskA()
和 performTaskB()
以串行顺序运行, performTaskA()
必须先完成,然后才能启动performTaskB()
。
正如您现在可能已经注意到的,上面的代码不是最佳的。由于 performTaskA()
和 performTaskB()
彼此独立,我们可以通过同时运行 performTaskA()
和 performTaskB()
来改善执行时间,使其只需 3 秒即可完成。这就是结构化并发发挥作用的地方。
结构化并发的工作原理是,我们将创建2个子任务,同时执行 performTaskA()
和 performTaskB()
。在Swift 5.5中,创建子任务有2种主要方法:
- 使用async-let绑定
- 使用任务组(task group)
对于本文,让我们专注于更直接的方式------使用async-let绑定。
Async-let 绑定
以下代码演示了如何在我们之前的示例中应用async-let :
swift
func doSomething() {
Task {
// Create & start a child task
async let a = performTaskA()
// Create & start a child task
async let b = performTaskB()
let sum = await (a + b)
print(sum) // Output: 5
}
}
在上述代码中,请注意我们如何组合async
和let
关键字,在performTaskA()
和performTaskB()
函数上创建async -let绑定。这样做将创建2个同时执行这两个函数的子任务。
由于 performTaskA()
和 performTaskB()
都标记为async
,我们需要等待这两个函数完成才能获得 a
和 b
的值。因此,在获取a
和b
的值时,我们必须使用await
关键字来指示代码可能会在等待 performTaskA()
和 performTaskB()
完成时暂停。
Actor
在处理异步和并发代码时,我们可能遇到的最常见的问题是数据竞争和死锁。这类问题很难调试,也很难修复。随着Swift 5.5中引入Actors技术,我们现在可以依靠编译器在我们的代码中标记任何潜在的竞争条件。那么Actors是如何工作的呢?
Actor如何工作?
Actors 是引用类型,其工作方式与类类似。然而,与类不同,Actors将确保一次只有1个任务可以修改其状态,从而消除竞争条件的根本原因------多个任务同时访问/更改相同的对象状态。
为了创建一个Actor,我们需要使用actor
关键字对它进行注释。以下是一个具有计数功能的actor
,它有一个count
可变状态,可以使用 addCount()
方法进行修改:
swift
actor Counter {
private let name: String
private var count = 0
init(name: String) {
self.name = name
}
func addCount() {
count += 1
}
func getName() -> String {
return name
}
}
我们可以实例化一个actor,就像实例化一个类一样:
swift
// Instantiate a `Counter` actor
let counter = Counter(name: "My Counter")
现在,如果我们尝试在Counter
actor之外调用addCount()
方法,我们将收到一个编译器错误- Actor-isolated instance method 'addCount()' can not be referenced from a non-isolated context
我们遇到这个错误的原因是编译器试图保护Counter
actor的状态。如果假设同时有多个线程调用addCount()
,那么将出现竞争条件。因此,我们不能像调用普通实例方法一样简单地调用actor的方法。
要实施此限制,我们必须用await
关键字标记调用点,表示addCount()
方法在调用时可能会暂停。这实际上很有意义,因为为了维持对count
变量的互斥访问,addCount()
的调用点可能需要暂停,以便等待其他任务完成后再继续。
考虑到这一点,我们可以应用我们在async/await 部分中学到的内容,并像这样调用addCount()
:
swift
let counter = Counter(name: "My Counter")
Task {
await counter.addCount()
}
nonisolated 关键词
现在,我想提请您注意Counter
的getName()
方法。就像addCount()
方法一样,调用getName()
方法也需要用await
等待关键字注释。
但是,如果您仔细观察,getName()
方法只访问Counter
计数器的名称常量,它不会改变计数器的状态,因此不可能创建竞赛条件。
在这种情况下,我们可以通过将其标记为nonisolated
,将getName()
方法排除在actor的保护之外。
swift
nonisolated func getName() -> String {
return name
}
有了这个,我们现在可以像普通实例方法一样调用getName()
方法:
swift
let counter = Counter(name: "My Counter")
let x = counter.getName()
在结束这篇文章之前,我想谈谈关于actor的最后一点,那就是MainActor
。
MainActor
MainActor
是一种特殊的actor,总是在主线程上运行。在Swift 5.5中,所有UIKit和SwiftUI组件都被标记为MainActor
。由于与UI相关的所有组件都是 main actor,当我们想在后台操作完成后更新UI时,我们不再需要担心忘记调度到主线程。
如果您有一个应该始终在主线程上运行的类,您可以使用@MainActor
关键字对其进行注释,例如:
swift
@MainActor
class MyClass {
}
但是,如果您只希望类中的特定函数始终在主线程上运行,您可以使用@MainActor
关键字注释该函数:
swift
class MyClass {
@MainActor
func doSomethingOnMainThread() {
}
}
小结
我对Swift并发感到非常兴奋,我可以预见Swift并发在不久的将来肯定会成为编写异步Swift代码的标准。
我在这篇文章中涵盖的内容只是冰山一角,如果您想了解更多信息,我强烈建议您查看WWDC21的这些视频。
感谢您的阅读。👨🏻💻