How to manage unstructured tasks with Swift’s structured concurrency

Swift structured concurrency shortcomings (Part 1)

Soumya Mahunt
ITNEXT

--

Photo by Josh Redd on Unsplash

Swift Structured Concurrency is a relatively new feature introduced with Swift 5.5. It is an innovative approach to managing asynchronous tasks that prevents task leaks, ensures cooperative cancellation, and makes the control flow more reasonable in a concurrent context rather than the earlier approach of passing callbacks. While this idea is constantly evolving with new features, one feature that is sorely missing is the simpler management (i.e cancellation) of unstructured tasks.

In this post, we will explore managing unstructured tasks in Swift Structured Concurrency and handling cancellation for unstructured tasks. We will look into other languages’ approaches and how we can do something similar in Swift.

Why do we need unstructured tasks?

Swift provides ways of creating structured tasks with async let keyword and TaskGroup which simplifies error propagation, cooperative cancellation, and execution from an asynchronous context by managing the hierarchy of tasks. These approaches should be preferred to creating unstructured tasks. However, for some scenarios, where there is no clear hierarchy of tasks, it is absolutely necessary to use unstructured tasks:

  • Launching tasks from non-async code, i.e. fetching content from a server to display on UI.
  • Managing the lifetime of tasks based on some events, i.e terminating some account-specific async work when the user logs out of the account.

Unstructured tasks can be created by using Task APIs, with an option to whether created task inherits actor context or not. To manage the cancellation of such tasks, we have to store the task instance and invoke the cancel method when such a need arises. But in such cases, we also have to take an accounting not invoking cancel method for tasks that are completed successfully.

Exploring other structured concurrency runtimes

Kotlin has the concept of CoroutineScope using which newly spawned coroutines/asynchronous work can be canceled by canceling the scope. While CoroutineScope has other functionalities as well we will only consider the cancellation mechanism in this post:

.NET takes this cancellation mechanism one step further with CancellationToken and CancellationTokenSource by allowing linking external CancellationToken with CancellationTokenSource. When canceling a single source instance all the linked instances are also canceled:

Implementing unstructured tasks cancellation handling

While Swift doesn’t have any such built-in construct, we can create our own using the built-in cooperative cancellation mechanism. Using built-in TaskGroup we can register tasks to be canceled with the built-in cancellation handler:

By using this mechanism we can cancel all the tasks that aren’t completed by canceling the group. Some caveats we have to take into account:

  • TaskGroup can’t be used outside of tasks where it was created, hence we have to use AsyncStream for submitting tasks to TaskGroup. And since Task is a generic type, our AsyncStream’s element type should be an existential type to handle all variations:
  • Awaiting Task's result increases the priority of the task to the caller task’s priority. Hence we have to use the least priority for our cancellation task to avoid this side effect.

Keeping these in mind, the implementation of our CancellationSource looks something like this:

We are using AsyncStream's continuation to register tasks for cancellation and terminating the stream in the event of cancellation. Note that, tasks are canceled instantly if submitted after cancellation has already been triggered.

We can even go one step further and allow registration of CancellationSources to be canceled. We can implement Cancellable protocol for CancellationSource with the help of waiting for the cancellation task’s result:

We can apply this implementation in an UIKit controller instance. We can keep two CancellationSource one that is canceled when the instance is deallocated and another canceled when the controller goes out of the user’s view. By keeping two cancellation sources, we can reason about whether an asynchronous work should be performed as long as the instance is alive on memory i.e. updating the view based on the app’s central state, or whether asynchronous work only needs to be performed when the controller instance is on user’s view i.e. fetching some data asynchronously to display user.

Further improvements

There are certain limitations to this type of cancellation handling:

  • When linking multiple CancellationSource there might be a possibility of circular linking. In such cases, the cancellation task will leak and never finish. This can be handled by only allowing linking during the initialization of a CancellationSourceand preventing registration after initialization.
  • TaskGroup stores results of submitted tasks, and since these results are not consumed by the cancellation task, long usage of CancellationSource might leak child tasks. Fortunately by using upcoming DiscardingTaskGroup such leaks could be prevented.

Conclusion

Swift Structured Concurrency is evolving with every new version of Swift with the goal of being feature complete by Swift 6. With this article, I want to start a series of posts filling the feature gaps with other concurrency runtimes during the initial adoption phases. Managing unstructured tasks is an essential aspect of Swift Structured Concurrency, and developers should follow best practices to ensure that they manage unstructured tasks effectively. Let me know in the comments what other methods you are using to manage unstructured tasks and any other feature gaps you perceive in the new concurrency features.

The final implementation along with other primitives that might ease adoption are available below.

Further reading

--

--

Writer for

Senior Software Engineer at MoEngage | System architecture enthusiast | Ex Tataneu