Dive into structured tasks and task cancellation in Swift Concurrency
While Swift Concurrency provides an easy way to write asynchronous and parallel code in a structured way, there are some concepts that aren't quite clear in the document, especially regarding some implicit inheritances.
This article concludes with a discussion about those implicit inheritances when creating and interacting with a task, and also clarifies the concept of structured tasks in Swift Concurrency. Then, I will show some examples of how to cancel a Task correctly to avoid resource leaks.
Structured Tasks and Actors
A task contains a piece of code to run, which is suspend-able and can call and await another asynchronous task to finish.
Tasks are arranged in a structured way. This allows us to handle cancellation and errors in an efficient way. For example, when we cancel the root task, any child tasks are allowed to check cancellation by Task.checkCancellation
. When a child task throws an error, its parent task should catch or re-throw the error in the do-catch statement.
An actor is a way to prevent race condition, allowing only one task to access their mutable state at a time. You may treat an actor like a thread with data protection - but it’s not equivalent to the thread, the code in the actor may be accessed by different threads at different time. For example, the properties in the MainActor
can only be accessed via the main thread. Accessing or calling another actor’s properties or methods requires a concurrency environment(inside a Task) and needs to be awaited.
The image above depicts a simple relationship of structured tasks. It’s not quite hard to understand this structure. However, what makes us confused is how to map our code into this diagram.
Ways to create a task
We create a task in those explicit ways:
Task.init
Task.detached
For example:
Here we use
withUnsafeCurrentTask
to the get unsafe task instance and print its hashValue. To help you understand whether your code runs in a new child or separated task, you can try this method. It’s not recommended to use the unsafe one though.
And as we use async let
to declare a variable returned by an async method, we create a child task, which inherits actor and context from its parent task.
Consider those code:
The code above can be depicted by the image below:
When we first use
Task.init
in a synchronized method, a rootTask 0
is created and its actor isActor 0
.
If we use
Task.detached
to create a detached task, aTask 1
is created and its actor isActor 1
(the name is arbitrary and it’s not important in this case, and what you should know is it’s different from the actor 0.)
If we use
Task.init
to create a task, aTask 2
is created and its actor is stillActor 0
.
When we use async let to declare a variable returned by an async and
isolated
method, a new child taskTask 0-1
is created and its actor is stillActor 0
.
When we use async let to declare a variable returned by an async and
nonisolated
method, a new child taskTask 0-2
is created and its new actorActor 2
.
Furthermore:
The tasks associated with the same actor, can access its methods or properties in a synchronous way.
The detached tasks or methods can access other actors in a asynchronous way.
There are two terms we should concern about when we create a new task or call an async method:
Whether it inherits actor: decide how to access data inside that actor.
Whether it inherits concurrency context: to be part of the original task or a child task. It decides whether cancellation or errors can be propagated.
I have organized the form below to describe how actor and context inheritance work for different kinds of task creation and the invocation of some built-in async methods.
Note that whether code is running under the original task or a child task is not that important: just keep in mind that if code inside a task inherits concurrency context, the propagation of cancellation and errors can work as expected.
Also, the “throwing” versions of some methods work as the same as the non throwing version and I don’t list them here.
Implicit main actor
How to know which actor is a task on behalf of? Is main actor the default actor? No, if Swift concurrency can’t figure out the current actor, you can think that the task is associated with an arbitrary actor, and the code inside may run on a background thread instead of the main thread.
As creating a root task, to make sure it runs on the main thread, there are different ways.
You can mark your class as MainActor
by adding @MainActor
on the class declaration.
Or you can mark the method by adding @MainActor
.
Or you can capture the main actor in the block inside a task:
Note that when you create a task in a closure invoked by SwiftUI, or call an async method in the .task view modifier in SwiftUI, the current actor is not always be Main Actor.
Task cancellation
To cancel a task, we call the cancel
method of a task instance.
Then the task itself and all of its child tasks with the same concurrency context that supports cancellation can stop the actual work by checking the Task.isCancelled
or Task.checkCancellation()
, which will throws CancellationError
.
This way is called cooperative model.
Note that it’s a cooperative way to cancel the work and there are some pitfalls that you should be mind of, which can lead to the failure of cancelling a task:
If the current context is out of the task being cancelled,
Task.isCancelled
orTask.checkCancellation()
won’t work.
Bridging from traditional callback or delegation using
withCheckedContinuation
can’t useTask.isCancelled
orTask.checkCancellation()
to check task cancellation.
Consider this code below:
Calling Task.isCancelled
inside a block post to run on a dispatch queue will always result in false, even though the root task may run on the same thread.
To make sure the block won’t run after the task is cancelled, you can try this way:
By using withTaskCancellationHandler(operation:onCancel:)
, once the task is cancelled the onCancel
block will be invoked so you can cancel any work that doesn’t support cooperative cancellation.
Alternately, you can utilize the Task.sleep
which supports cooperative cancellation while doing asynchronous work and report progress in the callback style way. For example, when using AVAssetExportSession
to export video asynchronously, we use Task.sleep
to wait for 1 second before reporting the current status. Therefore, when the task is cancelled, we can cancel the AVAssetExportSession
in the catch right on time.
The code above:
Uses
async let
to create a child task that checks progress to run in parallel with theawait exportSession.export()
code. Because the export method doesn’t support cooperative cancellation, we need to check cancellation in period.
In
repeatReportProgress
method, we useswhile
statement to check the status of theAVAssetExportSession
between 1 second. Thewhile
code runs on a non-isolated actor, which would not be the Main actor in this case.
Afterwords
Thank you for reading until the end. Swift Concurrency is powerful and graceful enough for us to write and organize asynchronous code. By utilizing it, we can make our code more readable and efficient. I hope this article has helped you better understand Swift Concurrency if you had any confusion before.