What is the best solution for background task using Swift async, await, @MainActor
Asked Answered
L

1

8

I’m studying async, await, @MainActor of Swift.

I want to run a long process and display the progress.

import SwiftUI

@MainActor
final class ViewModel: ObservableObject {
    @Published var count = 0

    func countUpAsync() async {
        print("countUpAsync() isMain=\(Thread.isMainThread)")
        for _ in 0..<5 {
            count += 1
            Thread.sleep(forTimeInterval: 0.5)
        }
    }

    func countUp() {
        print("countUp() isMain=\(Thread.isMainThread)")
        for _ in 0..<5 {
            self.count += 1
            Thread.sleep(forTimeInterval: 0.5)
        }
    }
}

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()

    var body: some View {
        VStack {
            Text("Count=\(viewModel.count)")
                .font(.title)

            Button("Start Dispatch") {
                DispatchQueue.global().async {
                    viewModel.countUp()
                }
            }
            .padding()

            Button("Start Task") {
                Task {
                    await viewModel.countUpAsync()
                }
            }
            .padding()
        }
        .padding()
    }
}

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

When I tap “Start Dispatch” button, the “Count” is updated but am warned:

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.

I thought the class ViewModel is @MainActor, count property is manipulated in Main thread, but not. Should I use DispatchQueue.main.async{} to update count although @MainActor?

When I tap “Start Task” button, button is pressed until the countupAsync() is done and not update Count on screen.

What is the best solution?

Leger answered 7/1, 2022 at 8:33 Comment(0)
P
18

You asked:

I thought the class ViewModel is @MainActor, count property is manipulated in Main thread, but not. Should I use DispatchQueue.main.async {} to update count although @MainActor?

One should avoid using DispatchQueue at all. Just use the new concurrency system where possible. See WWDC 2021 video Swift concurrency: Update a sample app for guidance about transitioning from the old DispatchQueue code to the new concurrency system.

If you have legacy code with DispatchQueue.global, you are outside the new cooperative pool executor, and you cannot rely on an actor to resolve this. You would either have to manually dispatch the update back to the main queue, or, better, use the new concurrency system and retire GCD entirely.

When I tap “Start Task” button, button is pressed until the countupAsync() is done and not update “Count” on screen.

Yes, because it is running on the main actor and you are blocking the main thread with Thread.sleep(forTimeInterval:). This violates a key precept/presumption of the new concurrency system that forward progress should always be possible. See Swift concurrency: Behind the scenes, which says:

Recall that with Swift, the language allows us to uphold a runtime contract that threads will always be able to make forward progress. It is based on this contract that we have built a cooperative thread pool to be the default executor for Swift. As you adopt Swift concurrency, it is important to ensure that you continue to maintain this contract in your code as well so that the cooperative thread pool can function optimally.

Now that discussion is in the context of unsafe primitives, but it applies equally to avoiding blocking API (such as Thread.sleep(fortimeInterval:)).

So, instead, use Task.sleep(nanoseconds:), which, as the docs point out, “doesn’t block the underlying thread.” Thus:

func countUpAsync() async throws {
    print("countUpAsync() isMain=\(Thread.isMainThread)")
    for _ in 0..<5 {
        count += 1
        try await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
    }
}

and

Button("Start Task") {
    Task {
        try await viewModel.countUpAsync()
    }
}

The async-await implementation avoids blocking the UI.


In both cases, one should simply avoid old GCD and Thread API, which can violate assumptions that the new concurrency system might be making. Stick with the new concurrent API and be careful when trying to integrate with old, blocking API.


You said:

I want to run a long process and display the progress.

Above I told you how to avoid blocking with Thread.sleep API (by using the non-blocking Task rendition). But I suspect that you used sleep as a proxy for your “long process”.

Needless to say, you will obviously want to make your “long process” run asynchronously within the new concurrency system, too. The details of that implementation are going to be highly dependent upon precisely what this “long process” is doing. Is it cancelable? Does it call some other asynchronous API? Etc.

I would suggest that you take a stab at it, and if you cannot figure out how to make it asynchronous within the new concurrency system, post a separate question on that topic, with a MCVE.

But, one might infer from your example that you have some slow, synchronous calculation for which you periodically want to update your UI during the calculation. That seems like a candidate for an AsyncSequence. (See WWDC 2021 Meet AsyncSequence.)

func countSequence() async {
    let stream = AsyncStream(Int.self) { continuation in
        Task.detached {
            for _ in 0 ..< 5 {
                // do some slow and synchronous calculation here
                continuation.yield(1)
            }
            continuation.finish()
        }
    }

    for await value in stream {
        count += value
    }
}

Above I am using a detached task (because I have a slow, synchronous calculation), but use the AsyncSequence to get a stream of values asynchronously.

There are lots of different approaches (which are be highly dependent upon what your “long process” is), but hopefully this illustrates one possible pattern.

Potheen answered 9/1, 2022 at 20:44 Comment(1)
Hello, Mr. Rob. Thank you for your advice. Thread.sleep(forTimeInterval:) is the cause. I just insert the Thread.sleep for debugging the progress bar to move slow. I replace to Taks.sleep(nanoseconds:), it works fine! Thank you very much. But I have to watch the WWDC0201 video Swift concurrency: Update a sample app and Swift concurrency: Behind the scenes. Thank you for your very kindly advice!Leger

© 2022 - 2024 — McMap. All rights reserved.