Home > Back-end >  How to execute a CPU-bound task in background using Swift Concurrency without blocking UI updates?
How to execute a CPU-bound task in background using Swift Concurrency without blocking UI updates?

Time:03-17

I have an ObservableObject which can do a CPU-bound heavy work:

import Foundation
import SwiftUI

@MainActor
final class Controller: ObservableObject {
    @Published private(set) var isComputing: Bool = false
    
    func compute() {
        if isComputing { return }
        
        Task {
            heavyWork()
        }
    }
    
    func heavyWork() {
        isComputing = true
        sleep(5)
        isComputing = false
    }
}

I use a Task to do the computation in background using the new concurrency features. This requires using the @MainActor attribute to ensure all UI updates (here tied to the isComputing property) are executed on the main actor.

I then have the following view which displays a counter and a button to launch the computation:

struct ContentView: View {
    @StateObject private var controller: Controller
    @State private var counter: Int = 0
    
    init() {
        _controller = StateObject(wrappedValue: Controller())
    }
    
    var body: some View {
        VStack {
            Text("Timer: \(counter)")
            Button(controller.isComputing ? "Computing..." : "Compute") {
                controller.compute()
            }
            .disabled(controller.isComputing)
        }
        .frame(width: 300, height: 200)
        .task {
            for _ in 0... {
                try? await Task.sleep(nanoseconds: 1_000_000_000)
                counter  = 1
            }
        }
    }
}

The problem is that the computation seems to block the entire UI: the counter freezes.

Why does the UI freeze and how to implement .compute() in such a way it does not block the UI updates?


What I tried

  • Making heavyWork and async method and scattering await Task.yield() every time a published property is updated seems to work but this is both cumbersome and error-prone. Also, it allows some UI updates but not between subsequent Task.yield() calls.
  • Removing the @MainActor attribute seems to work if we ignore the purple warnings saying that UI updates should be made on the main actor (not a valid solution though).

Edit 1

Thanks to the answer proposed by @Bradley I arrived to this solution which works as expected (and is very close to the usual DispatchQueue way):

@MainActor
final class Controller: ObservableObject {
    @Published private(set) var isComputing: Bool = false
    
    func compute() {
        if isComputing { return }
        
        Task.detached {
            await MainActor.run {
                self.isComputing = true
            }
            await self.heavyWork()
            await MainActor.run {
                self.isComputing = false
            }
        }
    }
    
    nonisolated
    func heavyWork() async {
        sleep(5)
    }
}

CodePudding user response:

The issue is that heavyWork inherits the MainActor isolation from Controller, meaning that the work will be performed on the main thread. This is because you have annotated Controller with @MainActor, meaning all properties and methods by default inherit the MainActor isolation. But also when you create a new Task { }, this inherits the current task's (i.e. the MainActor's) current priority and actor isolation meaning heavyWork is guaranteed to run on the main thread.

We need to ensure (1) that we run the heavy work at a lower priority, so the system is less likely to schedule it on the UI thread. This also needs to be a detached task, which will prevent the default inheritance that Task { } performs. We can do this by using Task.detached with a low priority (like .background or .low).

Then (2), we ensure that the heavyWork is nonisolated, so it will not inherit the @MainActor context from the Controller. However, this does mean that you can no longer mutate any state on the Controller (but you can still access state as long as you await the accesses inside heavyWork).

Then (3), we wait for the value to be computed using the value property returned by the "task handle". This ensures that the defer block will not be called until the work is done, but also allows us to access a return value from the heavyWork function, if any.

@MainActor
final class Controller: ObservableObject {
    @Published private(set) var isComputing: Bool = false
    
    func compute() {
        if isComputing { return }
        Task {
            isComputing = true

            // (1) run detached at a non-UI priority
            let work = Task.detached(priority: .low) {
                self.heavyWork()
            }

            // (3) non-blocking wait for value
            let result = await work.value
            print("result on main thread", result)

            isComputing = false
        }
    }
    
    // (2) will not inherit @MainActor isolation
    nonisolated func heavyWork() -> String {
        sleep(5)
        return "result of heavy work"
    }
}
  • Related