What role do Tasks play within Swift’s concurrency system?
在使用Swift新的内置并发系统编写异步代码时,创建a Task使我们能够访问新的异步上下文,在该上下文中,我们可以自由调用异步标记的API,并在后台执行工作。
但除了使我们能够封装一段异步代码外,Task类型还允许我们控制此类代码的运行、管理和潜在取消方式。
1. Bridging the gap between synchronous and asynchronous code
在基于UI的应用程序中使用Task的最常见方式可能是让它充当同步、主线程绑定UI代码和用于获取或处理UI呈现的数据的任何后台操作之间的桥梁。
例如,在这里,我们使用基于UIKit的ProfileViewController中的Task,以便能够使用异步标记的API加载视图控制器应该呈现的用户模型:
class ProfileViewController: UIViewController {
private let userID: User.ID
private let loader: UserLoader
private var user: User?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
do {
let user = try await loader.loadUser(withID: userID)
userDidLoad(user)
} catch {
handleError(error)
}
}
}
...
private func handleError(_ error: Error) {
// Show an error view
...
}
private func userDidLoad(_ user: User) {
// Render the user's profile
...
}
}
上述代码中真正有趣的一点是,没有捕获self,也没有DispatchQueue.main.async调用,无需保留的令牌或可取消项,或使用闭包或Combine等工具执行异步操作时通常必须执行的任何其他类型的“账本”。
那么,我们究竟如何才能执行一个网络调用(它肯定会在后台线程上执行),然后直接调用用户界面更新方法,如userDidLoad和handleError,而不必首先使用DispatchQueue.main手动调度这些调用呢
这就是Swift新的MainActor属性的用武之地,它可以自动确保与UI相关的api(如UIView或UIViewController中定义的api)在主线程上正确调度。因此,只要我们使用Swift的新并发系统编写异步代码,并且在这样一个标记了MainActor的上下文中,我们就不再需要担心在后台队列上意外执行UI更新。整洁的
上述实现的另一个有趣之处是,我们不需要手动保留加载任务来完成它。这是因为异步任务在释放相应的任务句柄时不会自动取消,它们只是在后台继续执行。
2. Referencing and cancelling a task
但是,在这种特殊情况下,我们可能确实希望保留对加载任务的引用,因为我们可能希望在视图控制器消失时取消它,并且我们可能还希望防止在任务已在进行时出现系统调用视图时执行重复的任务:
class ProfileViewController: UIViewController {
private let userID: User.ID
private let loader: UserLoader
private var user: User?
private var loadingTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard loadingTask == nil else {
return
}
loadingTask = Task {
do {
let user = try await loader.loadUser(withID: userID)
userDidLoad(user)
} catch {
handleError(error)
}
loadingTask = nil
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadingTask?.cancel()
loadingTask = nil
}
...
}
请注意一个任务如何有两种通用类型-第一种表示它返回的输出类型(在我们的例子中这是无效的,因为我们的任务只是将其加载的用户模型转发到视图控制器的方法上),第二个是它的错误类型(因为我们处理任务本身中的所有错误,所以在这种情况下错误类型是Never)。
对Task调用cancel方法也会将其所有子任务标记为已取消。因此,通过取消视图控制器中的顶级loadingTask,我们同时也隐式取消了它的底层网络操作。
但是,请注意,由每个单独的task来实现取消其特定操作所需的实际取消处理代码。因此,即使系统将根据标记自动管理和传播取消,也由每个任务决定如何实际处理该取消(例如,通过在其闭包中调用task.checkCancellation)。
3. Context inheritance
给定任务与其父任务之间的关系可能非常重要,至少在@MainActor标记的类中是如此,如视图和视图控制器。这是因为子任务不仅在取消方面与其父任务相连接,它们还自动继承父任务使用的相同执行上下文。
为了说明这种行为何时会出现一些问题,让我们假设ProfileViewController是从本地数据库而不是通过网络加载其用户模型,并且我们的数据库API当前是完全同步的。
乍一看,以下实现似乎完全可以,因为我们可能期望异步工作仍将在后台线程上执行(即使我们不再在任务中执行任何基于等待的调用):
class ProfileViewController: UIViewController {
private let userID: User.ID
private let database: Database
private var user: User?
private var loadingTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard loadingTask == nil else {
return
}
loadingTask = Task {
do {
let user = try database.loadModel(withID: userID)
userDidLoad(user)
} catch {
handleError(error)
}
loadingTask = nil
}
}
...
}
但是,尽管上述任务确实是异步执行的,但它仍将在主线程上执行,因为它是使用MainActor调度的(它从创建它的ViewWillAppear方法继承了该上下文)。因此,从本质上讲,我们上面的任务或多或少相当于在DispachQueue.main.async中执行相同的数据库调用。
由于我们可能希望将数据库调用从主线程移开(以防止该调用干扰UI的响应),因此我们可以使用detached task,该任务将在其自己的独立上下文中执行。执行此操作时,在回调视图控制器的方法时,我们还必须使用await,因为这些方法是由MainActor隔离的(我们也不能在任务中直接将loadingTask属性设置为nil):
class ProfileViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard loadingTask == nil else {
return
}
loadingTask = Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
do {
let user = try self.database.loadModel(withID: self.userID)
await self.userDidLoad(user)
} catch {
await self.handleError(error)
}
await self.loadingTaskDidFinish()
}
}
...
private func loadingTaskDidFinish() {
loadingTask = nil
}
}
通常,当我们显式地想要创建一个使用自己的执行上下文的新顶级任务时,建议只使用detached tasks。在其他情况下,建议只使用**Task{}**封装异步代码。
4. Awaiting the result of a task
最后,让我们看看如何等待给定Task实例的结果。例如,假设我们希望扩展上述基于数据库的视图控制器实现,支持通过网络加载当前用户的图像。
为此,我们将分离的任务包装到另一个任务实例中,然后使用wait关键字等待数据库加载操作完成,然后再继续图像下载-如下所示:
class ProfileViewController: UIViewController {
private let userID: User.ID
private let database: Database
private let imageLoader: ImageLoader
private var user: User?
private var loadingTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard loadingTask == nil else {
return
}
loadingTask = Task {
let databaseTask = Task.detached(
priority: .userInitiated,
operation: { [database, userID] in
try database.loadModel(withID: userID)
}
)
do {
let user = try await databaseTask.value
let image = try await imageLoader.loadImage(from: user.imageURL)
userDidLoad(user, image: image)
} catch {
handleError(error)
}
loadingTask = nil
}
}
...
private func userDidLoad(_ user: User, image: UIImage) {
// Render the user's profile
...
}
}
请注意,我们如何再次在顶级任务中直接调用视图控制器的方法,因为它现在是MainActor绑定的,就像以前一样。这说明了我们现在可以多么顺利地混合在主队列上和主队列下执行的工作,而不必担心在错误的线程上意外执行UI更新。
5. Conclusion
Swift的新Task类型使我们能够封装、观察和控制一个异步工作单元,从而允许我们调用异步标记的API,并执行后台工作,即使在完全同步的代码中也是如此。这样,我们就可以逐步引入异步函数和Swift新并发系统的其余部分,即使在没有考虑这些新功能的应用程序中也是如此。