swiftasynchronousasync-awaitmainactor

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


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?


Solution

  • 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?

    Where possible, one should avoid using DispatchQueue at all. Remain within the new Swift concurrency system. 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(for:), which, as the docs point out, “doesn’t block the underlying thread.” Thus:

    func countUpAsync() async throws {
        for _ in 0..<5 {
            count += 1
            try await Task.sleep(for: .milliseconds(500))
        }
    }
    

    and

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

    The async-await implementation avoids blocking the UI.


    Now, if the “sleep” call was just a place holder for some async function, then just replace Task.sleep with whatever you are awaiting.

    func countUpAsync() async throws {
        for i in 0..<5 {
            count = i
            try await …
        }
    }
    

    Where possible, one should simply avoid old GCD and Thread API, which can violate assumptions that the new concurrency system might be making. Stick with Swift concurrency.

    The one exception to the “do not use GCD” is if the task in your loop was doing anything synchronous. We have a contract with Swift concurrency to never block a thread from the cooperative thread pool. For more information, see Integrate a blocking function into Swift async for this edge-case scenario.