TaskGroup as a workflow design tool
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.
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:
When we go beyond the most basic example, however, there is a lot a task group can do:
- the tasks can return any complex type like a struct, enum, etc.
- we can add more tasks depending on previous tasks having completed or failed
- the group fails automatically if any of its tasks throws
- 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.
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:
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
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:
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:
I’ve added some async functions to my project like
askForUserPermissions() to mock the process. Additionally
APIClient.connect() returns a “connected” instance of
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:
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:
That last line packs a real punch:
first(/LoginTaskResult.client)fetches the first encountered
- it unwraps the
APIClientvalue and assigns it to
- 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:
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:
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
Once the execution gets past the
while loop I have my user data stored in
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:
And that’s a wrap! The complete login sequence is neatly modeled as a single
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
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:
Interested in discussing the new
await Swift syntax and concurrency? Hit me up on twitter at https://twitter.com/icanzilb.