Search code examples
iosswiftswiftuiasync-awaitswift-concurrency

`Task` blocks main thread when calling async function inside


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.


Solution

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