Running tasks in parallel with async-await in Swift

Async-await in Swift supports scheduling and running multiple tasks in parallel. One of the benefits is that we can schedule all the async operations at once without worrying about any thread explosions. Thread explosion could have happened with DispatchQueue APIs if our queue is concurrently performing, and we would add a lot of work items to it. The structured concurrency on the other hand makes sure this does not happen by only running a limit amount of tasks at the same time.

Let’s take an example where we have a list of filenames, and we would like to load images for these filenames. Loading is async and might also throw an error as well. Here is an example how to use the TaskGroup:

@MainActor final class ViewModel: ObservableObject {
let imageNames: [String]
init(imageNames: [String]) {
self.imageNames = imageNames
}
func load() {
Task {
let store = ImageStore()
let images = try await withThrowingTaskGroup(of: UIImage.self, body: { group in
imageNames.forEach { imageName in
group.addTask {
try await store.loadImage(named: imageName)
}
}
return try await group.reduce(into: [UIImage](), { $0.append($1) })
})
self.images = images
}
}
@Published var images = [UIImage]()
}
struct ImageStore {
func loadImage(named name: String) async throws -> UIImage {
return
}
}
view raw ViewModel.swift hosted with ❤ by GitHub

In our view model, we have a load function which creates a task on the main actor. On the main actor because the view model has a @MainActor annotation. The Swift runtime makes sure that all the functions and properties in the view model always run on the main thread. This also means that the line let store runs on the main thread as well because the created task belongs to the main actor. If a task belongs to an actor, it will run on the actor’s executor. Moreover, all the code except the child task’s closure containing loadImage runs on the main thread. This is because our ImageStore does not use any actors. If ImageStore had @MainActor annotation, then everything would run on the main thread and using task group would not make any sense. If we remove the @MainActor from the view model, then we can see that let store starts running on a background thread along with all the other code in the load function. That is a case of unstructured concurrency. Therefore, it is important to think about if code has tied to any actors or not. Creating a task does not mean it will run on a background thread.

But going back to the TaskGroup. Task groups are created with withThrowingTaskGroup or when dealing with non-throwing tasks then withTaskGroup function. This function creates a task group where we can add tasks which run independently. For getting results back from the group, we can use AsyncSequence protocol functions. In this simple example, we just want to collect results and return them. Async sequence has reduce function which we can use exactly for that.

To summarize what we achieved in the code snippet above. We had a list of filenames which we transformed into a list of UIImages by running the transformation concurrently using a task group. In addition, we used MainActor for making sure UI updates always happen on the main thread.

If this was helpful, please let me know on Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

1 Comment »