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.
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:
|
|
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.
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:
|
|
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:
|
|
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 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:
|
|
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 encounteredLoginTaskResult.client
- it unwraps the
APIClient
value and assigns it toclient
- 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 throwing
and async
:
|
|
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:
|
|
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
|
|
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.