调用异步函数时,`Task` 会阻塞主线程。

34

我有一个 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
            }
        }
    }
}

结果:

当按下按钮时,旋转动画会冻结

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

奇怪的是,如果我将Task移到populate内部并去掉async,则旋转动画不会出现卡顿,因此我认为循环实际上是在后台执行的。但是现在我会收到“不允许从后台线程发布更改”的警告。

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

结果:

即使按钮被按下,旋转动画仍然会继续

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


@lazarevzubov 但是 Task 不是异步的吗?当我在 Task 中使用 await 的时候,当前线程不应该是后台线程吗?我无法理解第一种和第二种选项之间的区别。无论如何,在 SwiftUI 中,您只需要设置属性,UI 就会自动更新。 - aheze
你尝试过在第二个选项中使用 MainActor.run { self.items = items } 将更新移动到主线程吗? - lazarevzubov
1
任务应该被分离,执行发布属性修改的函数应该被包装在MainActor中 - 可能的解决方案在这里 - Asperi
1
我在这篇帖子中总结了我的发现:https://dev59.com/8MPra4cB1Zd3GeqPtPfb#73015435 - Michael Bernat
@Asperi,BOYCOTT on russia!Don't buy, sell, support - HELP TO STOP WAR!与“可能的解决方案”有什么关系?同样适用于您个人资料中的BOYCOTT on russia - terrorist must be punished!。在SO上,激进言辞和/或政治宣传是否不合适? - meaning-matters
显示剩余2条评论
6个回答

37
首先,作为一般观察,WWDC 2021的在SwiftUI中发现并发性中,他们建议将ObservableObject对象隔离到主要actor中。
但是UI中的故障是由于主要actor被这个缓慢的过程阻塞所引起的。因此,我们必须将这个任务从主要actor中移出。有几种可能的方法:
  1. 你可以将慢同步过程移动到一个“分离的”任务中。而Task {…}会代表当前执行者启动一个新的顶级任务,“分离的”任务是一个“不属于当前执行者的非结构化任务”。所以,分离的任务将避免阻塞当前执行者:

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

    请注意,虽然这解决了阻塞问题,但是,Task.detached {…}(就像Task {…}一样)是非结构化并发。所以,除非你在withTaskCancellationHandler中包装它,否则我通常不建议使用这种模式。但是关于取消,请参考下面的第4点。

  2. 从Swift 5.7开始,可以通过一个async函数来实现相同的行为,该函数是nonisolated的(参见SE-0338)。这样可以保持在结构化并发的范围内,同时将工作从当前执行者中移出:

    @MainActor
    class ViewModel: ObservableObject {
        @Published var items = [String]()
    
        private nonisolated func generate() async -> [String] {
            var items: [String] = []
    
            for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
                items.append("\(i)")
            }
            return items
        }
    
        func populate() async {
            items = await generate()
        }
    }
    
  3. 或者我们可以使用一个独立的actor来处理耗时的过程,这样可以将任务从视图模型的执行者中移出:

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

    此外,在Swift并发中,我们不应违反“确保前进”的约定,或者如果必须这样做,可以定期Task.yield以确保并发系统的正常运行。正如SE-0296所说:

    由于潜在的挂起点只能出现在异步函数内部明确标记的位置,因此长时间的计算仍然可能阻塞线程。当调用一个只是做大量工作的同步函数时,或者在直接编写的异步函数中遇到特别密集的

    无论如何,这些模式将允许慢速处理过程不阻塞主线程,从而实现不间断的用户界面。在这里,我点击了按钮两次:

    enter image description here

    不用说,这是没有“取消之前的一个”逻辑的。有了这个逻辑,你可以多次点击,所有之前的点击都会被取消,你只会看到一个更新,避免了可能过度负担系统的大量冗余任务。但是思想是一样的,即在执行复杂任务时保持流畅的用户界面。
    查看WWDC 2021视频Swift并发:幕后故事, 使用Swift actors保护可变状态, 和 Swift并发:更新示例应用程序,这些视频对于理解从GCD过渡到Swift并发非常有帮助。

10
更新于 2022 年 6 月 10 日: 在 WWDC 上,我向一些 Apple 工程师询问了这个问题 - 它与 actor 继承有关。但是,在 Xcode 14 Beta 中进行了一些编译器层面的更改。例如,这段代码将在 Xcode 14 上顺畅运行,但在 Xcode 13 上会出现延迟:
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)")
        }

        /// explicitly capture `items` to avoid `Reference to captured var 'items' in concurrently-executing code; this is an error in Swift 6`
        Task { @MainActor [items] in
            self.items = items
        }
    }
}

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

    var body: some View {
        Button {
            Task {

                /// *Note!* Executes on a background thread in Xcode 14.
                await self.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
            }
        }
    }
}

Task继承调用它的上下文。

  • 从普通的ObservableObject类中调用的Task将在后台运行,因为该类不是主actor。
  • Button内部调用的Task可能会在主actor上运行,因为Button是UI元素。但是,Xcode 14改变了一些东西,实际上也会在后台运行...

要确保函数在独立于继承actor上下文的后台线程上运行,可以添加nonisolated关键字。

nonisolated func populate() async {

}

注意: Visualize and optimize Swift Concurrency 视频非常有用。


1
关于“Xcode 14更改了一些东西,实际上它也可以在后台运行”,如何确定?我尝试了Thread.isMainThread,但它说“Class property isMainThread is unavailable from asynchronous contexts.”如果一个Task在MainActor上下文中异步运行,则可以直接更新UI @State变量,但如果Task在不同的线程上,则需要使用DispatchQueue.main.async或在@State变量本身上使用@MainActor修饰符在主线程上更新@State变量,对吗?否则,您将收到有关在后台线程上更新的紫色警告。 - James Toomey
不是很确定 - 这是苹果工程师告诉我的... - aheze
1
@JamesToomey - 我怀疑工程师提到的是SE-0338,在这个提案中,他们明确规定非隔离的async方法不在任何特定的执行者上运行。 - Rob
1
@JamesToomey 请查看下面的帖子。在Xcode 14中,它确实在后台线程中运行。 - undefined
@LiangWang 谢谢你的发帖!很有用的信息。 - undefined
显示剩余4条评论

8
首先,你不能两全其美;要么在主线程上执行 CPU 密集型工作(对 UI 产生负面影响),要么在另一个线程上执行工作,但需要明确地将 UI 更新调度到主线程上。
然而,你真正关心的是:
(通过使用 Task)我认为这将在后台线程上执行 populate,但整个 UI 却冻结了。
当你使用 Task 时,你正在使用非结构化并发,当你通过 init(priority:operation) 初始化你的 Task 时,任务会继承调用者的优先级和 actor 上下文。

虽然Task是异步执行的,但它是使用调用者的演员上下文来执行的,在View body的上下文中是主要演员。这意味着尽管您的任务是异步执行的,但它仍在主线程上运行,并且该线程在处理过程中不可用于UI更新。因此,您是正确的,这与MainActor有关。

当您将Task移动到populate中时,它不再在MainActor上下文中创建,因此不在主线程上执行。

正如您发现的那样,您需要使用第二种方法来避免主线程。您只需要使用MainActor将最终更新移回到主队列即可:

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

您也可以在主体上下文中使用 Task.detached() 来创建一个未附加到 MainActor 上下文的 Task

1
谢谢,关于继承上下文的部分我明白了。但是关于 DispatchQueue.main.async {,我听说不应该将 async/await 与旧代码混合使用,你知道是否会有任何影响吗?我还尝试用 Task.detached 替换 Task,但没有任何变化。 - aheze
正确的,看我的更新。 - Paulw11

6

正如其他人所提到的那样,此行为的原因是 Task.init 自动继承了执行者上下文。您正在从按钮回调中调用您的函数:

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

}
func populate() async {
    Task.detached {
        // Calculation here
    }
}

虽然未结构化任务不是很规范,但我建议使用结构化任务,比如 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
    }
}

当您希望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: {
            // ...
        }
    }
}

此外,withTaskGroup 还可以创建结构化任务,并且您也可以避免继承演员上下文。当计算具有多个可以并发进行的子任务时,这可能非常有用。

2
在Xcode 14中,Task{}在上述示例中在后台线程中运行。让我证明一下。
需要强调的是,添加@MainActor将确保它在主线程上运行。

enter image description here


0

您可以通过删除类来解决此问题。您没有使用Combine,因此不需要其ObservableObject,如果坚持使用值类型,则SwiftUI最有效率。使用这种设计,按钮不会挂起:

extension String {
    static func makeItems() async -> [String]{
        var items = [String]()
        for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
            items.append("\(i)")
        }
        return items
    }
}

struct AnimateContentView: View {
    @State var rotation = CGFloat(0)
    @State var items = [String]()
    
    var body: some View {
        Button {
            Task {
                items = await String.makeItems()
            }
        } label: {
            Color.blue
                .frame(width: 300, height: 80)
                .overlay(
                    Text("\(items.count)")
                        .foregroundColor(.white)
                )
                .rotationEffect(.degrees(rotation))
        }
        .onAppear { /// should be a continuous rotation effect
            withAnimation(.easeInOut(duration: 2).repeatForever()) {
                rotation = 90
            }
        }
    }
}

谢谢!但是为什么这个类会引起问题呢?我更喜欢保留它,以便于代码组织和与UIKit共享状态。 - aheze
你可以使用自定义结构体来实现这个。 - malhal
3
@aheze,对于你所做的事情使用类没有任何问题。请放心使用。 - damd

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接