Home iOS & Swift Books Modern Concurrency in Swift

7
Concurrent Code With TaskGroup Written by Marin Todorov

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

You’ve made your way through a lot of new concepts so far. At this point, you’re hopefully comfortable with designing code with async/await, creating asynchronous sequences and running tasks in parallel with async let bindings.

async let bindings are a powerful mechanism to help design your asynchronous flow, especially when you have a mix of tasks where some need to run in parallel, while others depend on each other and run sequentially.

Task 1 Task 2 Task 5 execution Task 3 Task 4 async let async let

While you have some flexibility to decide how many and which tasks to run with async let, that syntax doesn’t offer truly dynamic concurrency.

Imagine that you need to run a thousand tasks in parallel. Writing async let a thousand times is out of the question! Or what if you don’t know in advance how many tasks you need to run in parallel, so you need to write code that can handle that decision at runtime?

Luckily, there’s a solution: meet TaskGroup, the modern API that allows you to create dynamic concurrency in your code. TaskGroup is an elegant API that allows you to create concurrency on the fly, reduces the possibility of data races and lets you safely process the results.

TaskGroup execution Task 5 Task 4 Task 3 Task 2 Task 1 Collect task results Clean up

Introducing TaskGroup

As in previous chapters, you’ll start by reading a short overview of the APIs you’ll try. You’ll then move on to working on a brand new, aliens-related project!

There are two API variants used to construct a task group: TaskGroup and ThrowingTaskGroup. Like other APIs you’ve covered in this book, these two variants are almost identical. The difference is that the latter allows for throwing tasks.

You don’t initialize a task group yourself — as both APIs don’t offer public initializers. Instead, you use one of the following handy generic functions, which creates a group for you and assists the compiler in properly type checking your code:

  • withTaskGroup(of:returning:body:): Creates a group with the given task return type, the given return type for the final result you’ll construct from tasks in the group, and the body closure as the code that initializes and runs the group.
  • withThrowingTaskGroup(of:returning:body:): Takes similar parameters, but each task, as well as the group as a whole, might throw an error.

An important point about these functions is that they only return once the group finishes running all of its tasks.

Here’s a short example that demonstrates how to use a task group:

//1
let images = try await withThrowingTaskGroup(
  of: Data.self
  returning: [UIImage].self
) { group in
  // 2
  for index in 0..<numberOfImages {
    let url = baseURL.appendingPathComponent("image\(index).png")
    // 3
    group.addTask {
      // 4
      return try await URLSession.shared
        .data(from: url, delegate: nil)
        .0
    }
  }
  // 5
  return try await group.reduce(into: [UIImage]()) { result, data in
    if let image = UIImage(data: data) {
      result.append(image)
    }
  }
}

Don’t be put off if the code doesn’t speak to you at first. Like most modern concurrency APIs, this example is both your first encounter with TaskGroup and almost everything you need to know about it.

Step by step, this code does the following:

  1. You set each task’s return type as Data via the of argument. The group as a whole will return [UIImage]. You could also have an explicit return type in the closure declaration and skip the returning argument.
  2. Elsewhere in your code, you’ve calculated the number of images you want to fetch, which lets you loop through them here.
  3. group is the ready-to-go ThrowingTaskGroup. Inside the for loop, you use group.addTask { ... } to add tasks to the group.
  4. You perform the actual work of the task by fetching data from an API.
  5. Task groups conform to your old friend AsyncSequence, so as each task in the group completes, you collect the results into an array of images and return it.

Long story short, the example starts a variable number of concurrent tasks, and each one downloads an image. Finally, you assign the array with all the images to images. Those few lines of code really pack quite a punch!

Data Data Data execution images withThrowingTaskGroup(...) withThrowingTaskGroup(...) [Data] Task 3 Task 2 Task 1 URLSession.data(...) URLSession.data(...) URLSession.data(...)

You manage the group’s tasks with the following APIs:

  • addTask(priority:operation:): Adds a task to the group for concurrent execution with the given (optional) priority.
  • addTaskUnlessCancelled(priority:operation:): Identical to addTask(...), except that it does nothing if the group is already canceled.
  • cancelAll(): Cancels the group. In other words, it cancels all currently running tasks, along with all tasks added in the future.
  • isCancelled: Returns true if the group is canceled.
  • isEmpty: Returns true if the group has completed all its tasks, or has no tasks to begin with.
  • waitForAll(): Waits until all tasks have completed. Use it when you need to execute some code after finishing the group’s work.

As you see, TaskGroup conforms to AsyncSequence, so you can iterate over the group asynchronously to get the task return values, just like a regular Swift Sequence.

This is quite an ingenious design because it both runs concurrent tasks and iterates over the results as a sequence — and, therefore, in a non-concurrent context. That allows you to update your mutable state safely — for example, by storing the result of each task in an array.

In the next section, you’ll try many of these great APIs in an app that searches for aliens.

Getting started with Sky

In this chapter, you’ll work on an iOS app called Sky that scans satellite imagery of the sky and analyzes it for signs of alien life.

2 9 3 9 6 2 9 4 ...

Spawning tasks in a simple loop

Open ScanModel.swift and add the following convenience method anywhere inside the ScanModel class:

func worker(number: Int) async -> String {
  await onScheduled()

  let task = ScanTask(input: number)
  let result = await task.run()

  await onTaskCompleted()
  return result
}
duus pgwouj enKyroxozif() abPdvuciwil() utFidbDuzwfilex() utGelbPiyswewev() abBdsocajag() warx qubc gufp wivs

var scans: [String] = []
for number in 0..<total {
  scans.append(await worker(number: number))
}
print(scans)

bihzaq(kabjug:) tubpud(zinget:) tebkav(sidzul:) vihhup(tagdur:) nffoul 8 ibaol ohiud ukioy tvrous 6 rjnias 5

Creating a concurrent task group

Inside runAllTasks(), delete everything except the first line that resets started. Insert this instead:

await withTaskGroup(of: String.self) { [unowned self] group in

}
for number in 0..<total {
  group.addTask {
    await self.worker(number: number)
  }
}

zuglat(pixvud:) ripcug(wenqil:) dolviy(zampet:) wavbad(wecqeb:) jugxiz(lusnal:) huxmaz(mepqiz:) shheac 4 zlqoem 6

Controlling task execution

Remember that the concurrency system breaks operations down into partials. Resuming after an await is a partial, just like anything else. After the first ScanTask.run() completes, the concurrency system now has to choose between running a different scheduled scan task or resuming any of the completed ones.

await Task {
await Task(priority: .medium) {

Getting results from a task group

An important detail to note about withTaskGroup is that it waits for all tasks to finish before returning. That means that, on the next line of code after calling this function, it’s safe to assume all the tasks have completed.

return await group
  .reduce(into: [String]()) { result, string in
    result.append(string)
  }
let scans = await withTaskGroup(
  of: String.self
) { [unowned self] group -> [String] in
print(scans)
["1", "0", "2", "3", "4", "5", "6", "7", "9", "10", "8", "11", "13", "12", "15", "14", "16", "17", "18", "19"]

Mutating shared state

A final point to make about using task groups is that it’s quite important to understand which parts of your code actually run in parallel.

PidkWweos Zuvj 8 Tilg 3 Xaws 7 Kuvk 6 Bebr 0 Omm Far

xuhmud(hojtol:) cewsah(rixmod:) fudgiq(wavxag:) miybuj(nuvdap:) rihcov(nofpiy:) zonvus(fabxor:) tncouk 2 🔥 lbxoen 7 jvHabautro nyevn uq ilvadtujcechq ip “nibh”

Processing task results in real time

Sometimes, you need to run a bunch of concurrent tasks and simply use the collected results at the end of the job, just as you implemented runAllTasks() in the previous section.

return try await group
  .reduce(into: [String]()) { result, string in
    result.append(string)
  }
await withTaskGroup(of: String.self) { [unowned self] group in
print(scans)
for await result in group {
  print("Completed: \(result)")
}
print("Done.")
...
Completed: 13
Completed: 14
Completed: 15
Completed: 17
Completed: 16
Completed: 19
Completed: 18
Done.

Controlling the group flow

As mentioned earlier in the chapter, the TaskGroup APIs are very flexible, largely thanks to their simplicity. This section will show you how to combine these simple APIs to compose a more complex behavior.

let batchSize = 4

for index in 0..<batchSize {
  group.addTask {
    await self.worker(number: index)
  }
}
// 1
var index = batchSize

// 2
for await result in group {
  print("Completed: \(result)")
  // 3
  if index < total {
    group.addTask { [index] in
      await self.worker(number: index)
    }
    index += 1
  }
}

Running code after all tasks have completed

Oftentimes you’d like to do some cleanup, update the UI or do something else after you run a group.

await MainActor.run {
  completed = 0
  countPerSecond = 0
  scheduled = 0
}

Group error handling

So far, running scans has been a walk in the park. ScanTask never fails and always completes its heavy-duty work on time.

func run() async throws -> String {
try await UnreliableAPI.shared.action(failingEvery: 10)
let result = try await task.run()
func worker(number: Int) async throws -> String {
try await self.worker(number: index)
try await withThrowingTaskGroup(of: String.self) { [unowned self] group in
for try await result in group {

Using the Result type with TaskGroup

To handle errors safely, you won’t throw an error; instead, you’ll use the Result type. If you haven’t used Result before, it’s a simple Swift enum with the following two cases:

func worker(number: Int) async -> Result<String, Error> {
  await onScheduled()

  let task = ScanTask(input: number)

  let result: Result<String, Error>
  do {
    result = try .success(await task.run())
  } catch {
    result = .failure(error)
  }

  await onTaskCompleted()
  return result
}
withThrowingTaskGroup(of: Result<String, Error>.self)
await self.worker(number: index)

Completed: success("5")
Completed: success("7")
Completed: success("6")
Completed: failure(Sky.UnreliableAPI.Error())
Completed: success("8")
Completed: success("10")
switch result {
case .success(let result):
  print("Completed: \(result)")
case .failure(let error):
  print("Failed: \(error.localizedDescription)")
}
Completed: 5
Completed: 6
Completed: 7
Failed: UnreliableAPI.action(failingEvery:) failed.
Completed: 10
Completed: 8
Completed: 12

Key points

  • To run an arbitrary number of concurrent tasks, create a task group. Do this by using the function withTaskGroup(of:returning:body:). For a throwing task group, use withThrowingTaskGroup(of:returning:body:).
  • You can add tasks to a group by calling addTask(priority:operation:) or addTaskUnlessCancelled(priority:operation:).
  • Control task execution by canceling the group via cancelAll() or waiting for all tasks to complete with waitForAll().
  • Use the group as an asynchronous sequence to iterate over each task result in real time.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

© 2022 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.