For my talk at iOS Conf SG 2022 I wanted to showcase some Swift concurrency use-cases that don’t fall into the common scenarios. Revisiting the slides some weeks later, I think it’d be fun to cover my TaskGroup example in more detail.

In this post I’ll show how to use TaskGroup to design a complete (but fictional) user login-sequence.

What is TaskGroup good for?

Most examples floating around show using a TaskGroup to add a number of identical tasks to a group and execute them concurrently. This makes sense as one of the requirements of the group API is for all tasks to have the same return type. Here’s a group running 100 tasks concurrently each returning its index:

1
2
3
4
5
await withTaskGroup(of: Int.self) { group in
  (0..<100).forEach { num in
    group.addTask { num }
  }
}

When we go beyond the most basic example, however, there is a lot a task group can do:

  1. the tasks can return any complex type like a struct, enum, etc.
  2. we can add more tasks depending on previous tasks having completed or failed
  3. the group fails automatically if any of its tasks throws
  4. and finally, by cancelling the group you cancel all running and scheduled group tasks.

I’d say all of these features are more than enough to design a complete asynchronous sequence like a user login in an app. I’m not saying it’s the best way to do it but I think it’s very interesting that you can.

Designing an async sequence

The quick requirements for the fictional login sequence are:

  • fetch the device location
  • ask for user permissions
  • connect to the API server
  • authenticate the user with the API
  • log these steps on the server

Some of these steps can run in parallel β€” others need to happen sequentially. Additionally, if any of these steps fail the sequence should also fail.

Let’s imagine the flow as many concurrent tasks (in silver) happening in any order and some others are dependent on each other (in blue). The blue ones are checkpoints at which we can add more blue or silver tasks and model the sequence as we go:

Imagining the login sequence as a flow of concurrent tasks with checkpoints sprinkled in-between was the hard part, now let’s see about the code.

Task results

In a group all tasks need to return the same result. This is fine β€” I’ll define an enum of all the possible return values in the login group:

1
2
3
4
5
enum LoginTaskResult {
  case none
  case client(APIClient)
  case user(APIClient.User?)
}

Some tasks don’t return a value β€” those will use none. The task connecting to the server API will return the connected client APIClient and the task that ultimately authenticates the current user will return the logged user data APIClient.User.

I have the basic design of the group β€” each task will return a LoginTaskResult while the group as a whole will return an APIClient.User or throw an error.

This constitutes the basic frame of the loginAction function that logs the user in:

1
2
3
4
5
6
7
func loginAction() async throws -> APIClient.User {
  try await withThrowingTaskGroup(
    of: LoginTaskResult.self, 
    returning: APIClient.User.self) { group in

  }
}

Adding a task batch

The key in this design is to add tasks to the group in batches β€” as a start I’ll add all the tasks that do not depend on the first checkpoint. These are all the highlighted tasks below:

Or to put that in code β€” I’ll add the “first” three tasks to my group as shown below:

1
2
3
4
5
group.addTask(operation: fetchLocation)
group.addTask(operation: askForUserPermissions)
group.addTask(priority: .high) {
	return .client(try await APIClient.connect())
}

I’ve added some async functions to my project like fetchLocation() and askForUserPermissions() to mock the process. Additionally APIClient.connect() returns a “connected” instance of APIClient.

And here we have the first checkpoint β€” the next tasks in the sequence need to use an APIClient to continue with the authentication so we can only add them to the group after we get back a client.

Waiting on a checkpoint

I can’t wait on the connection task itself (as I don’t get a Task back from the APIs) but I can loop over the group until I get a connected client instance (or the group throws).

If you write this ad-hoc for every checkpoint the code gets messy pretty quickly. So I’ll first add a function to TaskGroup that will allow me to wait for a specific task result.

To do this I’m going to use pointfree’s excellent swift-case-paths package. The complete function looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
extension ThrowingTaskGroup {
  func first<Value>(
    _ path: CasePath<ChildTaskResult, Value>
  ) async throws -> Value? {
  
    for try await result in self {
      if let match = path.extract(from: result) {
        return match
      }
    }
    return nil
  }
}

Using case paths is similar to using key paths in Swift. The new ThrowingTaskGroup.first(_:) function takes a CasePath which is to say, in my case, a case in ChildTaskResult and returns the first match wrapped-value.

path.extract(from: result) is where the code “unwraps” the enum associated value in order to return it.

Let’s see that in practice β€” I’m gonna add the code to wait on the checkpoint so the whole group looks like this now:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
group.addTask(operation: fetchLocation)
group.addTask(operation: askForUserPermissions)
group.addTask(priority: .high) {
  return .client(try await APIClient.connect())
}

guard let client = 
  try await group.first(/LoginTaskResult.client) else {
  throw "Could not connect to API"
}

That last line packs a real punch:

  • first(/LoginTaskResult.client) fetches the first encountered LoginTaskResult.client
  • it unwraps the APIClient value and assigns it to client
  • if the group completes and first(_:) doesn’t find a client the code throws an error β€” thus failing the complete sequence!

The line acts as a checkpoint barrier. It’s required to get a client instance before the sequence continues!

Continue with the sequence

Once I get an APIClient I’m ready to continue with the login sequence by adding some more tasks. These are the ones that need to run strictly after the first checkpoint but before the next checkpoint. Those are highlighted below:

Let’s add an additional task to the group using the fetched client API object:

1
2
3
4
let tracer = client.traceEvent
group.addTask {
  try await tracer(.connected)
}

The API client offers a tracer object that allows you to log events. Since none of the steps depend on the successful logging, I’m adding the task to the group and forget about it. Since order is not issue here β€” this task might run concurrently with any of the previous tasks like fetching the location, etc.

Don’t worry though β€” in case logging the .connected event fails with an error that will also fail the complete group automatically.

Note: All of these requirements are kind of arbitrary just to demonstrate what’s possible. You obviously need to write the code to describe your own desired workflow.

Next, something quite interesting β€” logging in the user can fail and if so, I’d like to keep retrying until the operation succeeds. TaskGroup allows me to add an arbitrary amount of tasks so retrying shouldn’t be an issue.

I’m gonna add the following code to the group:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var user: APIClient.User!

while user == nil {
	group.addTask(operation: client.logUserIn)
	group.addTask {
		try await tracer(.login)
	}

	user = try await group.first(/LoginTaskResult.user)
}

I’m running a loop until I get a logged APIClient.User object. For each iteration I’m adding a logging task to log the attempt and also add APIClient.logUserIn to the group.

After adding the tasks for each login attempt I wait for the checkpoint result. If you’re wondering how I’m adding the client method as a group task β€” it’s just a method that’s throwing and async:

1
2
3
@Sendable func logUserIn() async throws -> LoginTaskResult {
  ...
}

Once the execution gets past the while loop I have my user data stored in user.

Note: In production you want to limit the number of retries here before throwing out of the group.

Wrapping up the sequence

To complete the sequence I’ll wait for all the scheduled tasks to complete, log a completion event, and finally return the logged in user out of the group:

1
2
3
4
5
try await group.waitForAll()

_ = try await tracer(.completedLoginSequence)

return user

And that’s a wrap! The complete login sequence is neatly modeled as a single TaskGroup.

You can see the completed function in the code repo here LoginAction.swift.

The little demo app in the repo shows the login sequence in action β€” here’s how that looks like in the Simulator with the workflow mocked to fail three times before logging in:

The completed code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
try await withThrowingTaskGroup(
  of: LoginTaskResult.self, 
  returning: APIClient.User.self) { group in

	// First batch
	group.addTask(operation: fetchLocation)
	group.addTask(operation: askForUserPermissions)
	group.addTask(priority: .high) {
		return .client(try await APIClient.connect())
	}

	// First checkpoint
	guard let client = 
		try await group.first(/LoginTaskResult.client) else {
		throw "Could not connect to API"
	}

	let tracer = client.traceEvent
	group.addTask {
		try await tracer(.connected)
	}

	var user: APIClient.User!

	// Retrying a login
	while user == nil {
		group.addTask(operation: client.logUserIn)
		group.addTask {
			try await tracer(.login)
		}

		// Login checkpoint
		user = try await group.first(/LoginTaskResult.user)
	}

	// Wrapping up
	try await group.waitForAll()
	_ = try await tracer(.completedLoginSequence)

	return user
}

What I really like about this implementation is that the code is:

  • very simple and easy to read
  • makes maximum use of the async error-handling, cancellation, and priority management
  • is self-contained in a single function call at the point-of-use

What do you think? Let me know!

The completed code and the demo app are on GitHub TaskGroupDesign.

Where to go from here?

If you’d like to support me, get my book on Swift Concurrency:

Β» swiftconcurrencybook.com Β«

Interested in discussing the new async/await Swift syntax and concurrency? Hit me up on twitter at https://twitter.com/icanzilb.