Cooperative Task Cancellation

SwiftUI Concurrency Essentials

July 11, 2021 - 6 min read

Swift’s new concurrency features makes it much easier to write correct concurrent code. This is achieved in a number of ways, most prominently by adding features like async/await to the Swift language itself, allowing the compiler to perform flow analysis and providing meaningful feedback to the developer.

As Doug points out in this post on the Swift forums, “Swift has always been designed to be safe-by-default”, and “an explicit goal of the concurrency effort for Swift 6 is to make safe-by-default also extend to data races”.

Swift has always been designed to be safe-by-default.

In this series of articles and videos, my goal is to walk you through the key aspects of Swift’s new concurrency model that you need in the context of building SwiftUI apps specifically. Last time, I showed you how to fetch data from an asynchronous API using URLSession , how async/await helps us to get rid of the pyramid of doom, and how to call asynchronous code from a synchronous context.

Today, I want to focus on task cancellation. This is an important feature in Swift’s concurrency model that allows us to avoid unexpected behaviour in our apps.

Out-of-order search results

To understand why this is important, let’s take a look at the sample app I built for this post (available in the GitHub repository for the series). It’s a simple search screen that makes use of SwiftUI’s new searchable view modifier. The search field is bound to the searchTerm published property of view model, and we use the onReceive view modifier to listen to any changes to this property. Inside the closure, we create a new asynchronous task and call the executeQuery method on the view model.

struct BookSearchView: View {
  @StateObject
  fileprivate var viewModel = ViewModel()
  
  var body: some View {
    List(viewModel.result) { book in
      BookSearchRowView(book: book)
    }
    .overlay {
      if viewModel.isSearching {
        ProgressView()
      }
    }
    .searchable(text: $viewModel.searchTerm)
    .onReceive(viewModel.$searchTerm) { searchTerm in
      async {
        await viewModel.executeQuery()
      }
    }
  }
}

This means we run the query every time the user types a character, which essentially gives us a live search experience.

Demo

@MainActor
fileprivate class ViewModel: ObservableObject {
  @Published var searchTerm: String = ""
  
  @Published private(set) var result: [Book] = []
  @Published private(set) var isSearching = false
  
  func executeQuery() async {
    let currentSearchTerm = searchTerm.trimmingCharacters(in: .whitespaces)
    if currentSearchTerm.isEmpty {
      result = []
      isSearching = false
    }
    else {
      async {
        isSearching = true
        result = await searchBooks(matching: searchTerm)
        isSearching = false
      }
    }
  }
  
  private func searchBooks(matching searchTerm: String) async -> [Book] { ... }
}

If you pay close attention to the recording of the app, you will notice something strange: the user types a search term (“Hitchhiker”), and after a short moment, the result for this search term appears. But soon after, the search result is replaced with a different search result, which is a bit unexpected. If you take a closer look, you will notice that this second result is for the search term “Hitchhike”.

Why is that?

Well, it turns out that the OpenLibrary API has a rather slow response time (which is why it’s such a great showcase for our example). In addition, shorter search terms seem to take longer time to fetch, and this is why the results for earlier (shorter) search terms arrive after the results for longer search terms.

So when the results for the search term ”Hitchhiker” arrive, the requests for all the other previous search terms are still outstanding:

Requests arriving out of order
Requests arriving out of order

When they eventually finish, the ones that took a longer time to complete will overwrite the quicker ones. This results in the unexpected UX, and we definitely need to fix this. The OpenLibrary API might be an extreme example due to its slow response time, but you will observe similar behaviours in many other APIs as well.

Can we fix this?

Now, if you’ve used Combine before, you might recall the debounce operator - this operator will publish events only if a specified time has elapsed between two events. Since published properties are Combine publishers, it is actually pretty simple to make use of the debounce operator, so let’s see if this solves the problem:

.onReceive(viewModel.$searchTerm.debounce(for: 0.8, scheduler: RunLoop.main)) { searchTerm in
  async {
    await viewModel.executeQuery()
  }
}

By making this change, the debounce operator will only send the latest value of the searchTerm property to the receiver (in this case, the closure) when the user stops typing for 0.8 seconds.

And indeed, this does solve the problem when the user types their search term without making a pause. But we will run into the same problem again if they start typing, then pause to think for a short moment, and then continue typing before the result arrives for what they’d entered before the pause .

So - even though this is better (mostly because we reduce the number of requests we’re sending to the API, which also helps prevent thrashing), it’s not perfect.

Scratch this

To really improve the UX of our application, we need to make sure that any outstanding requests are cancelled before sending a new one.

Swift’s new concurrency features allow us to implement this with just a few additional lines of code. In executeQuery, we use async { } (which will be replaced with Task { } before Swift 5.5 goes GA) to launch a new asynchronous task from a synchronous context. A look at the source code reveals that async is a function that returns a Task.Handle:

public func async<T>(priority: Task.Priority? = nil, operation: @escaping @Sendable () async -> T) -> Task.Handle<T, Never>

Task.Handle is an affordance to interact with an active task (see the docs) - for example, we can use a task handle to cancel a task.

Here is the updated executeQuery function:

private var searchTask: Task.Handle<Void, Never>?
  
func executeQuery() async {
  searchTask?.cancel()
  let currentSearchTerm = searchTerm.trimmingCharacters(in: .whitespaces)
  if currentSearchTerm.isEmpty {
    result = []
    isSearching = false
  }
  else {
    searchTask = async {
      isSearching = true
      result = await searchBooks(matching: searchTerm)
      if !Task.isCancelled {
        isSearching = false
      }
    }
  }
}
  • To make this work, we create a private property to hold a reference to a Task.Handle<Void, Never> - this means our task doesn’t return a value, and it will never fail (i.e. it doesn’t throw).
  • Inside the executeQuery function, we first cancel any previously launched task.
  • Next, we store the task we create for the new search request in the searchTask property (so we can cancel it later, if needed)

And that’s basically it! This works because URLSession’s async methods support Swift’s new concurrency model. To learn more about using async/await with URLSession, check out Apple’s official WWDC video.

Note that we guard updating the isSearching property by checking if the current task has been cancelled. This property is used to drive the progress spinner on the UI, and we want to make sure the spinner is visible as long as any search request is outstanding. This is why we may only set this property to false if a search request has successfully completed, or the search term is empty.

If you are using APIs that participate in cooperative task cancellation, you’re all set now. However, if you are the author of an API yourself, there are some extra steps you need to take to make sure your code takes part in cooperative task cancellation.

Cooperative Task Cancellation

The documentation for cancel() contains a very important note:

Whether this function has any effect is task-dependent.

For a task to respect cancellation it must cooperatively check for it while running. Many tasks will check for cancellation before beginning their “actual work”, however this is not a requirement nor is it guaranteed how and when tasks check for cancellation in general.

This means you are responsible for stopping any work as soon as possible when a caller requests to cancel the task your code is running on.

Consider the following code snippet. It is an iterative implementation of a function that computes the nth Fibonacci number. I’ve artificially slowed down the algorithm by adding Task.sleep() to the inner loop.

private func fiboncacci(nth: Int, progress: ((Int) -> Void)? = nil) async throws -> Int {
  var last = 0
  var current = 1
    
  for i in 0..<nth {
    // this is the real computation
    (current, last) = (current + last, current)
      
    // simulate compute-intensive behaviour (pause for 0.75 sec)
    await Task.sleep(75_000_000)
      
    // report progress
    progress?(i)
      
    // cooperative cancellation
    try Task.checkCancellation()
  }
    
  return last
}

By calling Task.checkCancellation(), the function checks if the caller has requested to cancel the task. Task.checkCancellation() will check is Task.isCancelled is true, and will throw Task.CancellationError if that’s the case. This way, the fibonacci function can stop any ongoing work after each iteration of its inner loop.

If you’d rather not throw Task.CancellationError, you can use Task.isCancelled to check if the current task has been cancelled and stop any ongoing work.

Throwing an error is just one way to respond to cancellation. Depending on the kind of work your code performs, you should choose which of the following options works best:

  • Throwing an error (as demonstrated above)
  • Returning nil or an empty collection
  • Returning the partially completed work

SwiftUI’s new task() view modifier

SwiftUI features a new view modifier that lets you run code in an asynchronous task as soon as the view appears. It will automatically cancel the task once the view disappears.

Here is a snippet from the sample in the previous article in this series:

struct WordSearchView: View {
  @StateObject var viewModel = WordsAPIViewModel()
  var body: some View {
    List { ... }
    .task {
      viewModel.searchTerm = "Swift"
      await viewModel.executeQuery()
    }
  }
}

If you need to call asynchronous code when your view appears, favour using task { } over onAppear / onDisappear.

Yielding

One final piece of advice: if you’re writing computationally intensive code, you should call Task.yield() every now and then to yield to the system and give it the opportunity to perform any other work, such as updating the UI. If you don’t do this, your app might appear to be frozen to the user, even though it is actively running some computationally intensive code.

Closure

Swift’s new concurrency model makes it easy to write well-structured apps that handle concurrency in a predictable and structured way. Personally, I like the way how the concepts have been added to the language in a domain-specific way - this falls in line with many other areas of Swift that make use of DSL approaches as well (such as SwiftUI itself, for example).

I hope this post helped you understand how to use task cancellation to make your UIs more predictable and increase their usability. If you’ve got any questions, reach out to me on Twitter, or leave a comment on the repository for this post.

Keep in mind that Swift’s Structured Concurrency is still a work in progress, and some of its features are undergoing some fluctuation. To keep on top of things, I recommend keeping an eye on the relevant proposals, and following the discussion on the Swift forums:

Thanks for reading! 🔥


The header image is based on xmark.circle.fill from Apple’s SF Symbols.


Read next