Swift New Concurrency Framework (Part 4) Task
It is recommend to see part3 before continue this
Task
A task provides a fresh execution context to run asynchronous code. Each task runs concurrently with respect to other execution contexts. They will be automatically scheduled to run in parallel when it is safe and efficient to do so. Because tasks are deeply integrated into Swift, the compiler can help prevent some concurrency bugs. Also, keep in mind that calling an async function does not create a new task for the call. You create tasks explicitly. There are a few different flavors of tasks in Swift.\
As illustrated in Figure 1, there are a few key points to consider:
- Tasks can run in parallel or concurrently.
- A task inherits the context of its parent thread. For example, if the parent thread is the main thread, the task will also run on the main thread.
As shown in Figure 2, I replaced Task
with Task.detached
, which resulted in an error.
When using Task.detached
, it does not inherit the actor context (e.g., self
in a class). Instead, it runs in a completely independent execution context. This means you must explicitly capture self
when accessing instance properties or methods.
As shown in Figure 3:
✅ Task.detached
creates a completely independent task.
❌ It does not inherit the main thread context.
someTask
run on the main thread. In Swift, methods inside UIViewController
(or any UIKit class) implicitly inherit @MainActor
. Even though Task.detached
starts on a background thread, calling await self?.someTask()
moves execution back to the main thread because someTask()
belongs to UIViewController
.
“As shown in Figure 4, I have now moved someTask
to a new class, which is the ViewModel. Since someTask
is asynchronous and no specific thread is specified, it will automatically run on a background thread."
As shown in Figure 5, we have now removed Task.detached
, and viewDidLoad()
runs on the main thread. Since viewDidLoad()
belongs to UIViewController
, it always executes on the main thread.
Task {}
inherits the caller's thread, which in this case is the main thread. Because Task {}
is created inside viewDidLoad()
, it starts execution on the main thread.
Does someTask
run on a background thread? This can happen because Swift's concurrency model may offload work to a background thread when entering an `async` function, even if the Task
was initially created on the main thread. Swift's concurrency model is designed to optimize performance by utilizing multiple threads.
The second print statement inside the someTask
method, after await Task.sleep
, also prints false
, indicating that the code is still not running on the main thread. After await
, the continuation of the Task
may resume on a different thread (not necessarily the main thread). This is expected behavior in Swift concurrency, as the runtime determines the most efficient thread for resuming the task.
The key takeaway is that Swift concurrency does not guarantee that an async function will run on the same thread where it was called. Even if the Task
is created on the main thread, the async function (someTask
) may execute on a background thread. The await
keyword suspends the task, allowing the runtime to decide which thread to use for resuming execution. This explains why both print statements inside someTask
—before and after await
—output false
."
“Explicitly using @MainActor
ensures that this method runs on the main thread. When working with Task
, always check which thread the code is executing on. If no specific thread is specified, execution may be unpredictable. If you need a method to always run on the main thread, make sure to annotate it with @MainActor
."
As shown in Figure 7, now we create another Task inside someTask which uses Parent thread which is backgorund so Task 2 noe using background thread,
As shown in Figure 8, what I want is to run Task 2 execution context on Main thread , I can use that logic now to tell explicitly run on Main thread
“As shown in Figure 9, here’s a recap and summary:
Task
inherits the parent thread if no specific execution context is specified.- An
async
method may run on a background thread. - If you use
Task.detached
, it will not inherit the parent thread and will always run independently. - Using
Task { @MainActor ... }
ensures that the task runs on the main thread. - If an
async
function is not explicitly assigned an execution context, its execution thread can be unpredictable.
Many people assume that the caller determines where an async function runs, which is understandable because traditionally, most code has worked this way. However, invoking Task
from the main thread does not guarantee that it will continue execution on the main thread.
If you need execution to remain on the main thread, there are several options, as discussed.”
Types of Task
1- async-let
Let’s start with the simplest of these tasks, which is created with a new syntactic form called an async-let binding. To help you understand this new syntactic form,
Imagine you are building a weather app that needs to fetch two pieces of data concurrently:
- The current temperature for a city.
- The current humidity for the same city.
You want to fetch both pieces of data simultaneously and then combine them to display the weather information.
As you can see in Figure 11 this code currently fetchHumidity and fetchTemperature currently running sequentially , in background thread, but we want to fetch concurrently using async await
As shown in Figure 13, async let
allows you to start multiple asynchronous tasks concurrently. In this example, fetchTemperature
and fetchHumidity
run at the same time, reducing the total time required to fetch both pieces of data. The fetchTemperature
and fetchHumidity
functions run on background threads because Swift concurrency automatically manages threads for asynchronous tasks.
As shown in Figure 14 and Figure 15, we cancelled the task of , it is possible to cancel the task , but Temperature task is running.. called 21K times, Yes, the Output Suggests Task.sleep(nanoseconds:)
Is Resuming Too Quickly After CancellationYour observations are on point. The cancellation of temperatureTask
seems to cause Task.sleep(nanoseconds:)
to wake up almost instantly instead of waiting for 200ms.since we are using try? This explains why "Temperature task is running..."
is printing 15,000+ times after cancellation.Normally, Task.sleep(nanoseconds:)
suspends execution for the given duration. However, if the Task
gets canceled, Swift may wake it up immediately instead of waiting for the full sleep time. This is out of scope let’s see the best approach when you know Task can cancel,
Now we are properly catching an error instead of using `try?`
As shown in Figure 17, we added a check if Task is cancelled or not , it is best practice You can check the cancellation status of the current task from any function, whether it is async or not. This means that you should implement your APIs with cancellation in mind, especially if they involve long-running computations. Your users may call into your code from a task that can be canceled, and they will expect the computation to stop as soon as possible.
Now after you added check `try?` also work without printing 21k times,
Summary from WWDC
Lets first break down the evaluation of an ordinary let binding. There are two parts: the initializer expression on the right side of the equals and the variable’s name on the left. There may be other statements before or after the let, so I’ll include those here too. Once Swift reaches a let binding, its initializer will be evaluated to produce a value. In this example, that means downloading data from a URL, which could take a while. After the data has been downloaded, Swift will bind that value to the variable name before proceeding to the statements that follow. Notice that there is only one flow of execution here, as traced by the arrows through each step. Since the download could take a while,
Sinnce the download could take a while, you want the program to start downloading the data and keep doing other work until the data is actually needed. To achieve this, you can just add the word async in front of an existing let binding.This turns it into a concurrent binding called an async-let. The evaluation of a concurrent binding is quite different from a sequential one,
To evaluate a concurrent binding, Swift will first create a new child task, as shown in Figure 21, which is a subtask of the one that created it. Because every task represents an execution context for your program, two arrows will simultaneously come out of this step. This first arrow is for the child task, which will immediately begin downloading the data. The second arrow is for the parent task, which will immediately bind the variable result to a placeholder value.
This parent task is the same one that was executing the preceding statements. While the data is being downloaded concurrently by the child, the parent task continues to execute the statements that follow the concurrent binding. But upon reaching an expression that needs the actual value of the result, the parent will await the completion of the child task, which will fulfill the placeholder for result. In this example, our call to URLSession could also throw an error. This means that awaiting the result might give us an error.
Whenever you make a call from one async function to another, the same task is used to execute the call. So, the function fetchOneThumbnail inherits all attributes of that task.
When creating a new structured task like with async-let, it becomes the child of the task that the current function is running on. Tasks are not the child of a specific function, but their lifetime may be scoped to it. The tree is made up of links between each parent and its child tasks. A link enforces a rule that says a parent task can only finish its work if all of its child tasks have finished. This rule holds even in the face of abnormal control-flow which would prevent a child task from being awaited.
For example, in this code, I first await the metadata task before the image data task. If the first awaited task finishes by throwing an error, the fetchOneThumbnail function must immediately exit by throwing that error. But what will happen to the task performing the second download? During the abnormal exit, Swift will automatically mark the unawaited task as canceled and then await for it to finish before exiting the function. Marking a task as canceled does not stop the task. It simply informs the task that its results are no longer needed. In fact, when a task is canceled, all subtasks that are decedents of that task will be automatically canceled too. So if the implementation of URLSession created its own structured tasks to download the image, those tasks will be marked for cancellation.
The function fetchOneThumbnail finally exits by throwing the error once all of the structured tasks it created directly or indirectly have finished. This guarantee is fundamental to structured concurrency. It prevents you from accidentally leaking tasks by helping you manage their lifetimes, much like how ARC automatically manages the lifetime of memory.
2- task group
.The next kind of task I want to tell you about is called a group task. They offer more flexibility than async-let without giving up all of the nice properties of structured concurrency. As we saw earlier, async-let works well when there’s a fixed amount of concurrency available.Let’s create an example using async let
and task groups (withTaskGroup
) to demonstrate how you can perform multiple asynchronous tasks concurrently and collect their results. Task groups are particularly useful when you have a dynamic number of tasks or need to manage tasks in a structured way.
Imagine you are building a weather app that needs to fetch weather data for multiple cities concurrently. You want to use a task group to fetch the temperature for each city and then combine the results.
As shown in Figure 22 `withTaskGroup` allows you to run multiple tasks concurrently and collect their results in a structured way. Tasks are added to the group using group.addTask
. ask groups are ideal when the number of tasks is dynamic (e.g., based on the number of cities). The for await
loop collects results from the group as they become available.Results are returned in the order they complete, not necessarily the order they were added.
Also we avoid data races in here , we will see later how did we avoid data races
If any task throws an error, the entire group will throw an error. You can handle errors using do-catch
if needed. as shown in Figure 24
As shown in Figure 25 the app crash due to a data race issue. The problem is that we’re trying to insert a temperature into a single dictionary from each child task. This is a common mistake when increasing the amount of concurrency in your program.
Data races are accidentally created. This dictionary cannot handle more than one access at a time, and if two child tasks tried to insert temperature simultaneously, that could cause a crash or data corruption.
Make sure you have this build setting
In the past, you had to investigate those bugs yourself, but Swift provides static checking to prevent those bugs from happening in the first place. Whenever you create a new task, the work that the task performs is within a new closure type called a `@Sendable` closure. The body of a @Sendable closure is restricted from capturing mutable variables in its lexical context, because those variables could be modified after the task is launched. This means that the values you capture in a task must be safe to share. For example, because they are value types, like Int and String, or because they are objects designed to be accessed from multiple threads, like actors, and classes that implement their own synchronization
To avoid the data race in our example, you can have each child task return a value. This design gives the parent task the sole responsibility of processing the results. In this case, I specified that each child task must return a tuple containing the String ID and UIImage for the thumbnail. Then, inside each child task, instead of writing to the dictionary directly, I have them return the key value tuple for the parent to process. The parent task can iterate through the results from each child task using the new for-await loop. The for-await loop obtains the results from the child tasks in order of completion. Because this loop runs sequentially, the parent task can safely add each key value pair to the dictionary. This is just one example of using the for-await loop to access an asynchronous sequence of values. If your own type conforms to the AsyncSequence protocol, then you can use for-await to iterate through them too.
As shown in Figure 28, I fixed the old warning but new warning came
Whenever you create a new task, the work that the task performs is within a new closure type called a `@Sendable` closure. Ask question is self is Sendable in our case its a ViewModel with class, if we convert it into struct this warning will remove
As shown in Figure 29, no warning at all with no data races
3- Unstructured Task
But we know that you don’t always have a hierarchy when you’re adding tasks to your program. Swift also provides unstructured task APIs, which give you a lot more flexibility at the expense of needing a lot more manual management. There are a lot of situations where a task might not fall into a clear hierarchy. Most obviously, you might not have a parent task at all if you’re trying to launch a task to do async computation from non-async code. as shown in Figure 31, Alternatively, the lifetime you want for a task might not fit the confines of a single scope or even a single function. You may, for instance, want to start a task in response to a method call that puts an object into an active state and then cancel its execution in response to a different method call that deactivates the object.
Let’s say we have a collection view, and we can’t yet use the collection view data source APIs. Instead, we want to use our fetchThumbnails function we just wrote to grab thumbnails from the network as the items in the collection view are displayed. However, the delegate method is not async, so we can’t just await a call to an async function. We need to start a task for that, but that task is really an extension of the work we started in response to the delegate action. We want this new task to still run on the main actor with UI priority. We just don’t want to bound the lifetime of the task to the scope of this single delegate method. For situations like this, Swift allows us to construct an unstructured task. Let’s move that asynchronous part of the code into a closure and pass that closure to construct an async task. Now here’s what happens at runtime. When we reach the point of creating the task, Swift will schedule it to run on the same actor as the originating scope, which is the main actor in this case. Meanwhile, control returns immediately to the caller. The thumbnail task will run on the main thread when there’s an opening to do so without immediately blocking the main thread on the delegate method. Constructing tasks this way gives us a halfway point between structured and unstructured code. A directly constructed task still inherits the actor, if any, of its launched context, and it also inherits the priority and other traits of the origin task, just like a group task or an async-let would. However, the new task is unscoped. Its lifetime is not bound by the scope of where it was launched. The origin doesn’t even need to be async. We can create an unscoped task anywhere. In trade for all of this flexibility, we must also manually manage the things that structured concurrency would have handled automatically. Cancellation and errors won’t automatically propagate, and the task’s result will not be implicitly awaited unless we take explicit action to do so.
So we kicked off a task to fetch thumbnails when a collection view item is displayed, and we should also cancel that task if the item is scrolled out of view before the thumbnails are ready. Since we’re working with an unscoped task, that cancellation isn’t automatic. Let’s implement it now. After we construct the task, let’s save the value we get. We can put this value into a dictionary keyed by the row index when we create the task so that we can use it later to cancel that task. We should also remove it from the dictionary once the task finishes so we don’t try to cancel a task if it’s already finished. Note here that we can access the same dictionary inside and outside of that async task without getting a data race flagged by the compiler. Our delegate class is bound to the main actor, and the new task inherits that, so they’ll never run together in parallel. We can safely access the stored properties of main actor-bound classes inside this task without worrying about data races. Meanwhile, if our delegate is later told that the same table row has been removed from the display, then we can call the cancel method on the value to cancel the task.
4- Detached Task
So now we’ve seen how we can create unstructured tasks that run independent of a scope while still inheriting traits from that task’s originating context. But sometimes you don’t want to inherit anything from your originating context. For maximum flexibility, Swift provides detached tasks. Like the name suggests, detached tasks are independent from their context. They’re still unstructured tasks. Their lifetimes are not bound to their originating scope. But detached tasks don’t pick anything else up from their originating scope either. By default, they aren’t constrained to the same actor and don’t have to run at the same priority as where they were launched. Detached tasks run independently with generic defaults for things like priority, but they can also be launched with optional parameters to control how and where the new task gets executed.
Let’s say that after we fetch thumbnails from the server, we want to write them to a local disk cache so we don’t hit the network again if we try to fetch them later. The caching doesn’t need to happen on the main actor, and even if we cancel fetching all of the thumbnails, it’s still helpful to cache any thumbnails we did fetch. So let’s kick off caching by using a detached task. When we detach a task, we also get a lot more flexibility in setting up how that new task executes. Caching should happen at a lower priority that doesn’t interfere with the main UI, and we can specify background priority when we detach this new task.
Let’s plan ahead for a moment now. What should we do in the future if we have multiple background tasks we want to perform on our thumbnails? We could detach more background tasks, but we could also utilize structured concurrency inside of our detached task. We can combine all of the different kinds of tasks together to exploit each of their strengths. Instead of detaching an independent task for every background job, we can set up a task group and spawn each background job as a child task into that group. There are a number of benefits of doing so. If we do need to cancel the background task in the future, using a task group means we can cancel all of the child tasks just by canceling that top level detached task. That cancellation will then propagate to the child tasks automatically, and we don’t need to keep track of an array of handles. Furthermore, child tasks automatically inherit the priority of their parent. To keep all of this work in the background, we only need to background the detached task, and that will automatically propagate to all of its child tasks, so we don’t need to worry about forgetting to transitively set background priority and accidentally starving UI work.
Async-let allows for a fixed number of child tasks to be spawned as variable bindings, with automatic management of cancellation and error propagation if the binding goes out of scope. When we need a dynamic number of child tasks that are still bounded to a scope, we can move up to task groups. If we need to break off some work that isn’t well scoped but which is still related to its originating task, we can construct unstructured tasks, but we need to manually manage those. And for maximum flexibility, we also have detached tasks, which are manually managed tasks that don’t inherit anything from their origin