跳到主要内容

Swift 并发中意想不到的任务挂起点

· 阅读需 8 分钟
GoSwiftUI
goswiftui.com

Swift Concurrency 中的任务会遇到所谓的挂起点。在这些点上,执行器可以决定立即开始执行任务,或先将其调度起来。任务何时挂起并不总是显而易见,但通常我们会说它发生在你写下 await 关键字的位置。

本文要强调一个意想不到且不必要的挂起点,它很容易影响应用操作的性能。

忙碌的主线程

众所周知(希望如此),UI 需要在主线程上更新。在 Swift Concurrency 中,你可以使用 Main Actor 在代码中强制执行这一点。例如,你可能有一个像下面这样标注的视图模型:

@MainActor
@Observable
final class ArticleViewModel {
/// ...
}

由于主线程只有一个,你需要清楚自己在它上面执行了什么。如果执行了繁重的阻塞操作,就可能让主线程一直处于忙碌状态。这会导致 UI 更新变慢,或出现 UI 卡顿。

换句话说,作为开发者,我们需要确保只有在真正需要时,才开始在 Main Actor 上运行任务。理解 Swift Concurrency 的工作方式,以及它如何继承 actor 上下文非常重要。还有一种情况是,你的项目设置可能启用了 Default Actor Isolation for the Main Actor

继承 Main Actor 隔离

假设我们向视图模型添加一个用于调度新任务的方法:

@MainActor
@Observable
final class ArticleViewModel {

var isPublishingArticle = false
/// ...

func publish(_ article: Article) {
isPublishingArticle = true

Task {
// Inherits the @MainActor isolation from the view model.
// Starts executing on the Main Thread.
await ArticlePublisher.publish(article: article)

isPublishingArticle = false
}
}
}

publish(_ article: Article) 方法是同步的,并启动一个新的 Task 来调用 ArticlePublisher 的异步方法。我们在 Main Actor 上把 isPublishingArticle 设置为 true,因为这涉及 UI 更新。由于我们的 Task 会从视图模型继承 Main Actor 隔离,发布文章后我们可以直接把它赋值为 false

先说明一下:这个方法有更好的写法,比如在 Immediate Task 内部赋值 true,但我见过许多开发者使用与上面类似的方法结构。因此,本文会继续基于这个结构讨论。

一个意想不到的挂起点

让我们放大看看上面创建的 task:

Task {
// Inherits the @MainActor isolation from the view model.
// Starts executing on the Main Thread.
await ArticlePublisher.publish(article: article)

isPublishingArticle = false
}

这可能不是立刻显而易见,但这个 task 被调度到了 Main Actor 上。我们的 task 甚至要等主线程可用后才能开始运行。

当主线程很忙时,task 可能需要过一小段时间才能启动。然而有趣的是:我们实际上并没有直接执行任何需要主线程的工作!

我们做的第一件真正的事,是通过 await 调用 ArticlePublisher。换句话说,task 刚开始执行后,就马上挂起并切换隔离域。对于这个简单示例来说这也许没问题,但我见过一些代码会在短时间内执行大量类似操作。我们不仅在不必要地等待,还会短暂阻塞主线程,让问题变得更严重。

使用 Xcode Instruments 可视化这个问题

使用 Xcode Instruments,我们可以通过 Swift Concurrency 模板可视化挂起点。下面的图片展示了我们如何快速跳到主线程,然后又立刻从主线程跳出去:

Main-Thread 'hopping' happens due to these unexpected suspension points.

这些意想不到的挂起点会导致 Main Thread “跳进跳出”。

应用性能下降的程度,完全取决于操作发生时应用正在做什么。在我的案例中,我正在实时向 UI 注入新结果,这让界面变得非常慢。

上面的代码示例中还有一个更大的问题:我们还请求回到主线程:

Task {
/// Inherits the @MainActor isolation from the view model.
/// Starts executing on the Main Thread.
await ArticlePublisher.publish(article: article)

/// We now need to get back on the Main Thread
/// so we can update `isPublishingArticle`.

isPublishingArticle = false
}

然而,如果你把上面这种 task 调度了一百次,其他 task 可能仍在排队等待在主线程上运行。它们基本上已经被调度好了,会阻塞第一个 task 返回并完成。这会造成双重性能打击,并拖慢首次 UI 更新出现的时间。

解决这个意想不到的挂起点

对我来说,这个挂起点相当意外。它并不是马上可见的,但现在我知道之后,就很容易在代码库的任何地方识别它。我也直接更新了我的 Swift Concurrency Agent Skill,以防再次发生。

修复方式很简单:确保 task 立即在另一个隔离域中开始执行:

Task { @concurrent in
await ArticlePublisher.publish(article: article)

/// We now need to get back on the Main Thread
/// so we can update `isPublishingArticle`.
await MainActor.run {
isPublishingArticle = false
}
}

注意,我们需要使用 MainActor.run 跳回主线程。

在我的个人代码中,首次结果出现的时间得到了显著改善:

The time to first UI result improved after fixing the unnecessary suspension point.

修复不必要的挂起点后,首次 UI 结果出现的时间得到了改善。

再次说明,我是在短时间内执行了很多次该 task。这让问题暴露得更加明显,但它很好地说明了一个看似无害的挂起点如何导致更大的问题。

总结

Swift Concurrency 经过优化,可以帮助我们编写线程安全的代码,但仍然有许多事情需要我们自己管理。借助 Xcode Instruments,我们可以可视化并识别意想不到的挂起点,而这些挂起点可能会对应用性能产生很大影响。

原文地址:https://www.avanderlee.com/concurrency/unexpected-task-suspension-points-in-swift-concurrency/?utm_source=substack&utm_medium=email