`Task`在Swift的并发系统中扮演什么角色?

1,032 阅读7分钟

当使用Swift新的内置并发系统编写异步代码时,创建一个Task ,让我们可以访问一个新的异步上下文,在其中我们可以自由地调用async-marked API,并在后台执行工作。

但除了使我们能够封装一段异步代码外,Task 类型还可以让我们控制这种代码的运行、管理和潜在的取消方式。

缩小同步和异步代码之间的差距

也许在基于UI的应用程序中使用Task ,最常见的方式是让它作为我们的同步、主线程绑定的UI代码和任何用于获取或处理我们的UI正在渲染的数据的后台操作之间的桥梁。

ProfileViewController 例如,在这里,我们在基于UIKit的Task ,能够使用一个async-marked API来加载我们的视图控制器应该渲染的User 模型:

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 调用,没有需要保留的标记或可取消的东西,或者任何其他类型的 "簿记",这些都是我们在使用闭包或组合等工具进行异步操作时通常必须做的。

那么,我们到底是如何执行一个网络调用(这肯定会在后台线程上执行),然后直接调用UI更新方法,如userDidLoadhandleError ,而不首先使用DispatchQueue.main 手动调度这些调用呢?

这就是Swift的新 MainActor属性的作用,它可以自动确保与UI相关的API(比如那些在UIViewUIViewController )在主线程上被正确分派。因此,只要我们使用Swift的新并发系统编写异步代码,并在这样一个MainActor-marked context中,那么我们就不必再担心意外地在后台队列中执行UI更新。很好!

我们上述实现的另一个有趣之处在于,我们不需要手动保留我们的加载任务以使其完成。这是因为异步任务在其相应的Task 句柄被取消时不会被自动取消--它们只是在后台继续执行。

引用和取消一个任务

然而,在这种特殊情况下,我们可能确实想保持对我们的加载任务的引用,因为我们可能想在我们的视图控制器消失时取消它,我们可能还想防止重复的任务被执行,以防系统在一个任务已经在进行时调用viewWillAppear :

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
    }

    ...
}

请注意Task 有两个通用类型--第一个表示它返回的输出类型(在我们的例子中是Void ,因为我们的任务只是将其加载的User 模型转发给我们的视图控制器的方法),第二个是它的错误类型(由于我们在任务本身中处理所有的错误,在这种情况下错误类型是Never )。

在一个Task 上调用cancel 方法也会将其所有的子任务标记为取消。因此,通过在我们的视图控制器中取消我们的顶层loadingTask ,我们也同时隐含地取消了它的底层网络操作。

然而,请注意,这取决于每个单独的任务来实现取消其特定操作所需的实际取消处理代码。因此,即使系统会自动管理和传播标记方面的取消,也要由每个任务来决定如何实际处理这种取消(例如,在其闭包中调用Task.checkCancellation )。

上下文的继承

一个给定的Task 和它的父任务之间的关系可能是相当重要的,至少在@MainActor 标记的类中是这样,比如视图和视图控制器。这是因为子任务不仅在取消方面与它们的父任务相连--它们还自动继承与它们的父任务使用的相同的执行上下文。

为了说明这种行为何时会变得有些问题,让我们想象一下,我们的ProfileViewController 是从本地数据库加载其User 模型,而不是通过网络,而且我们的Database API目前是完全同步的。

乍一看,下面的实现似乎是完全没问题的,因为我们可能期望我们的异步工作仍将在后台线程上执行(即使我们在Task 中不再执行任何基于await 的调用)。

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
        }
    }

    ...
}

然而,尽管上面的Task 确实会被异步执行,但它仍然会在主线程上执行,因为它是用MainActor (它从创建它的viewWillAppear 方法中继承了这个上下文)来调度的。因此,从本质上讲,我们上面的Task ,或多或少等同于在一个DispachQueue.main.async 闭包中执行相同的数据库调用。

由于我们可能想把数据库调用从主线程中移开(以防止调用干扰我们的用户界面的响应性),我们可以使用一个detached 任务--它将在自己独立的上下文中执行。在这样做的时候,我们在调用视图控制器的方法时也必须使用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 任务。在其他情况下,简单地使用Task {} 来封装我们的异步代码是推荐的方式。

等待一个任务的结果

最后,让我们来看看我们如何能够await 一个特定的Task 实例的结果。例如,假设我们想扩展上述基于Database 的视图控制器实现,支持通过网络加载当前用户的图片。

要做到这一点,我们将把我们的detached 任务包裹在另一个Task 实例中,然后我们将使用await 关键字来等待我们的数据库加载操作完成,然后再继续我们的图像下载--像这样:

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
        ...
    }
}

请注意,我们可以再次在我们的顶层Task ,直接调用我们的视图控制器的方法,因为它现在是MainActor ,就像以前一样。这说明我们现在可以很顺利地混合在主队列上和主队列外执行的工作,而不必担心意外地在错误的线程上执行UI更新。

总结

Swift 新的Task 类型使我们能够封装、观察和控制一个异步工作单元--这反过来又使我们能够调用async 标记的 API,并执行后台工作,即使是在原本完全同步的代码中。这样一来,我们就可以逐步引入async 函数和 Swift 新的并发系统的其余部分,甚至在那些设计时没有考虑到这些新功能的应用程序中。

谢谢你的阅读!