So far, we’ve mainly been talking about structured concurrency. It’s weird to introduce you to this lesson; as you could say, we will look into a way to create unstructured concurrency.
Detached tasks allow you to create a new top-level task and disconnect from the current structured concurrency context. You could argue that using them results in unstructured concurrency since you’re disconnecting potentially relevant tasks. The cancellation inheritance that we’ve seen in action before would not work with detached tasks, for example.
While it sounds terrible to disconnect from structured concurrency, there are still examples of use cases in which you can benefit from detached tasks. However, you must know the consequences to ensure you understand what you’re doing.
What is a detached task?
A detached task runs a given operation asynchronously as part of a new top-level task.
Task.detached {
// Runs the given nonthrowing operation asynchronously as part of a new top-level task.
}
The code inside the closure will be executed asynchronously from the parent context.
The following code demonstrates this concept by using an async method to print out a value:
func detachedTasksExample() async {
await asyncPrint("Operation one")
Task.detached {
/// Runs the given nonthrowing operation asynchronously as part of a new top-level task.
await asyncPrint("Operation two")
}
await asyncPrint("Operation three")
func asyncPrint(_ string: String) async {
print(string)
}
// Potentially prints, the order is not guaranteed to be the same every time:
// Operation one
// Operation three
// Operation two
}
By using a detached task, we stepped away from structured concurrency, and the print statements are no longer running in order. This isn’t unique to detached tasks, the same would happen to other unstructured tasks like creating a new Task { ... } since the order of execution will only be guaranteed if we await the results.
What is characteristic for detached tasks is that they do not inherit the task context they’re running in. This means they won’t inherit priority and cancellation state, which is better explained in the next section.
Risks of using detached tasks
Tasks that run detached will create a new context to operate in. They won’t inherit the parent task’s priority and the task-local storage, and they won’t cancel if the parent task gets cancelled. For example, imagine having the following long running asynchronous operation:
/// Faking a long running task by using a `Task.sleep`
private func longRunningAsyncOperation() async {
do {
try await Task.sleep(for: .seconds(5))
} catch {
print("\(#function) failed with error: \(error)")
}
}
If we would run that as follows:
let outerTask = Task {
/// This one will cancel.
print("Start longRunningAsyncOperation 1")
await longRunningAsyncOperation()
/// This detached task won't cancel.
Task.detached(priority: .background) {
try Task.checkCancellation()
/// And, therefore, this task won't cancel either.
print("Start longRunningAsyncOperation 2")
await longRunningAsyncOperation()
}
}
outerTask.cancel()
We will get the following print statements:
Start longRunningAsyncOperation 1
longRunningAsyncOperation() failed with error: CancellationError()
Start longRunningAsyncOperation 2
A few things are worth pointing out:
- We create the detached task after the first long running task. Even though we cancel the task immediately, it still gets executed.
- The
sleepinside the first long running task respects the cancellation within the local task context. This is because we execute the async method from within the task that got cancelled. - While we perform a
try Task.checkCancellation()inside the detached task, it does not result in a cancellation. This truly demonstrates how detached tasks work in a new context.
To ensure the detached task cancels seamlessly, you must hold a reference and cancel it manually. Additionally, they are not automatically canceled as soon as you release your reference. You would no longer have the ability to cancel the task yourself while it continues independently.
When to use a detached task
Detached tasks should be your last resort. You can often run tasks in parallel using task groups (we’ll cover them later) or async let instead and benefit from parent-child relationships. The latter will allow you to cancel the parent task and all related child tasks automatically.
However, in some cases, you have operations that can run independently, don’t require a connection with the parent context, and are acceptable to succeed if the parent operation cancels. You don’t want to await the results or block the parent actor (more on actors later in this course) from executing other tasks. An example could be cleaning up a directory:
Task.detached(priority: .background) {
await DirectoryCleaner.cleanup()
}
In this example, we don’t reference any local references using self. The cleanup code runs independently, can continue while the parent context cancels, and executes using a background priority. We’ll go deeper into priorities later in this module.
Summary
Detached tasks should be your last resort. Most of the time, you won’t need them, and you’re able to solve the same using regular child tasks, async let, or task groups. Please remind yourself of the consequences explained in this lesson, and only use detached tasks if you’re sure you can live with the consequences.
In the next lesson, we’re going to dive into Task groups. We’ve briefly mentioned them several times over the past lessons, so it’s time to see how they work.