Concurrency with swift: Structured concurrency
This is part 2 of the article series on Concurrency with Swift. The article’s content assumes that the reader understands the Async/Await concept.
If you are unfamiliar with the Async/Await concept, please read the first part: Concurrency with Swift: Async/Await.
Before we learn Structured Concurrency, it is essential to understand Structured Programming. And, if you are here after reading the first part you are already a step closer to understanding Structured Concurrency.
We have already been doing concurrent programming for a long time. For example, if we want to implement an asynchronous task we do it by implementing a function with the closure parameters. When this async function is called the flow will continue after the function call. Once the async task is done, it will call closure in the form of a completion block. So in this case the program flow and closure completion flow was called at different times which is missing the sequential structured flow(Programming) and hence the Structured Concurrency.
But if we choose Async/Await to implement an asynchronous function we follow the program from top to bottom attaining Structured Programming and thus opening doors to implement multiple tasks in parallel thereby following structural control flow and implementing Structured Concurrency together.
So, Structured concurrency provides a paradigm for spawning concurrent child tasks in a well-defined hierarchy of tasks that allows for cancellation, error propagation, priority management, and other tricky details of concurrency management to be handled transparently.
In the first part, I stated that Structured Concurrency has three major features: Unstructured Tasks, async-let, and Task Group. There we discussed Tasks very briefly but don’t worry we’ll talk about Unstructured Tasks in much more detail later.
For now, just remember
- Tasks provide a fresh context to run asynchronous code.
- Tasks are concurrent with respect to other execution contexts.
- Tasks are deeply integrated and start immediately.
Let us discuss the simplest of these –
async-let binding is also known as concurrent binding which is generally used in a situation where the result of a task is not required until later in the program. This creates work that can be carried out in parallel.
Let us understand with an example:
For convenience, we are taking an example of a Dice game with all hard code values and rules to quickly demonstrate the concepts.
A major assumption is that getting a dice result is a time-consuming operation.
Rule of the game (business requirement): The player opted to use two dice but with a catch that there is a max value for the sum of the dice number. If the sum of two dice is greater than the max it will throw the error.
Check this function using async/await for getting the dice throw.
This is a better way than using GCD/Operations as we get the support of inbuilt error propagation, no more missing completion, and a top-to-the-bottom structured flow of the program.
This approach has an important drawback: Although the Int.random is asynchronous and lets other work happen while it progresses, only one call to Int.random runs at a time. Each dice value is realised completely before the next one starts the task. However, there’s no need for these operations to wait—each dice can get the value independently, or even at the same time. So here we have missed the concurrent factor big time.
So, how to achieve concurrency?
In Swift, we have two ways to work with structured concurrency:
- Task groups
The simplest solution is async-let.
The following code demonstrates how to apply async-let binding to our previous example:
async-let is the best fit required to refactor our func sequentialDiceValues. Here we annotated the firstDice and secondDice with the async-let keyword, the program flow will not block and continue to call Int.random async properties in parallel. At the end where we are waiting for the result from the async task, we will get blocked until results from firstDice and secondDice Int.random async calls are received or any of them raises an exception as indicated by the try keyword.
That was all it took us to write a Structured Control-Flow Concurrent function but have we achieved Structured Concurrency?
Yes! We hit that.
With async-let, we ran two tasks concurrently until results are awaited and these async-let child tasks are structured too.
Let’s understand how.
As mentioned earlier, async-let is also called Concurrent binding. It is similar to let constant sequential binding where the expression on the right side of = is evaluated and the let constant is initialised. However, the difference is that the initialiser expression is evaluated in a separate concurrently-executing child task.
As soon as swift sees an async-let, a new Swift Concurrency task is spawned behind the scene. This task will run as a child of the task that’s currently running. And will start running immediately. On normal completion, the child task will initialise the variables in the async-let and we must use await whenever we want to use the async-let task’s value.
Though it is completely legal to not await the child task if we don’t care about the task result. But we should not be practicing this as child tasks are scoped to the context they are defined. As soon as the scope ends these child tasks will be implicitly marked cancelled which means that we should stop performing any work on the child task to respect the Swift Concurrency’s cooperative cancellation.
“Cooperative Cancellation”, is one aspect of Structured Concurrency which makes use of a concept called The Task Tree.
The Task Tree:
- The task tree is a hierarchy that our structured concurrency code runs on. It influences the attribute of tasks like cancellation, priority, and task local variables.
- A child task will always inherit the same priority and will run on the same actor the parent task is running on.
- Tasks are not the child of a specific function, but their life may be scoped to it (we saw the example earlier)
- A task parent-child link enforces a rule that says a parent task can only finish its work if all of its child tasks have finished.
- We can see this happening in the func parallerDiceValues where await is the point of suspension blocking the flow until both concurrent tasks return either successfully or with error.
- When both child tasks finish successfully, parallerDiceValues return with value after the await with max time to that of one of the child tasks with higher wait time.
- But if One of them errors out causing the parent, which was try awaiting on it, to throw an error: the tree is responsible to cancel other child tasks and then await for them to finish before the parent task function can exit/throw.
- Marking a task cancelled will not stop the task. It simply informs the task that its results are no longer needed because Cancellation is cooperative
- All the child tasks and their descendants will be cancelled when their parent is cancelled because the tree is formed by Structured Tasks.
Cancellation is Cooperative:
We talked about how cancellation is cooperative in the swift task tree but still didn’t see how to stop the task marked cancelled.
- Check the task cancellation explicitly using try Task.checkCancellation() or Task.isCancelled to throw a cancellation error or partial return in the appropriate context.
- We should design our task with cancellation in mind aiming to stop the task as soon as possible.
With This in place, our call to func parallerDiceValues will stop the child tasks if any task errors and respectively the parent task.
These logs clearly show that one child task failed and it throws an error while another child task is waiting at the suspension point and is in flight. But after the suspension point, we again check for the task cancellation. This makes the task finish the task with a cancellation error (It can be very handy in many cases).
To Summarize the async-let:
- It is a structured task.
- async-let should be always awaited for results later in the program flow and should not use as a means to run code asynchronously after the function is exited.
- Use it for concurrent async work where results can be deferred to a later time.
- Can be used for scenarios where a finite length of concurrency is required.
- We can not use async-var because a variable can change but async-let is initialised by concurrent binding.
Another feature of structured concurrency is TaskGroup and we’ll talk about TaskGroup in the next article on Structured Concurrency.
But before we wrap up let’s understand Unstructured Concurrency.
A Task with no parent is an unstructured task that is required to bridge between the synchronous and asynchronous context.
There are some situations in which you will feel the need to use unstructured concurrency:
- Launch task from the asynchronous context.
- Start a detached task to avoid inheriting the parent task traits.
How to launch a Task?
We have done this before when we call any asynchronous function from a synchronous context. Check out the async-await article for more information about bridging between the contexts.
In the above example, we saw how to create the task, It is to mind that as soon a task is encountered it starts immediately.
Tasks are independent and can run in parallel:
We can run multiple tasks in parallel, consider the following example here we are running two unrelated tasks in parallel.
These tasks run in parallel and are concurrent to each other as well as to other synchronous functions but are unstructured compared to async-let tasks. As in the above example the first task is set to throw an error (custom reserved number error) but the other tasks keep running to the end as in result. Whereas in structured concurrency a task tree is formed which dictates the structured behaviour.
Cancel a Task:
Cancellation is cooperative in structured concurrency whereas cancellation is manual for an unstructured task.
It will mark the task cancelled but will not stop the task. We will have to check the task cancellation using try Task.checkCancellation() or Task.isCancelled bool check followed by throwing an error.
Other important characteristics of an unstructured task:
- Inherit actor isolation and priority of the origin context.
- Lifetime is not confined to any scope (can be stored for future reference).
- Can be launched anywhere, even non-async functions.
- Must be manually cancelled or awaited (No tree).
We saw all these characteristics in the above example. But interestingly if anyone has noticed that at the call site of runTwoParallelTasks after the await the print statement is executed on the main thread.
It is because as stated Tasks inherit the traits from the originating context. So the task which calls the runTwoParallelTasks inherits the @MainActor trait.
To avoid this behavior we may make use of DetachedTask to call the runTwoParallelTasks function.
For more information about managing detached tasks, see Task.
In the final word, it is suggested to stick around with Structured concurrency but if required switch to or mix Unstructured Tasks (Normal & Detached) to attain flexibility.
We’ll talk about the Tasks Group in the next part!!