Modern Concurrency: Beyond the Basics

Oct 20 2022 · Swift 5.5, iOS 15, Xcode 13.4

Part 2: Concurrent Code

13. Using TaskGroup

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 12. TaskGroup Next episode: 14. Actor

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Transcript: 13. Using TaskGroup

In this episode, you’ll do more with the task group you created in the previous episode. You’ll get and process task results, and control the number of tasks running in parallel.

Continue with your Sky project from the previous episode or open the starter project. If using the starter project, remember to set up Signing and Capabilities. Build and run on your device and tap Engage systems.

This first indicator — number of scheduled tasks — should be more than 1, and this alert message should display a fraction of 20 seconds.

You can see your task group is running tasks. Now, it’s time to get the result of all this work. Stop the run.

Getting results from a task group

Open ScanModel and locate runAllTasks. Task groups can return a result that conforms to AsyncSequence, so you can use the reduce method of AsyncSequence:

At the end of the TaskGroup closure, after the for-loop, return a result:

return await group
  .reduce(into: [String]()) { result, string in
    result.append(string)
  }

You use reduce to collect all the returned task values into an array of strings.

Xcode complains about the closure returning a value, so set the closure’s return type:

await withTaskGroup(of: String.self) { [unowned self] group🟩 -> [String]🟥 in 

And assign the returned value to scans.

🟩let scans = 🟥await withTaskGroup(
  of: String.self
) { [unowned self] group -> [String] in 

The task group waits for all tasks to finish before returning. If you add code below it, you can assume all the tasks have completed.

So, just to verify your result, add a print statement at the end of runAllTasks(), after the TaskGroup closure:

print(scans)

To save time, build and run on your device, then tap Engage systems:

["1", "0", "2", "3", "4", "5", "6", "7", "9", "10", "8", "11", "13", "12", "15", "14", "16", "17", "18", "19"]

The task group runs tasks in whatever order makes best use of system resources, so your output probably shows a different order.

Processing task results inside the closure

Actually, TaskGroup lets you dynamically manage the workload of the group during execution. So instead of returning the group’s result to be used outside the group, you’ll process results inside the group’s closure.

Undo your last four edits, to remove the code that returns the group result, then uses it:

let scans = await withTaskGroup(of: String.self)   // delete let scans = 
  { [unowned self] group -> [String] in  // delete -> [String]
  for number in 0..<total {
    group.addTask {
      await self.worker(number: number)
    }
  }
  return await group  // delete 3 lines
    .reduce(into: [String]()) { result, string in
      result.append(string)
    }
}
print(scans)  // delete this line

Now, add a second for loop at the bottom of the closure, after the for number loop:

for await result in group {
  print("Completed: \(result)")
}
print("Done.")
  • group conforms to AsyncSequence so you iterate over its results in a loop.
  • The loop runs as long as there are pending tasks and suspends before each iteration. It ends when the group finishes running all its tasks.

Build and run on your device. Look at the output console.

...
Completed: 13
Completed: 14
Completed: 15
Completed: 17
Completed: 16
Completed: 19
Completed: 18
Done.

The runtime executes the tasks asynchronously. As soon as each task completes, the for await loop runs one more time. Next, you’ll look into gaining even more control over the group execution by using custom iteration logic.

Controlling the group flow

Instead of letting the runtime decide how many tasks to execute and when, you’ll tell it to run at most 4 at a time. In runAllTasks(), replace all the code in the group’s closure:

let batchSize = 4
for index in 0..<batchSize {
  group.addTask {
    await self.worker(number: index)
  }
}

You set the first 4 tasks going — exactly 4. To run the rest of the tasks, you’ll add a new task whenever a running task completes.

Do this below the for loop:

var index = batchSize

You define a starting index and set it to the batch size. And add a for loop:

for await result in group {
  print("Completed: \(result)")
}

You’ll loop over any completing tasks and print its Completed message. Now, add the next task:

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

As long as the current index is less than the total number of tasks, you add one more task to the group.

You can see how flexible the task group APIs are:

  • You iterate over the results and add fresh tasks at the same time.
  • You control how many tasks can run at the same time.
  • And you don’t need to change anything outside the TaskGroup closure because these logic changes are completely transparent to the consumer.

This means you can:

  • Keep a group running indefinitely by always adding more and more tasks.

  • Retry tasks by re-adding them to the group upon failure.

  • Insert a high-priority UI task after either a set number of computational tasks finish running or you find a given result. Build and run on your device, then tap Engage systems.

  • The number of scheduled tasks is always 4 because, as soon as one completes, you schedule a new one in its place.

  • My phone used 5 threads before, but now the second indicator shows that it completes only 4 tasks per second, because that’s how many are running.

  • If your device used fewer than 4 threads before, the second indicator shows that you advance the total amount of work by only that many tasks per second. I’ll change my batch size to 7 to demonstrate this.

You see, it’s running 5 tasks per second, not 7. I’ll reset my batch size to 4.

Running code after all tasks have completed

After you run a task group, you usually want to do some cleanup, update the UI or do something else. In this project, you should reset some indicators when the scan is over, so they don’t confuse the user.

You could use TaskGroup.waitForAll() to wait for all the tasks to complete, then add the cleanup code. But the for try await loop already waits for all tasks: It only ends when the group runs out of tasks.

So, inside the task group closure, below the for await result in group loop, add this:

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

Build and run, then tap Engage systems to check this.

Now, your app is ready to re-scan. And that’s how you can use TaskGroup to dynamically create concurrency in your apps.

Check out the links below to see how the book handles TaskGroup errors with the Result type.

In this app, you didn’t have to worry about data races because the tasks are all independent. But when you introduce concurrency, you must always ensure your concurrent code doesn’t modify any shared state. And that’s what you’ll learn about in the rest of this course. Coming right up: actors!