'How to execute a CPU-bound task in background using Swift Concurrency without blocking UI updates?
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 scatteringawait 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 subsequentTask.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)
}
}
Solution 1:[1]
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"
}
}
Solution 2:[2]
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.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | DSoldo |