7

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)
    }
}
Louis Lac
  • 5,298
  • 1
  • 21
  • 36
  • 1
    don't mark the whole class as `@MainActor`, only parts you need to run on main thread. Or move network work into a different class, that is not @MainActor. Or use `DisptachQueue.global().async` to move heavy work to background, and then `DispatchQueue.main.async` for the parts that deal with UI. – timbre timbre Mar 16 '22 at 16:23
  • I tried it and unfortunately it does not scales well, basically need to wrap every state update in a `MainActor.run { }` which is even more boilerplate-ish than `DispatchQueue`. I thought that the `@MainActor` attribute only dispatch state update to the main actor, not entire method calls. `DispatchQueue` will certainly work but this defeat the whole purpose of using Swift Concurrency. – Louis Lac Mar 16 '22 at 16:54

2 Answers2

8

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 so all properties and methods on this class will, 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–forcing heavyWork to run on the main actor/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 directly. You can still read/modify the state of the actor if you await accesses to read operations or await calls to other methods on the actor that modify the state. In this case, you would need to make the heavyWork an async function.

Then (3), we wait for the value to be computed using the value property returned by the "task handle". This 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"
    }
}
Bradley Mackey
  • 6,777
  • 5
  • 31
  • 45
  • Thanks for you answer, this solves the issue (I edited my question with a solution based on yours which seems to work). IMO this is too bad that there is no way to automate this (doing all work on a background actor then updating the controller state on the main thread). I thought this was the role of the `@MainActor` attribute but it seems to schedule everything on the main actor instead. – Louis Lac Mar 16 '22 at 17:24
  • @LouisLac How do you mean automate this? In this example, the main actor is working as intended. You've told `Controller` it's main actor isolated, so everything it "does" should be on the main actor/thread. You can opt out of main actor isolation using `nonisolated` if you want or run your heavy work in a function that's located elsewhere - but it can't figure out what's "heavy" or not on its own. You will just sometimes need to use detached tasks and sometimes specify priorities for more fine-grained control over task execution. – Bradley Mackey Mar 16 '22 at 17:31
  • FWIW, I need to execute everything from a class on background and only update the state that drives the UI on the main actor. E.g. an `ObservableObject` that always works in the background and updates its published properties on the main actor. – Louis Lac Mar 16 '22 at 17:37
  • Weird, this solution does not work in my original (more complex) code but the usual GCD way works perfectly. Using Swift Concurrency I encounter a null pointer exception (which is probably due to a misuse on my side a guess). – Louis Lac Mar 16 '22 at 17:55
  • @LouisLac A null pointer exception sounds like a Swift bug to me. See if you can isolate the issue and report to https://bugs.swift.org – Bradley Mackey Mar 17 '22 at 08:55
  • 1
    I investigated it and the issue is likely on my side: probably a stack overflow due to too deep recursive call. Reducing the depth solved the issue. Thanks you for your help anyway! – Louis Lac Mar 17 '22 at 08:58
5

Maybe I have found an alternative, using actor hopping.

Using the same ContentView as above, we can move the heavyWork on a standard actor:

@MainActor
final class Controller: ObservableObject {

  @Published private(set) var isComputing: Bool = false

  func compute() {
    
    if isComputing { return }
    
    Task {
        
        isComputing = true
        
        print("Controller isMainThread: \(Thread.isMainThread)")
        let result = await Worker().heavyWork()
        print("result on main thread", result)

        isComputing = false
    }
  }
}

actor Worker {

  func heavyWork() -> String {

    print("Worker isMainThread: \(Thread.isMainThread)")
    sleep(5)
    return "result of heavy work"
  }
}

The function compute() is called on the main thread because of the MainActor's context inheritance but then, thanks to the actor hopping, the function heavyWork() is called on a different thread unblocking the UI.

DSoldo
  • 959
  • 10
  • 19