`Task` blocks main thread when calling async function inside
Asked Answered
T

6

42

I have an ObservableObject class and a SwiftUI view. When a button is tapped, I create a Task and call populate (an async function) from within it. I thought this would execute populate on a background thread but instead the entire UI freezes. Here's my code:

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
            }
        }
    }
}

Result:

Rotation animation freezes when the button is pressed

The button stops moving, then suddenly snaps back when populate finishes.

Weirdly, if I move the Task into populate itself and get rid of the async, the rotation animation doesn't stutter so I think the loop actually got executed in the background. However I now get a Publishing changes from background threads is not allowed warning.

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()
}

Result:

Rotation animation continues even when the button is pressed

How can I ensure my code gets executed on a background thread? I think this might have something to do with MainActor but I'm not sure.

Thapsus answered 12/4, 2022 at 4:38 Comment(6)
await means that you wait for the async function to return you a result (which obviously blocks the current thread, which is main). In your second option you run the task on a background thread, so you need to ensure that results of the task are delivered to the main thread. The error you see hints you about this and provides an example, how to achieve this. How do you deliver items to the UI? Is it just a property on an observable object?Lassiter
@Lassiter but aren't Tasks asynchronous? When I await inside a Task shouldn't the current thread be a background thread? I can't figure out the difference between the first and second options. Anyway in SwiftUI all you need to do is set the property and the UI automatically updates.Thapsus
Have you tried to use in your second option MainActor.run { self.items = items } to move the update to the main thread?Lassiter
Task should be detached and a function performing published property modification is wrapped with MainActor - possible solution is hereGratulation
@Lassiter I tried that, but I get Reference to captured var 'items' in concurrently-executing codeThapsus
I summarized my findings in this post: https://mcmap.net/q/391591/-how-can-i-avoid-that-my-swift-async-method-runs-on-the-main-thread-in-swiftuiEpisiotomy
S
58

First, as a general observation, in WWDC 2021’s Discover concurrency in SwiftUI, they recommend that you isolate the ObservableObject object to the main actor.

But the hitch in the UI is caused by the main actor being blocked by this slow process. So we must get this task off the main actor. There are a few possible approaches:

  1. You can move the slow synchronous process to a “detached” task. While Task {…} starts a new top-level task “on behalf of the current actor”, a detached task is an “unstructured task that’s not part of the current actor”. So, detached task will avoid blocking the current actor:

    @MainActor
    class ViewModel: ObservableObject {
        @Published var items = [String]()
    
        func populate() async {
            let task = Task.detached {                              // this introduces unstructured concurrency!!!
                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)")
                }
                return items
            }
    
            items = await task.value
        }
    }
    

    Note, while this solves the blocking problem, unfortunately, Task.detached {…} (like Task {…}) is unstructured concurrency. You really should wrap it in an withTaskCancellationHandler. And while here (pursuant to my observations in point 4, below), we should also:

    • Periodically yield to the Swift concurrency system; and
    • Periodically check for cancelation.

    So, like so:

    @MainActor
    class ViewModel: ObservableObject {
        @Published var items = [String]()
    
        func populate() async throws {
            let task = Task.detached {                              // this introduces unstructured concurrency
                var items: [String] = []
    
                for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
                    if i.isMultiple(of: 1000) {
                        await Task.yield()
                        try Task.checkCancellation()
                    }
                    items.append("\(i)")
                }
                return items
            }
    
            try await withTaskCancellationHandler {                 // with unstructured concurrency we have to handle cancelation manually 
                items = try await task.value
            } onCancel: { 
                task.cancel()
            }
        }
    }
    
  2. As of Swift 5.7, one can achieve the same behavior with an async function that is nonisolated (see SE-0338). And this keeps us within the realm of structured concurrency, but still gets the work off the current actor:

    @MainActor
    class ViewModel: ObservableObject {
        @Published var items = [String]()
    
        private nonisolated func generate() async throws -> [String] {
            var items: [String] = []
    
            for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
                if i.isMultiple(of: 1000) {
                    await Task.yield()
                    try Task.checkCancellation()
                }
    
                items.append("\(i)")
            }
            return items
        }
    
        func populate() async throws {
            items = try await generate()
        }
    }
    
  3. Or we can do this with a separate actor for the time-consuming process, which again gets the task off the view model’s actor:

    @MainActor
    class ViewModel: ObservableObject {
        @Published var items = [String]()
        private let generator = Generator()
    
        private actor Generator {
            func generate() async throws -> [String] {
                var items: [String] = []
    
                for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change
                    if i.isMultiple(of: 1000) {
                        await Task.yield()
                        try Task.checkCancellation()
                    }
                    items.append("\(i)")
                }
                return items
            }
        }
    
        func populate() async {
            items = try await generator.generate()
        }
    }
    
  4. As shown in my examples above, I would advise adding cancelation logic (in case the user wants to interrupt the calculation and start another) with try Task.checkCancellation().

    Also, in Swift concurrency, we should never violate the contract to “ensure forward progress”, or, if you must, periodically Task.yield to ensure proper function of this concurrency system. As SE-0296 says:

    Because potential suspension points can only appear at points explicitly marked within an asynchronous function, long computations can still block threads. This might happen when calling a synchronous function that just does a lot of work, or when encountering a particularly intense computational loop written directly in an asynchronous function. In either case, the thread cannot interleave code while these computations are running, which is usually the right choice for correctness, but can also become a scalability problem. Asynchronous programs that need to do intense computation should generally run it in a separate context. When that’s not feasible, there will be library facilities to artificially suspend and allow other operations to be interleaved.

    Now, the previously mentioned techniques (points 1-3, above) address your primary concern by prevent the blocking of the main actor. But the deeper observation here is that we really should avoid blocking any actors with “long running” work. But Task.yield addresses that problem.

    This periodic checking for cancelation and yielding is only needed when writing our own computationally intensive tasks. Most of Apple‘s async API (e.g. URLSession, etc.), already handle these issues for us.

    Anyway, all of this discussion on cancelation begs the question of how one would go about canceling a prior task. Simply save the Task in a property of the actor-isolated view model and then cancel the prior one before starting the next. E.g.:

    private var task: Task<Void, Error>?
    
    func start() {
        task?.cancel()                         // cancel prior one, if any
        task = Task { try await populate() }
    }
    

Anyway, these patterns will allow the slow process to not block the main thread, resulting in an uninterrupted UI. Here I tapped on the button twice:

enter image description here

Needless to say, that is without the “cancel prior one” logic. With that logic, you can tap multiple times, all the prior once will be canceled, and you will see only one update, avoiding potentially over taxing the system with a bunch of redundant tasks. But the idea is the same, an smooth UI while performing complex tasks.


See WWDC 2021 videos Swift concurrency: Behind the scenes, Protect mutable state with Swift actors, and Swift concurrency: Update a sample app, all of which are useful when trying to grok the transition from GCD to Swift concurrency.

Surgy answered 14/4, 2022 at 19:23 Comment(0)
T
12

Update 6/10/22: At WWDC I asked some Apple engineers about this problem — it really is all about actor inheritance. However, there were some compiler-level changes in Xcode 14 Beta. For example, this will run smoothly on Xcode 14, but lag on Xcode 13:

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)")
        }

        /// explicitly capture `items` to avoid `Reference to captured var 'items' in concurrently-executing code; this is an error in Swift 6`
        Task { @MainActor [items] in
            self.items = items
        }
    }
}

struct ContentView: View {
    @StateObject var model = ViewModel()
    @State var rotation = CGFloat(0)

    var body: some View {
        Button {
            Task {

                /// *Note!* Executes on a background thread in Xcode 14.
                await self.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
            }
        }
    }
}

Again, Task inherits the context of where it's called.

  • A Task called from within a plain ObservableObject class will run in the background, since the class isn't a main actor.
  • A Task called inside a Button will probably run on the main actor, since the Button is a UI element. Except, Xcode 14 changed some things and it actually runs in the background too...

To make sure a function runs on the background thread, independent of the inherited actor context, you can add nonisolated.

nonisolated func populate() async {

}

Note: the Visualize and optimize Swift concurrency video is super helpful.

Thapsus answered 10/6, 2022 at 14:24 Comment(8)
Re "Xcode 14 changed some things and it actually runs in the background too", how to know for sure? I tried Thread.isMainThread, but it says "Class property isMainThread is unavailable from asynchronous contexts." If a Task is running asynchronously but in the MainActor context, a UI @State var can be updated directly, but if the Task is on a different thread, then the @State var needs to be updated on the main thread using DispatchQueue.main.async or a @MainActor decorator on the @State var itself, correct? Or you will get the purple warning about an update on a background thread.Veridical
Not that sure — that's what the Apple engineer told me...Thapsus
Interesting! I'm sure it's right then. I wish they had more documentation about it. Maybe the docs will get better over time.Veridical
I have tested the OPs code. It runs on the background thread. Task will do a dispatch to the background.Aleris
@JamesToomey - I suspect the engineer was referencing SE-0338, where they formalized that non-isolated async methods do not run on any particular actor.Surgy
@Thapsus - Just to be precise, where you state “Executes on a background thread in Xcode 14” might be misleading. That Task {…} is on the main actor. It is populate which doesn’t run on the current actor. Maybe that’s what you were trying to say, but just a clarification for future readers.Surgy
Yes, we can't ensure anything undocumented. The only safe way is mark nonisolated at the function should be run in background thread we expect.Woken
@JamesToomey check my thread below. In Xcode 14, it does run in background threadBarroom
V
9

First, you can't have it both ways; Either you perform your CPU intensive work on the main thread (and have a negative impact on the UI) or you perform the work on another thread, but you need to explicitly dispatch the UI update onto the main thread.

However, what you are really asking about is

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

When you use a Task you are using unstructured concurrency, and when you initialise your Task via init(priority:operation) the task ... inherits the priority and actor context of the caller.

While the Task is executed asynchronously, it does so using the actor context of the caller, which in the context of a View body is the main actor. This means that while your task is executed asynchronously, it still runs on the main thread and that thread is not available for UI updates while it is processing. So you are correct, this has everything to do with MainActor.

When you move the Task into populate it is no longer being created in a MainActor context and therefore does not execute on the main thread.

As you have discovered, you need to use this second approach to avoid the main thread. All you need to do to your code is move the final update back to the main queue using the MainActor:

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

You could also use Task.detached() in the body context to create a Task that is not attached the MainActor context.

Voluptuary answered 12/4, 2022 at 10:0 Comment(2)
Thanks, that part about inheriting the context makes sense. But about the DispatchQueue.main.async {, I heard that you shouldn't mix async/await with the old stuff, do you know if there's any impact? I also tried replacing Task with Task.detached and nothing happened.Thapsus
Correct, see my updateVoluptuary
U
6

As others have mentioned, the reason of this behavior is that the Task.init inherits the actor context automatically. You're calling your function from the button callback:

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

}

The button callback is on the main actor, so the closure passed to the Task initializer is on the main actor too.

One solution is using a detached task:

func populate() async {
    Task.detached {
        // Calculation here
    }
}

While detached tasks are unstructured, I'd like to suggest structured tasks like async let tasks:

@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
    }
}

This is useful when you want the populate function to return some value asynchronously. This structured task approach also means cancellation can be propagated automatically. For example, if you want to cancel the calculation when the button is tapped multiple times in a short time, you can do something like this:

@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: {
            // ...
        }
    }
}

Moreover, withTaskGroup also creates structured tasks and you can avoid inheriting the actor context too. It can be useful when your computation has multiple child tasks that can progress concurrently.

Unobtrusive answered 24/4, 2022 at 13:47 Comment(0)
B
2

In Xcode 14, Task{} does run in the background thread for the above example. Let me prove it.

To be highlighted, Adding @MainActor will make sure it runs on the main thread.

enter image description here

Barroom answered 12/11, 2023 at 22:42 Comment(0)
S
0

You can fix it by removing the class. You aren't using Combine so you don't need its ObservableObject and SwiftUI is most efficient if you stick to value types. The button doesn't hang with this design:

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
            }
        }
    }
}
Surcharge answered 12/4, 2022 at 8:35 Comment(3)
Thanks! How come the class causes problems though? I would prefer to keep it for the code organization and ability to share state with UIKitThapsus
you can use custom structs for thatSurcharge
@Thapsus There's nothing wrong with using classes for what you're doing. Feel free to use them.Phototypography

© 2022 - 2025 — McMap. All rights reserved.