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

`Task` blocks main thread when calling async function inside

我有一个 ObservableObject class 和一个 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,旋转动画不会断断续续,所以我认为循环实际上是在的背景。但是我现在收到 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()
}

结果:

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

您可以通过删除 class 来修复它。你没有使用 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
            }
        }
    }
}

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

然而,你真正想问的是

(By using Task) I thought this would execute populate on a background thread but instead the entire UI freezes.

当您使用 Task 时,您正在使用 unstructured concurrency, and when you initialise your Task via init(priority:operation) 任务...继承调用者的优先级和参与者上下文

虽然 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

考虑:

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

你用async标记了populate,但是这里没有异步,会阻塞调用线程。

然后考虑:

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

看起来它一定是异步的,因为它正在启动 Task。但是这个Task运行s在同一个actor上,而且由于task是慢同步的,所以会阻塞当前的executor。

如果你不想运行在主要演员身上,你可以:

  1. 可以把view model留在main actor上,但是手动把慢同步进程移到detached task:

    @MainActor
    class ViewModel: ObservableObject {
        @Published var items = [String]()
    
        func populate() {
            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)")
                }
                await MainActor.run { [items] in
                    self.items = items
                }
            }
        }
    }
    
  2. 为了完整起见,你也可以让视图模型成为它自己的actor,并且只指定相关的观察属性在主actor上:

    actor ViewModel: ObservableObject {
        @MainActor
        @Published var items = [String]()
    
        func populate() {
            Task {
                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)")
                }
                await MainActor.run { [items] in
                    self.items = items
                }
            }
        }
    }
    

我通常倾向于前者,但这两种方法都有效。

无论哪种方式,它都会让慢进程不阻塞主线程。在这里,我点击了两次按钮:


查看 WWDC 2021 视频 Swift concurrency: Behind the scenes, Protect mutable state with Swift actors, and Swift concurrency: Update a sample app,所有这些在尝试理解从 GCD 到 Swift 并发的过渡时都很有用。

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

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

}

按钮回调在主要角色上,因此传递给 Task 初始化程序的闭包也在主要角色上。

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

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 函数异步地 return 一些值时,这很有用。这种结构化任务方法还意味着可以自动传播取消。例如,如果你想在短时间内多次点击按钮时取消计算,你可以这样做:

@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 还会创建结构化任务,您也可以避免继承 actor 上下文。当您的计算有多个可以同时进行的子任务时,它会很有用。