SwiftUI Tasks Blocking the MainActor

I find it way too easy to accidentally block the MainActor, and hang the user interface, with a long-running task that I thought was on a background thread. Here’s a recent example.

User Interface Hangs

Apple recommends that you don’t run long-running tasks on the main thread. Block the main thread for much more than 100ms and the user will notice the non-responsive or hanging user interface.

With Swift Concurrency that means keeping long-running work off the MainActor. It’s a mistake that I find easy to make, especially when I start sprinkling @MainActor over my code to silence strict concurrency checking.

Take a look at this SwiftUI view with a button that increments a count:

@MainActor
struct ContentView: View {
  @State private var appModel = AppModel()

  var body: some View {
    VStack {
      Button("Count", action: appModel.count)
        .buttonStyle(.borderedProminent)

      Text(appModel.counter.formatted())
        .fontDesign(.monospaced)
        .font(.largeTitle)

      Text("Running: \(appModel.running ? "Yes": "No")")
    }
    .task {
      // MainActor here
      await appModel.start()
    }
  }
}

The view has an async task that starts before the view appears that, when it completes, updates the running state shown below the count. The two methods and values used by the view are part of an Observable app model declared as a state property of the view.

The AppModel methods are called from the ContentView on the MainActor so I ended up isolating the whole app model to the MainActor:

@MainActor
@Observable final class AppModel {
  private(set) var counter: Int = 0
  private(set) var running: Bool = false

  func count() {
    counter += 1
  }

  func start() async {
    // MainActor here
    running = await doWork()
  }

  private func doWork() async -> Bool {
    // Which actor?
    sleep(10)
    return true
  }
}

The start method calls another async method, also declared in the AppModel, that performs the long-running work returning a status. For brevity here, I’ve replaced the actual task which involved importing some data with a ten second delay.

Maybe you can already see it, but this hangs the user interface for ten seconds until the doWork method completes. In this case, the doWork method runs on the MainActor because the entire AppModel is isolated to the MainActor and we end up blocking the user interface.

Async and Nonisolated

I found this Apple article on Improving app responsiveness helpful to understand the problem and possible solutions.

To quote the key points:

With Swift concurrency, make sure not to accidentally execute work on the MainActor. The correct approach to get work off of the main actor depends on whether you can refactor the heavy work into a non-actor-isolated asynchronous function.

It’s not enough for the work to be asynchronous, it also needs to non-isolated from the MainActor if you don’t want to block the user interface.

If you can wrap the long-running work in such a way to make it async and nonisolated, it’s easy to execute it off of the main actor with a Task and await.

If this isn’t possible, execute the synchronous function inside a call to the Task.detached(priority:operation:) method.

In my case, it’s possible to separate the long-running work from updating the AppModel state. That means I can solve my user interface hang by marking the doWork method as nonisolated so it no longer runs on the MainActor:

private nonisolated func doWork() async -> Bool {
  sleep(10)
  return true
}

Tooling Help?

What’s interesting to me about this type of problem is that it’s not a compilation error. Turning on strict concurrency checking doesn’t help here. The UI hang is not caused by a data race but by doing too much work on the main thread. That was unintended but, for me at least, seems like an easy mistake to make.

Instruments severe hang

You can use the Hangs tool in Instruments to check for UI hangs but I would love to see Xcode help us visualize what’s going on in the source code. For example, show me code on and off the Main Actor in different colours so I can quickly see the problem.

Read More

I’m still a long way from mastering Swift Concurrency. As well as the Apple article, I’m also finding it educational to watch the Concurrency tag on the Swift Forums: