2

I ran into an issue where my async functions caused the UI to freeze, even tho I was calling them in a Task, and tried many other ways of doing it (GCD, Task.detached and the combination of the both). After testing it I figured out which part causes this behaviour, and I think it's a bug in Swift/SwiftUI.

Bug description

I wanted to calculate something in the background every x seconds, and then update the view, by updating a @Binding/@EnvironmentObject value. For this I used a timer, and listened to it's changes, by subscribing to it in a .onReceive modifier. The action of this modifer was just a Task with the async function in it (await foo()). This works like expected, so even if the foo function pauses for seconds, the UI won't freeze BUT if I add one @EnvironmentObject to the view the UI will be unresponsive for the duration of the foo function.

GIF of the behaviour with no EnvironmentVariable in the view:

Correct, expected behaviour

GIF of the behaviour with EnvironmentVariable in the view:

Incorrect, unresponsive behaviour

Minimal, Reproducible example

This is just a button and a scroll view to see the animations. When you press the button with the EnvironmentObject present in the code the UI freezes and stops responding to the gestures, but just by removing that one line the UI works like it should, remaining responsive and changing properties.

import SwiftUI

class Config : ObservableObject{
    @Published var color : Color = .blue
}

struct ContentView: View {
    //Just by removing this, the UI freeze stops
    @EnvironmentObject var config : Config
    
    @State var c1 : Color = .blue
    
    var body: some View {
        ScrollView(showsIndicators: false) {
            VStack {
                HStack {
                    Button {
                        Task {
                            c1 = .red
                            await asyncWait()
                            c1 = .green
                        }
                    } label: {
                        Text("Task, async")
                    }
                    .foregroundColor(c1)
                }
                ForEach(0..<20) {x in
                    HStack {
                        Text("Placeholder \(x)")
                        Spacer()
                    }
                    .padding()
                    .border(.blue)
                }
            }
            .padding()
        }
    }
    
    func asyncWait() async{
        let continueTime: Date = Calendar.current.date(byAdding: .second, value: 2, to: Date())!
        while (Date() < continueTime) {}
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Disclaimer

I am fairly new to using concurrency to the level I need for this project, so I might be missing something, but I couldn't find anything related to the searchwords "Task" and "EnvironmentObject".

Question

Is this really a bug? Or am I missing something?

harcipulyka
  • 117
  • 1
  • 6
  • 2
    Does this answer your question? [\`Task\` blocks main thread when calling async function inside](https://stackoverflow.com/questions/71837201/task-blocks-main-thread-when-calling-async-function-inside) – Fabio Felici Oct 25 '22 at 13:30
  • No, I saw that question, and tried those solutions before I found out that the EnvironmentObject is the cause for the UI lags. – harcipulyka Oct 25 '22 at 13:46
  • 1
    It's not `EnvironmentObject` the cause of your bug, the cause is that you are blocking the MainThread when calling `asyncWait` – Fabio Felici Oct 25 '22 at 13:48
  • On my playground, the UI is frozen even if I remove the `EnvironmentObject` line. – Fabio Felici Oct 25 '22 at 13:51
  • Try it in a simulator instead, there I get the same result every time, as described in the question. – harcipulyka Oct 25 '22 at 13:53
  • My theory is that the OP used Task.detached but then called out to another function (asyncWait) which was running on the main actor. – jnpdx Oct 26 '22 at 21:40
  • Yep, you’re correct. – Rob Oct 27 '22 at 22:50
  • But I still contend that spinning is the wrong solution. One should never spin. `Task.sleep` avoids spinning and is non-blocking. – Rob Oct 27 '22 at 23:09
  • Yes, definitely. Again, speculation, but my suspicion is that the OP was just trying to demonstrate an async event (but, of course, didn’t actually do anything async) – jnpdx Oct 27 '22 at 23:42

2 Answers2

2

As far as I can tell, your code, with or without the @EnvrionmentObject, should always block the main thread. The fact that it doesn't without @EnvironmentObject may be a bug, but not the other way around.

In your example, you block the main thread -- you call out to an async function that runs on the context inherited from its parent. Its parent is a View, and runs on the main actor.

Usually in this situation, there's confusion about what actually runs something outside of the inherited context. You mentioned using Task.detached, but as long as your function was still marked async on the parent, with no other modifications, in would still end up running on the main actor.

To avoid inheriting the context of the parent, you could, for example, mark it as nonisolated:

nonisolated func asyncWait() async {
    let continueTime: Date = Calendar.current.date(byAdding: .second, value: 2, to: Date())!
    while (Date() < continueTime) {}
}

Or, you could move the function somewhere (like to an ObservableObject outside of the View) that does not explicitly run on the main actor like the View does.

Note that there's also a little bit of deception here because you've marked the function as async, but it doesn't actually do any async work -- it just blocks the context that it's running on.

jnpdx
  • 45,847
  • 6
  • 64
  • 94
  • That solved it, thank you! I'll have to learn about this a lot more! Do you think that me having to use this nonisolated keyword is a result of some bad practice? Because I haven't seen this before, even though I read many guides on async/await. – harcipulyka Oct 25 '22 at 14:28
  • 1
    Not necessarily a bad practice, but it's certainly good to know as many details about concurrency as possible before doing too much in that world (especially the unstructured part). You may want to give https://developer.apple.com/documentation/xcode/improving-app-responsiveness a read – jnpdx Oct 25 '22 at 14:34
0

The issue is that Task { ... } adds the task to the current actor. If you have some slow, synchronous task, you never want that on the main actor. People often conflate Task { ... } with DispatchQueue.global().async { ... }, but they are not the same thing.

And you should also avoid putting anything slow and synchronous in an @MainActor isolated function.

If you want to get some slow and synchronous process off the current actor, you would generally use Task.detached { ... }. Or you could create a separate actor for the time consuming process.

But in this case, there is no need to do any of this. Instead, use Task.sleep, which is a rendition of sleep designed for Swift concurrency which “doesn’t block the underlying thread.”

Button {
    Task {
        c1 = .red
        try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
        c1 = .green
    }
} label: {
    Text("Task, async")
}
.foregroundColor(c1)

Avoid spinning. Thread.sleep is a little better, but is still inadvisable. Use Task.sleep.

enter image description here

Rob
  • 415,655
  • 72
  • 787
  • 1,044