Instruction

Structured Concurrency

Structured concurrency allows you to organize and manage related tasks in a systematic way. It differs from general async/await concurrency, where you might start and stop related tasks across different parts of your code base without organizing how the tasks start or run. Look at the following example to explore the differences in practice.

Async/await vs. Structured Concurrency

Imagine you have an app that needs to get directions for a user planning a trip. To do so, the app needs to:

  1. Make a network request to send the user’s location to a server and get directions.
  2. Make another request to get points of interest along the route.
  3. Make one last request to retrieve weather information about the trip.
  4. Return the information to update the app.

You could achieve this using async/await like this:

func getTrip(location: CLLocation) async -> Trip {
  let tripDirections = await getTripDirections(location)
  let pointsOfInterest = await getPointsOfInterest(location)
  let weather = await getWeather(location)
  return Trip(directions: tripDirections, 
              pointsOfInterest: pointsOfInterest, 
              weather: weather)
}

Each await call suspends getTrip, preventing later tasks from starting until the current task completes. Once completed, the next await suspends getTrip and so on until all tasks are complete. Finally, a Trip is created and returned.

While this works, it isn’t ideal. The tasks aren’t running concurrently, so the user must spend additional time waiting for each task to complete in order. This could mean they lose interest and even close your app!

In contrast, truly concurrent tasks run side-by-side, speeding up the time to perform tasks by executing them together. This is where Swift’s structured concurrency comes to the rescue, via a concept called Task Groups.

Task Groups

Task groups allow you to group related tasks together and provide a single result. The benefit of using a task group is that each task can run independently within the group without being dependent on other tasks. Their results can then be utilized once all tasks within the group are complete.

If you were to recreate getTrip to use task groups, it might look like this:

// 1
enum TripPart {
  case directions(Directions)
  case pointsOfInterest([PointOfInterest])
  case weather(Weather)
}

func getTrip(location: CLLocation) async -> Trip {

  // 2
  return await withTaskGroup(of: TripPart.self, returning: Trip.self) { taskgroup in

    // 3
    var directions: Directions?
    var pointsOfInterest: [PointOfInterest]?
    var weather: Weather?

    // 4
    taskgroup.addTask {
      await .directions(getTripDirections(location))
    }

    taskgroup.addTask {
      await .pointsOfInterest(getPointsOfInterest())
    }

    taskgroup.addTask {
      await .weather(getWeather())
    }

    // 5
    for await retrievedPart in taskgroup {
      switch retrievedPart {
      case .weather(let retrievedWeather): weather = retrievedWeather
      case .pointsOfInterest(let retrievedPointsOfInterest): pointsOfInterest = retrievedPointsOfInterest
      case .directions(let retrievedDirections): directions = retrievedDirections
      }
    }
    // 6
    return Trip(directions: directions!, pointsOfInterest: pointsOfInterest!, weather: weather!)
  }
}

Here’s a breakdown of how the code works:

  1. You create an enum called TripPart to retrieve the values from each completed task.
  2. You create a TaskGroup to run your concurrent code and await its completion. You also tell the TaskGroup that each task will return a TripPart and return an overall Trip when completed.
  3. Inside the TaskGroup closure, you create variables that will hold the values from each task. These will be used to initialize the Trip object.
  4. You call addTask on the task group. This is where you create each individual task to be completed by the group.
  5. You loop through each of the completed tasks using a for await loop. This waits for each task to complete and then updates the appropriate variable with the result.
  6. Once all the tasks are complete, you create a Trip and return it. You can safely force unwrap the values because you know the values are populated when the for await completes.

It’s important to note that you don’t have control over the order in which each task completes. Rather, the TaskGroup handles scheduling and managing them for you.

Also, notice the use of for await in the code above. This is because Task Groups conform to a protocol called AsyncSequence. AsyncSequence works similarly to other Sequences, with the exception that values in the sequence may not be available immediately. This is why the code uses the await keyword to wait until the sequence is populated with the next value to read.

Now, move on to looking at how to handle Task Groups that deal with errors.

Handling Errors in Task Groups

You may encounter cases where a Task Group can fail and needs to throw an error. In this case, you can use the withThrowingTaskGroup method to propagate any thrown errors to the caller. Update getTrip to throw an error:

// 1
func getTrip(location: CLLocation) async throws -> Trip {
  // 2
  try await withThrowingTaskGroup(of: TripPart.self, returning: Trip.self) { taskgroup in
    var directions: Directions?
    var pointsOfInterest: [PointOfInterest]?
    var weather: Weather?
    
    taskgroup.addTask {
      return await .directions(self.getTripDirections())
    }
    
    taskgroup.addTask {
      return await .pointsOfInterest(self.getTripDirections())
    }
    
    taskgroup.addTask {
      return await .weather(self.getWeather())
    }
    
    // 3
    for try await retrievedPart in taskgroup {
      switch retrievedPart {
      case .directions(let retrievedString):
        directions = retrievedString
      case .pointsOfInterest(let retrievedPointsOfInterest):
        pointsOfInterest = retrievedPointsOfInterest
      case .weather(let retrievedWeather):
        weather = retrievedWeather
      }
    }
    
    return Trip(directions: directions!, pointsOfInterest: pointsOfInterest!, weather: weather!)
  }
}

Here’s a run-through of the changes:

  1. You mark getTrip as a function that can throw an error using the throws keyword.
  2. You use the withThrowingTaskGroup method to create a TaskGroup that can throw an error. The parameters remain the same.
  3. You update the for await loop with the try keyword. This signifies that retrieving a value from taskGroup could fail and must be handled.

For more complex concurrent tasks, you could have a large amount of tasks that can fail and need to report an error. In these cases, withThrowingTaskGroup is the tool to use.

What happens if you decide to cancel the task? Take a look in the next section.

Handling TaskGroups Cancelation

Task groups work in a tree-like way. Task groups can start other tasks, and tasks can even start task groups. Consequently, this impacts how cancelation works, the process of asking your tasks to stop what they’re doing.

Let’s take the getTrip() task group as an example. If, for some reason, the task group couldn’t complete a Task, yet it was crucial for the task group to complete all of its work, you can call cancelAll() on the group to cancel the entire TaskGroup.

func getTrip(location: CLLocation) async throws -> Trip {
  
  try await withThrowingTaskGroup(of: TripPart.self, returning: Trip.self) { taskgroup in
    var directions: Directions?
    var pointsOfInterest: [PointOfInterest]?
    var weather: Weather?
    
    // Only add these tasks if the TaskGroup isn't cancelled
    taskgroup.addTaskUnlessCancelled {
      try Task.checkCancellation()
      return await .directions(self.getTripDirections())
    }
    
    taskgroup.addTaskUnlessCancelled {
      try Task.checkCancellation()
      return await .pointsOfInterest(self.getTripDirections())
    }
    
    taskgroup.addTaskUnlessCancelled {
      try Task.checkCancellation()
      return await .weather(self.getWeather())
    }
    
    for try await retrievedPart in taskgroup {
      switch retrievedPart {
      case .directions(let retrievedString):
        directions = retrievedString
      case .pointsOfInterest(let retrievedPointsOfInterest):
        pointsOfInterest = retrievedPointsOfInterest
      case .weather(let retrievedWeather):
        weather = retrievedWeather
      }
    }
    
    return Trip(directions: directions!, pointsOfInterest: pointsOfInterest!, weather: weather!)
  }
}

When cancelAll() is called, the TaskGroup and the tasks within the group are canceled. This cancelation is also propagated throughout the child tasks, so any Tasks or TaskGroups they’ve created are also canceled.

Task cancelation is cooperative, which means your Tasks are responsible for handling cancelation. Calling try Task.checkCancellation() within your tasks, means they’ll throw if the group has been canceled.

Tasks can still be added to a TaskGroup when canceled, yet they’ll be canceled almost immediately. To avoid this issue, you can create tasks using addTaskUnlessCancelled().

func getTrip(location: CLLocation) async throws -> Trip {
  
  try await withThrowingTaskGroup(of: TripPart.self, returning: Trip.self) { taskgroup in
    var directions: Directions?
    var pointsOfInterest: [PointOfInterest]?
    var weather: Weather?
    
    // Only add these tasks if the TaskGroup isn't cancelled
    taskgroup.addTaskUnlessCancelled {
      try Task.checkCancellation()
      return await .directions(self.getTripDirections())
    }
    
    taskgroup.addTaskUnlessCancelled {
      try Task.checkCancellation()
      return await .pointsOfInterest(self.getTripDirections())
    }
    
    taskgroup.addTaskUnlessCancelled {
      try Task.checkCancellation()
      return await .weather(self.getWeather())
    }
    
    for try await retrievedPart in taskgroup {
      switch retrievedPart {
      case .directions(let retrievedString):
        directions = retrievedString
      case .pointsOfInterest(let retrievedPointsOfInterest):
        pointsOfInterest = retrievedPointsOfInterest
      case .weather(let retrievedWeather):
        weather = retrievedWeather
      }
    }
    
    return Trip(directions: directions!, pointsOfInterest: pointsOfInterest!, weather: weather!)
  }
}

Great job! This covers the basic theory about Task Groups. In the next section, you’ll practice using a TaskGroup yourself.

See forum comments
Download course materials from Github
Previous: Introduction Next: Demo