Cooperative Task Cancellation
SwiftUI Concurrency Essentials
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.
This means we run the query every time the user types a character, which essentially gives us a live search experience.
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:
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:
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 create a new Task
to launch a new asynchronous task from a synchronous context. Task
has a number of methods to interact with an active task - for example, we can use a task handle to cancel a task (see the docs).
Here is the updated executeQuery
function:
- To make this work, we create a private property to hold a reference to a
Task<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.
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:
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.
Thanks for reading! 🔥
Improve your app's UX with SwiftUI's task view modifier
Mastering the art of the pause
SwiftUI Hero Animations with NavigationTransition
Replicating the App Store Hero Animation
Styling SwiftUI Views
How does view styling work?
Previewing Stateful SwiftUI Views
Interactive Previews for your SwiftUI views
Asynchronous programming with SwiftUI and Combine
The Future of Combine and async/await