Hallo tassen en geldriemen, Leo hier. Today we will explore a little problem managing multiple async tasks in SwiftUI.
The new concurrency system in Swift is something that we all should start getting into. Don’t get me wrong, I’m not saying the old system has problems, no it works well. As you need it to ace an interview setup or the occasion that you need to trigger a series of asynchronous tasks one after another. The old system comes to the rescue!
All those capabilities and much more that we already have are extraordinary and they can be used to build everything that you need to run asynchronous work in your App.
However, as the Swift evolution continues come, you will see more and more Apple adopting this new Structured Concurrency. It introduces new features such as async/await and the Task type, which allow developers to write asynchronous code in a more intuitive and readable way. Structured concurrency also introduces the concept of cancellation tokens, which allow developers to cancel tasks that are no longer needed or have become irrelevant.
We will use the cancellation token concept to solve an asynchronous problem in this week’s article today.
Brace yourselves, and let’s code! But first…
Painting of the Day
The painting I chose for today is called Jolly Toper by the Dutch painter Judith Leyster in 1629.
Leyster was born in Haarlem, the eighth child of Jan Willemsz Leyster, a local brewer and clothmaker. While the details of her painting skill building are uncertain, she was already famous in 1628 to be mentioned in a Dutch book by Samuel Ampzing with the title Beschrijvinge ende lof der stadt Haerlem.
Her full oeuvre was attributed to Frans Hals or to her husband, Jan Miense Molenaer, until 1893 when Hofstede de Groot first attributed seven paintings to her, six of which are signed with her distinctive monogram ‘JL*’.
I chose this painting because my first house in the Netherlands was in Haarlem, and she is Haarlem painter.
The Problem – Various Async Tasks in SwiftUI
You have multiple asynchronous Tasks and you want when any of them return, you can cancel all others to save the data bandwidth and energy of your user’s iPhones.
We will use the task group function to solve our problem. But what are Task Groups?
A task is a specific piece of work that your software can execute asynchronously. Every piece of async/await code executes as a task. A task group essentially allows you to build a variable number of tasks and offers you more control over priority and cancellation.
There exists a hierarchy among tasks. In a task group, all tasks have the same parent task and are capable of having children. This method is known as structured concurrency since tasks and task groups are explicitly related to one another. Although you are partially responsible for the correctness, Swift can manage some behaviors like propagating cancellation for you and can catch some mistakes at compile time thanks to the explicit parent-child relationships between tasks.
Now that you know a little bit more about task groups let’s use them to have total control over multiple async operations.
This article will build this screen:
Where we will fire one async function for each service, one for Google, one for Facebook and one for a local cache and when any of them return we cancel the others this way saving data and energy for the user.
Solving multiple Async Calls Priority Problems in Swift
First, create a new SwiftUI project in your workspace.
Now let’s add some async functions to your ContentView.swift file. Two of the functions will just try to fetch google and Facebook sites, and the last one is a local cache.
You can translate this situation into your problem. For example, imagine that you have a backend call that you don’t want to wait more than 1 second to make otherwise you just pick up whatever is in the local cache to show to the user. Or you have multiple backend addresses that you are just interested in one response.
Now, inside your ContentView.swift add these three async functions:
private func fetchGoogle() async -> String { guard let _ = try? await URLSession.shared.data(from: URL(string: "https://www.google.com.br")!) else { return "Something really bad happened" } return "google" } private func fetchFacebook() async -> String { guard let _ = try? await URLSession.shared.data(from: URL(string: "https://www.facebook.com")!) else { return "Something really bad happened" } return "facebook" } private func fetchFromLocalCache() async throws -> String { try await Task.sleep( until: .now + .seconds(0.3), clock: .suspending ) return "slow cache" }
Small disclaimers: In your app, don’t put API calls in your view files. Do proper API error handling, this is the minimum code to make the point of the article which is to use a task group to solve an async problem.
The interesting thing to observe in the code above is that the fetchFromLocalCache function will run after 0.3 seconds, so if the other two functions are slower than that, we will use local cache information. This setup can also be used as a threshold for any kind of possible slow async operation that you can have in your app.
In this article, we will run all three operations concurrently and the first that returns we will add to its counter. Let’s add the function that will use the task group to sync all those three functions. Also, observe that one of the three operations is async throws which means that can throw errors.
Let’s build the function that will glue all those three async calls and have fine control over when to return or not.
Check the code below:
private func fetchTitleString() async -> String { let foo = try? await withThrowingTaskGroup(of: String.self) { group in // Mark 1 group.addTask { await fetchGoogle() } // Mark 2 group.addTask { await fetchFacebook() } // Mark 2 group.addTask { try await fetchFromLocalCache() } // Mark 2 if let result = try await group.next() { // Mark 3 group.cancelAll() // Mark 4 return result } else { return "Error" } } return foo ?? "Error" }
Here is where the magic happens. Let’s explain all the comments.
- Mark 1: Do you remember that one of the three async functions was throwing? Well, that is why we have to use the withThrowingTaskGroup instead of withTaskGroup function. This call creates a closure that the input is a group, that group you will use to control the flow of the child’s tasks. Each one of the tasks created inside the group and added to it is its child task.
- Mark 2: Here is how you add child tasks to the group. This is important so the group can propagate actions through its hierarchy. The feature of propagating actions through all its child tasks is a very important feature when you are aiming to have control over your async calls. Without it, cancellation or waiting for all to return wouldn’t be possible.
- Mark 3: Here is the place we control the child’s tasks behaviors. When the first return, we immediately cancel the others. Very cool, isn’t it?
With very little change you can wait for all tasks to be complete before you finish your function.
It is not the scope of this article but you could do something like:
var resultList = [String]() for try await fetchData in group { resultList.append(fetchData) } return resultList
Building a Testing UI
To test the fetchTitleString async function let’s build a SwiftUI view that will call the function every second, so we can check that every time we will have a different output.
struct ContentView: View { @State var googleTextCount = 0 @State var facebookTextCount = 0 @State var slowCacheHitCount = 0 let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() var body: some View { VStack { Text("Google : \(googleTextCount)") Text("Facebook : \(facebookTextCount)") Text("Slow Cache hit: \(slowCacheHitCount)") }.onReceive(timer, perform: { _ in Task { let textResponse = await getTitleString() switch textResponse { case "google": googleTextCount += 1 case "facebook": facebookTextCount += 1 case "slow cache": slowCacheHitCount += 1 default: print(textResponse) break } } }) .padding() } }
And you know you should see the results in your canvas. Yours will be different than mine because of a lot of reasons network reasons.
Check the result below:
Within 191 calls to the fetchTitleString function, 100 times Facebook was first to return, then 89 Google returned first, and finally 2 times neither Google nor Facebook returned within the threshold of 0.3 seconds, so we returned the fake local cache.
Full Code Example – SwiftUI with multiple Async Tasks
If you want to play around and study the full code, copy/paste the code below:
struct ContentView: View { @State var googleTextCount = 0 @State var facebookTextCount = 0 @State var slowCacheHitCount = 0 let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack { Text("Google : \(googleTextCount)") Text("Facebook : \(facebookTextCount)") Text("Slow Cache hit: \(slowCacheHitCount)") }.onReceive(timer, perform: { _ in Task { let textResponse = await getTitleString() switch textResponse { case "google": googleTextCount += 1 case "facebook": facebookTextCount += 1 case "slow cache": slowCacheHitCount += 1 default: print(textResponse) break } } }) .padding() } private func getTitleString() async -> String { let foo = try? await withThrowingTaskGroup(of: String.self) { group in group.addTask { await fetchGoogle() } group.addTask { await fetchFacebook() } group.addTask { try await fetchFromLocalCache() } if let result = try await group.next() { group.cancelAll() return result } else { return "Error" } } return foo ?? "Error" } private func fetchGoogle() async -> String { guard let _ = try? await URLSession.shared.data(from: URL(string: "https://www.google.com.br")!) else { return "Something really bad happened" } return "google" } private func fetchFacebook() async -> String { guard let _ = try? await URLSession.shared.data(from: URL(string: "https://www.facebook.com")!) else { return "Something really bad happened" } return "facebook" } private func fetchFromLocalCache() async throws -> String { try await Task.sleep( until: .now + .seconds(0.3), clock: .suspending ) return "slow cache" } }
And we are done!
Summary – Using Task Group to Solve Cancel other Tasks When One Return
Today we explored the amazing world of TaskGroup and Tasks cancelations within groups. It is amazing how easy is becoming to use concurrency and have a thread-safe environment to work in Swift.
Fellow Apple Lovers, that’s all. I hope you liked reading this article as much as I enjoyed writing it. If you want to support this blog you can Buy Me a Coffee or say hello on Twitter. I’m available on LinkedIn or send me an e-mail through the contact page.
You can likewise sponsor this blog so I can get my blog free of ad networks.
Thanks for the reading and… That’s all folks.
Image credit: Featured Image