In the last chapter, we talked extensively about structured concurrency. We learned that structured concurrency follows the same ideas of structured programming. A natural counterpart for structured concurrency is unstructured concurrency.
Unstructured concurrency offers more flexibility in exchange for slightly less usability. When working with unstructured concurrency, your code will not necessarily follow the top-to-bottom structure we have come to love. Luckily, working with unstructured concurrency is still very easy, and the flexibility won’t increase the complexity of your program considerably.
To work with unstructured concurrency , we use an object we already know: Task.
Tasks in Depth
- 1.
The moment a task is created with its initializer, it launches the code within the closure asynchronously.
- 2.
It creates a concurrent context for us to be able to use concurrency in the way of calling async methods .
Creating Tasks
Using Task to call async code
In Listing 5-1, the fetchUserInfo() info method is called the moment authenticateAndFetchProfile() method . You cannot decide when the code within a Task will execute. It will always be done immediately upon initialization.
Available priority options
A Task is an object, as such, you can store it in a variable. This is where part of the reason this is called “unstructured concurrency ” comes from. You can have multiple Task objects, store them in variables, or even in collections. And one thing you can do with unstructured concurrency that you can’t do with structured concurrency is cancelling tasks. Task cancellation is an important topic, and we will talk more about it when we talk about the Task Tree in this chapter. For now, keep in mind that you can cancel tasks by calling the cancel() method.
Creating a Task, and explicitly cancelling it later
We created a variable of type Task<Bool, Error>. This is because the context.evaluatePolicy(_:localizedReason:) returns a Bool and can throw an error.
Awaiting on a Task’s value
We need to do try await because our task can return a value and throw an error. Note the lack of a do-catch statement in this newly created Task. This code will implicitly create a task that is of type Task<(), Error>, because it doesn’t return anything, but it can throw an error (the error thrown by context.evaluatePolicy). Tasks do not need an explicit do-catch block because they will be captured by closure itself but add these statements if it makes sense in the context of your application.
When you create a new task with Task {}, it will inherit contextual information from the task that spawned it. The information inherited includes the actor the task is running on (if you spawn a Task in something that runs on the @MainActor, then the task will also run on the @MainActor), and priority. If your newly spawned Task is a root task – it creates an asynchronous context but it itself is not running in one – it will inherit the actor of whatever thread it is running on.
Unstructured Concurrency in Action
Now that you have a better understanding of what unstructured concurrency is, and you finally learned what the Task object is, we can start writing some sample code to better understand how it all works.
We will work and modify the project we created in the last chapter – the ImageDownloader project .
- 1.
When the app launched, it showed a ProgressView in the center until all images were done downloading. This means if we had a lot of images or they were really big, the ProgressView would be shown for a very long time. Once the images were done downloading, the view would refresh and show all the images at once.
- 2.
There was no guaranteed to the order the images would be displayed in. Images would be added to the array in a first-downloaded basis. This is not harmful buy itself, but if you had an app that displayed a chronological order of elements, it would matter.
We will modify the original project and address these two issues. If you would like to download the modifications directly, feel free to download the “Chapter 4 - ImageDownloaderUnstructured” project.
Modifying the ImageManager Class
Additional objects
Currently, ContentView reads an array of URLs from the ContentViewViewModel and displays them in the UI. We will change this, so the ImageManager is the actual data source of the images. We will also make the view read ImageDownload objects instead of URL objects. This will help us show a different UI depending on the download status of the image (the ImageDownloadStatus enum). We need to make ImageDownload conform to Identifiable so it plays nicely with SwiftUI.
Marking ImageManager as @MainActor
Marking the imageManager variable as @MainActor
Creating a published property for remote images
Because SwiftUI will use this as a data source, whenever this array changes, our UI will update.
A new version of download(serverImages:)
This is where part of the magic will happen. We will start by iterating over all the ServerImages we have downloaded. For each server image, we will create an explicit Task. We will also add this task immediately to the images array with the .downloading(_) status. The enum tracks the task itself because we can cancel it later if we want.
The task body will attempt to download the image. If the download fails, we will replace its entry in the images array with the .error(_) value. If it succeeds, we will replace it with the .downloaded(url) value. In short, the body of the task will replace its place in the array with a different enum value depending on whether the download was successful or not. And because this is a @Published property, SwiftUI can observe its changes and be notified in real time of whatever happens with this array.
Modifying the ContentViewViewModel Class
The new downloadImages() method
The view model is now a very light class, containing only this method. We could delegate everything to the ImageManager class, but I prefer to keep it as a separate logic class.
Modifying the ContentView Object
The new body property
Helper properties to create the views
This improved version of the ImageDownloader project shows placeholders for images as they are downloaded instead of showing a ProgressView while waiting for all the images until they are downloaded, and the images will always show up in the same order. Neat!
The Task Tree
The new concurrency system in Swift is driven by an underlying structure called the task tree . Like the name states, the task tree gives us a mental model of how concurrency works behind the scenes. This task tree dictates how your tasks are going to run in relation to others. It also controls how data is inherited to child tasks. Earlier, we mentioned that a task inherits the actor, priority, and task local variables from the task that launched it. This is thanks to the task tree. Tasks spawn child tasks and these child tasks inherit all these attributes from their parents.
Asynchronously fetching data from a server
This code has two async let calls that retrieve the user info and followers info, respectively. Because they are async let , the system will try to run them concurrently.
Intuitively, you may think that each async let call launches a new task implicitly, but this is not the case. Each async let call will launch a new child task. The distinction is important because Tasks can only be created explicitly.
Each child task will inherit the information we received earlier from the parent (in this case, the parent is the fetchData() function itself) – actor, priority, and local variables. When you create Task within another Task, it will also inherit the parent Task’s attributes, but unlike implicit tasks, you can tweak it a bit according to your necessities, like spawning it with a different priority. Nested tasks are not child tasks, strictly speaking.
When fetchData is executed, the same task is used to launch fetchUserInfo and fetchFollowingAndFollowers. This needs to be repeated and it’s very important you don’t forget. Tasks are always created explicitly. Unless we manually make those calls wrapped within a Task, they will be run under the same Task. One task can run multiple concurrent child tasks, with the same actor, priority, and task local variables.
Another important aspect of the task tree you need to consider is that a parent task can only finish its work when all its children have finished their work. Even if such termination is not successful (i.e., an error is thrown), it’s still a termination, and a task can be completed when such abnormal situations occur.
There are two other aspects that are also governed by the task tree . They are important enough to warrant their own sections: Error propagation and task cancellation.
Error Propagation
Let’s go back to the fetchData tree . fetchData itself can throw, and so can all the child tasks it spawns. Suppose an error occurs in fetchUserInfo. If the function throws an error, execution of the function will end at that point, throw the error up to the caller, and it will not execute anything underneath the point the error took place. So far, that’s exactly what would happen if we weren’t using the new concurrency system at all. That’s how error propagation works in Swift (and in many other programming languages, in fact).
But when we are working with the new concurrency system, things are not so straightforward. Sure, fetchUserInfo could throw an error, but fetchFollowingAndFollowers could complete normally and return an expected value.
If an error is thrown, it will be propagated up the task tree until it reaches the caller. Any other child tasks that are either awaiting or running concurrently will be marked as cancelled.
Task Cancellation
Task cancellation is very important, yet it does not work like you would expect at first glance. If you reread the last sentence in the previous paragraph, you will notice that we said that the tasks “will be marked as cancelled.”
With this new concurrency system, task cancellation is cooperative. Cancelling a task simply means that you are notifying it that you intend it to be cancelled. It is up to the task to actually find an appropriate time to cancel itself, and you need to implement this logic by yourself. The reason task cancellation is cooperative is because there are sensitive yet important tasks that may not be acceptable to forcefully stop their execution. If you are writing data to a database, or you are modifying a user file in a concurrent task, abruptly cancelling it can lead to user data corruption (and many 1-star reviews).
When a Task is cancelled , it also marks any children subtasks as cancelled, attempting to ensure no unnecessary work will be done.
To explain this concept better, I have provided a project, “Chapter 5 – Counter App” that we will work through.
The methods output the numbers to show in the UI
Launching both counter methods asynchronously
Giving the user manual control over the task
You can see that the counterTask variable acts as a parent task for countFrom1to10 and countFrom11to20. What do you think will happen when you tap the “Cancel” button? Will the number stop printing altogether and the UI display the ones it managed to print? Will it erase all the numbers? Feel free to take a minute to think about it.
But the right answer is neither of those options. The moment you tap the Cancel button, all the numbers will be displayed in the UI instantly (unlike one number at a time, like it had been doing) and the task will appear like it finished without an issue. What’s going on here?
Cancellation is cooperative. This means that the tasks will not be explicitly and automatically cancelled by anyone. Earlier, we said that tasks are marked as cancelled. Cancellation being cooperative means that tasks need to check their cancellation status and cancel execution when it is appropriate. It is not correct to cancel tasks instantly, because your code could be doing something essential that cannot be unexpectedly interrupted. So instead of having someone else cancel your code, your code needs to wait for an appropriate time to stop executing itself.
To check for cancellation, you need to place cancellation checks where it makes sense to do so. Swift provides you with the Task.checkCancellation()static method and the Task.isCancelled static property . Use the former when you are within an async context that can throw, and the latter when your context is not marked as throws.
Adding a task cancellation check to countFrom1To10()
Adding additional cancellation checks
There is no formula to identify where the cancellation checks should go, but it is important that you design your code with cancellation in mind, because you don’t want consumers of your code to have unnecessarily running operations. Many of Apple’s async methods are already accounting for cancellation. If you cancel a task that has a URLSession data task running, for example, it will react to cancellations automatically.
Before we fix countFrom11to20 , you may be wondering why exactly the numbers show up instantly when cancelling, instead of showing one number per second like it does before the “Cancel” button is tapped. The reason is that the try? await Task.sleep(nanoseconds: 1_000_000_000) call has the error silenced with try?. The code was written like this because the sleep(nanoseconds:) method actually throws an error when the Task it’s running on has been cancelled. Because the error is silenced, the task doesn’t sleep for one second, and the loop gets executed immediately. Essentially, cancellation is an error that gets thrown, and you can catch it and handle it differently than the others.
To provide an example of cooperative task cancellation , I had to silence the error and manually add the cancellation checks. In general, you can expect that any method of the new concurrency system is already checking for cancellation at some point, alongside Apple’s own async methods. You can avoid checking for cancellation in this specific method if you remove the question mark after the try.
Fixing the countFrom11To20 method
Task Cancellation and Task Groups
Downloading multiple images in a Task Group
If you have 10 images to download, and an error were to occur (or you cancel the group calling group.cancelAll()) within the withThrowingTaskGroupCall before all images are downloaded, the URLs array will contain a partial result consisting of only the images it managed to download before the unusual exit took place. Make sure you document this behavior, as callers to your code may not be aware of this and may cause their programs to have unexpected behavior.
Unstructured Concurrency with Detached Tasks
We have mentioned that the task tree ensures tasks inherit some properties from their parents such as priority, actor, and local variables. It is entirely possible to launch a task from another task that doesn’t inherit anything from its parent. These are called Detached Tasks , and they are useful when you need concurrency, but this concurrency is not strictly related. For example, downloading images from the internet could be one task. You may later want to store those images in a local cache. The process of storing the images in a local cache can be a detached task, that way if the task is cancelled but the image is downloaded, the cache-saving operation will proceed without an issue.
Creating a detached task
Syntactically, there’s not much difference when it comes to standard tasks but be aware of the task tree and inheritance properties when you work with them. Specifying the priority is optional, just like when working with normal tasks.
Summary
This chapter was packed with a discussion of unstructured concurrency and the task tree. We gave a lot of coverage to the Task object and how it behaves when it comes to creating concurrency. This type of concurrency is called unstructured concurrency , and it gives us more control, allowing us to cancel the tasks manually or passing them around as arguments.
We talked about how structured concurrency works when used alongside Tasks. We said that calling async methods within a Task will make the methods child tasks of the parent task. These child tasks inherit all the properties of the parent including priority, actor, and local variables. Launching async methods does not mean they run in a different task. Tasks are always explicitly created with the Task {} object.
We also talked about the task tree and how it governs the execution of your tasks. The task tree gives you a mental model of what kind of information is being inherited by child tasks. Also, it governs how cancellation takes place.
We learned that task cancellation is not straightforward. “Cancelling” a task simply means that will be marked as cancelled, but the tasks themselves are responsible for finding an appropriate time to stop execution, as forcefully stopping asynchronous code can have disastrous consequences for your users, such as data corruption. We also touched on how task cancellation works with task groups and how it can cause them to deliver partial results, which may be unexpected.
Finally, we talked about detached tasks , which are a form of unstructured concurrency that don’t inherit anything from a parent task. These tasks are completely independent.
Exercises
- 1.
Change it so the images are no longer downloaded with a task group. Instead, it should use unstructured concurrency with Task to download each image. Hint: You can create a separate view, called ServerImageView, with an accompanying view model to download a single image.
- 2.
Add the ability to let users cancel an independent image download task if they tap the “Cancel” button.
- 3.
SwiftUI includes a view modifier called .task, which will execute its contents when a view appears, and it will cancel the execution when the view goes out of view, essentially replacing the behavior for the onAppear and onDisappear modifiers. Use it to automatically download an image when its placeholder view (which can be a ProgressView) appears.
And naturally, if no errors occur, the images should appear like they originally did in the base exercise.
You can find my solution in the “Chapter 4 - ImageDownloaderWithTask” project.