“Task”在调用内部异步函数时会阻塞主线程

ahe*_*eze 38 ios async-await swift swiftui swift-concurrency

我有一个ObservableObject类和一个 SwiftUI 视图。当点击按钮时,我会在其中创建Task并调用(异步函数)。populate我以为这会populate在后台线程上执行,但整个 UI 都冻结了。这是我的代码:

class ViewModel: ObservableObject {
    @Published var items = [String]()
    func populate() async {
        var items = [String]()
        for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
            items.append("\(i)")
        }
        self.items = items
    }
}

struct ContentView: View {
    @StateObject var model = ViewModel()
    @State var rotation = CGFloat(0)

    var body: some View {
        Button {
            Task {
                await model.populate()
            }
        } label: {
            Color.blue
                .frame(width: 300, height: 80)
                .overlay(
                    Text("\(model.items.count)")
                        .foregroundColor(.white)
                )
                .rotationEffect(.degrees(rotation))
        }
        .onAppear { /// should be a continuous rotation effect
            withAnimation(.easeInOut(duration: 2).repeatForever()) {
                rotation = 90
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

结果:

按下按钮时旋转动画冻结

按钮停止移动,populate完成后突然弹回。

奇怪的是,如果我将 移动Taskpopulate自身并摆脱async,旋转动画不会卡顿,所以我认为循环实际上是在后台执行的。但是我现在收到Publishing changes from background threads is not allowed警告。

func populate() {
    Task {
        var items = [String]()
        for i in 0 ..< 4_000_000 {
            items.append("\(i)")
        }
        self.items = items /// Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
    }
}

/// ...

Button {
    model.populate()
}
Run Code Online (Sandbox Code Playgroud)

结果:

即使按下按钮,旋转动画也会继续

如何确保我的代码在后台线程上执行?我认为这可能与此有关,MainActor但我不确定。

Rob*_*Rob 47

首先,作为一般观察,在 WWDC 2021\xe2\x80\x99s Discover concurrency in SwiftUI中,他们建议您将ObservableObject对象与主要参与者隔离。

\n

但 UI 中的问题是由于主要参与者被这个缓慢的进程阻塞而引起的。所以我们必须把这个任务交给主角。有几种可能的方法:

\n
    \n
  1. 您可以将慢速同步进程移至 \xe2\x80\x9cdetached\xe2\x80\x9d 任务。当Task {\xe2\x80\xa6}代表当前 actor\xe2\x80\x9d 启动一个新的顶级任务 \xe2\x80\x9con 时,分离任务是一个 \xe2\x80\x9c 的非结构化任务,\xe2\x80\x99s 不是当前 actor 的一部分。演员\xe2\x80\x9d。因此,detached任务将避免阻塞当前参与者:

    \n
    @MainActor\nclass ViewModel: ObservableObject {\n    @Published var items = [String]()\n\n    func populate() async {\n        let task = Task.detached {\n            var items: [String] = []\n\n            for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change\n                items.append("\\(i)")\n            }\n            return items\n        }\n\n        items = await task.value\n    }\n}\n
    Run Code Online (Sandbox Code Playgroud)\n

    请注意,虽然这解决了阻塞问题,但不幸的是,Task.detached {\xe2\x80\xa6}(如Task {\xe2\x80\xa6})是非结构化并发因此,这可能不是我通常推荐的模式(除非您将其包装在 中withTaskCancellationHandler)。但请参阅下面第 4 点中有关取消的更多信息。

    \n
  2. \n
  3. 从 Swift 5.7 开始,可以使用以下函数实现相同的行为asyncnonisolated请参阅SE-0338)。这使我们保持在结构化并发的范围内,但仍然可以减轻当前参与者的工作:

    \n
    @MainActor\nclass ViewModel: ObservableObject {\n    @Published var items = [String]()\n\n    private nonisolated func generate() async -> [String] {\n        var items: [String] = []\n\n        for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change\n            items.append("\\(i)")\n        }\n        return items\n    }\n\n    func populate() async {\n        items = await generate()\n    }\n}\n
    Run Code Online (Sandbox Code Playgroud)\n
  4. \n
  5. 或者我们可以使用单独的actor耗时过程来完成此操作,这再次将任务从视图模型 \xe2\x80\x99s actor 中解放出来:

    \n
    @MainActor\nclass ViewModel: ObservableObject {\n    @Published var items = [String]()\n    private let generator = Generator()\n\n    private actor Generator {\n        func generate() async -> [String] {\n            var items: [String] = []\n\n            for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change\n                items.append("\\(i)")\n            }\n            return items\n        }\n    }\n\n    func populate() async {\n        items = await generator.generate()\n    }\n}\n
    Run Code Online (Sandbox Code Playgroud)\n
  6. \n
  7. 我建议添加取消逻辑(以防用户想要中断计算并开始另一个计算)try Task.checkCancellation()

    \n

    另外,在 Swift 并发中,我们永远不应该违反 \xe2\x80\x9censure 前进进度\xe2\x80\x9d 的契约,或者,如果必须的话,定期Task.yield确保该并发系统的正常运行。正如SE-0296所说:

    \n
    \n

    由于潜在的挂起点只能出现在异步函数中显式标记的点处,因此长计算仍然会阻塞线程。当调用只执行大量工作的同步函数时,或者遇到直接在异步函数中编写的特别密集的计算循环时,可能会发生这种情况。在任何一种情况下,线程都无法在这些计算运行时交错代码,这通常是正确性的正确选择,但也可能成为可扩展性问题。需要进行密集计算的异步程序通常应该在单独的上下文中运行。当\xe2\x80\x99s不可行时,将有库设施人为地暂停并允许其他操作交错。

    \n
    \n

    现在,前面提到的技术(上面第 1-3 点)通过防止主要参与者被阻塞来解决您的主要问题。但这里更深入的观察是,我们确实应该避免阻止任何使用 \xe2\x80\x9clong 运行 \xe2\x80\x9d 工作的演员。但 Task.yield解决了这个问题。

    \n

    无论如何,考虑到这个nonisolated例子,我们可以处理取消和交错:

    \n
    @MainActor\nclass ViewModel: ObservableObject {\n    @Published var items = [String]()\n\n    private nonisolated func generate() async throws -> [String] {\n        var items = [String]()\n        for i in 0 ..< .random(in: 4_000_000 ... 5_000_000) {\n            if i.isMultiple(of: 1000) {\n                await Task.yield()\n                try Task.checkCancellation()\n            }\n            items.append("\\(i)")\n        }\n        try Task.checkCancellation()\n        return items\n    }\n\n    func populate() async throws {\n        items = try await generate()\n    }\n}\n
    Run Code Online (Sandbox Code Playgroud)\n

    或者该actor方法将遵循相同的模式:

    \n
    @MainActor\nclass ViewModel: ObservableObject {\n    @Published var items = [String]()\n    private let generator = Generator()\n\n    private actor Generator {\n        func generate() async throws -> [String] {\n            var items = [String]()\n            for i in 0 ..< .random(in: 4_000_000 ... 5_000_000) {\n                if i.isMultiple(of: 1000) {\n                    await Task.yield()\n                    try Task.checkCancellation()\n                }\n                items.append("\\(i)")\n            }\n            try Task.checkCancellation()\n            return items\n        }\n    }\n\n    func populate() async throws {\n        items = try await generator.generate()\n    }\n}\n
    Run Code Online (Sandbox Code Playgroud)\n

    只有在编写我们自己的计算密集型任务时才需要定期检查取消和让出。大多数 Apple\xe2\x80\x98s asyncAPI(例如URLSession等)已经为我们处理了这些问题。

    \n

    无论如何,所有关于取消的讨论都回避了一个问题:如何取消先前的任务。只需将其保存Task在与参与者隔离的视图模型的属性中,然后在开始下一个之前取消前一个。例如:

    \n
    private var task: Task<Void, Error>?\n\nfunc start() {\n    task?.cancel()                         // cancel prior one, if any\n    task = Task { try await populate() }\n}\n
    Run Code Online (Sandbox Code Playgroud)\n
  8. \n
\n

无论如何,这些模式将允许缓慢的进程不会阻塞主线程,从而产生不间断的 UI。在这里,我点击了按钮两次:

\n

在此输入图像描述

\n

不用说,这是没有 \xe2\x80\x9ccancel 先前的 \xe2\x80\x9d 逻辑。通过这种逻辑,您可以点击多次,之前的所有一次都将被取消,并且您只会看到一个更新,从而避免了一堆冗余任务可能对系统造成过度负担。但想法是一样的,即在执行复杂任务时提供流畅的用户界面。

\n
\n

请参阅 WWDC 2021 视频Swift 并发:幕后花絮使用 Swift Actor 保护可变状态Swift 并发:更新示例应用程序,所有这些在尝试从 GCD 过渡到 Swift 并发时都非常有用。

\n


ahe*_*eze 11

22 年 6 月 10 日更新:在 WWDC 上,我向一些 Apple 工程师询问了这个问题 \xe2\x80\x94 这确实与 actor 继承有关。然而,Xcode 14 Beta 中存在一些编译器级别的更改。例如,这将在 Xcode 14 上顺利运行,但在 Xcode 13 上会滞后:

\n
class ViewModel: ObservableObject {\n    @Published var items = [String]()\n\n    func populate() async {\n        var items = [String]()\n        for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds\n            items.append("\\(i)")\n        }\n\n        /// explicitly capture `items` to avoid `Reference to captured var \'items\' in concurrently-executing code; this is an error in Swift 6`\n        Task { @MainActor [items] in\n            self.items = items\n        }\n    }\n}\n\nstruct ContentView: View {\n    @StateObject var model = ViewModel()\n    @State var rotation = CGFloat(0)\n\n    var body: some View {\n        Button {\n            Task {\n\n                /// *Note!* Executes on a background thread in Xcode 14.\n                await self.model.populate()\n            }\n        } label: {\n            Color.blue\n                .frame(width: 300, height: 80)\n                .overlay(\n                    Text("\\(model.items.count)")\n                        .foregroundColor(.white)\n                )\n                .rotationEffect(.degrees(rotation))\n        }\n        .onAppear { /// should be a continuous rotation effect\n            withAnimation(.easeInOut(duration: 2).repeatForever()) {\n                rotation = 90\n            }\n        }\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

同样,Task继承调用它的上下文。

\n
    \n
  • Task从普通类中调用的调用将ObservableObject在后台运行,因为该类不是主要参与者。
  • \n
  • aTask内调用的 aButton可能会在主要参与者上运行,因为 aButton是一个 UI 元素。不过,Xcode 14 改变了一些东西,它实际上也在后台运行......
  • \n
\n

为了确保函数在后台线程上运行,独立于继承的 Actor 上下文,您可以添加nonisolated.

\n
nonisolated func populate() async {\n\n}\n
Run Code Online (Sandbox Code Playgroud)\n

注意:可视化和优化 Swift 并发视频非常有帮助。

\n


Pau*_*w11 9

首先,你不能两全其美;要么在主线程上执行 CPU 密集型工作(并对 UI 产生负面影响),要么在另一个线程上执行该工作,但需要将 UI 更新显式分派到主线程上。

然而,你真正想问的是

(通过使用Task)我认为这会在后台线程上执行填充,但整个 UI 都会冻结。

当您使用 a 时,Task您正在使用非结构化并发,并且当您Task通过init(priority:operation)初始化 任务时...继承调用者的优先级和参与者上下文

虽然 aTask是异步执行的,但它使用调用者的 actor 上下文来执行此操作,在 a 的上下文中,它View body是主要 actor。这意味着,虽然您的任务是异步执行的,但它仍然在主线程上运行,并且该线程在处理时不可用于 UI 更新。所以你是对的,这与一切有关MainActor

当您将其移入时,Taskpopulate不再在MainActor上下文中创建,因此不会在主线程上执行。

正如您所发现的,您需要使用第二种方法来避免主线程。您需要对代码执行的操作就是使用以下命令将最终更新移回主队列MainActor

func populate() {
    Task {
        var items = [String]()
        for i in 0 ..< 4_000_000 {
            items.append("\(i)")
        }
        await MainActor.run {
            self.items = items 
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

您还可以Task.detached()在正文上下文中使用来创建Task不附加MainActor上下文的对象。


Cos*_*syn 6

正如其他人所提到的,这种行为的原因是Task.init自动继承参与者上下文。您正在从按钮回调调用您的函数:

Button {
    Task {
        await model.populate()
    }
} label: {

}
Run Code Online (Sandbox Code Playgroud)

按钮回调位于主要参与者上,因此传递给Task初始化器的闭包也位于主要参与者上。

一种解决方案是使用分离任务:

func populate() async {
    Task.detached {
        // Calculation here
    }
}
Run Code Online (Sandbox Code Playgroud)

虽然分离任务是非结构化的,但我想建议结构化任务,例如async let任务:

@MainActor
class ViewModel: ObservableObject {
    @Published var items = [String]()

    func populate() async {
        async let newItems = { () -> [String] in
            var items = [String]()
            for i in 0 ..< 4_000_000 {
                items.append("\(i)")
            }
            return items
        }()

        items = await newItems
    }
}
Run Code Online (Sandbox Code Playgroud)

populate当您希望函数异步返回某个值时,这非常有用。这种结构化任务方法还意味着取消可以自动传播。例如,如果你想在短时间内多次点击按钮时取消计算,你可以这样做:

@MainActor
class ViewModel: ObservableObject {
    @Published var items = [String]()

    func populate() async {
        async let newItems = { () -> [String] in
            var items = [String]()
            for i in 0 ..< 4_000_000 {
                // Stop in the middle if cancelled
                if i % 1000 == 0 && Task.isCancelled {
                    break
                }
                items.append("\(i)")
            }
            return items
        }()

        items = await newItems
    }
}

struct ContentView: View {
    @StateObject var model: ViewModel
    @State var task: Task<Void, Never>?

    init() {
        _model = StateObject(wrappedValue: ViewModel())
    }

    var body: some View {
        Button {
            task?.cancel() // Cancel previous task if any
            task = Task {
                await model.populate()
            }
        } label: {
            // ...
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

此外,withTaskGroup还创建结构化任务,您也可以避免继承参与者上下文。当您的计算有多个可以同时进行的子任务时,它会很有用。