Rules of Structured Concurrency


You can find video version of this article here.
In the previous article, we covered the key aspects of Swift Concurrency to build a solid mental model of it. In this article, we will discuss structured and unstructured tasks. You’ll see that their behavior can be tricky in some scenarios, but we’ll break it down into rules to make it clear.
⚠️ : I assume you are familiar with the basic concepts of Structured Concurrency like async let and task group. If not, you can start here.
Structured and Unstructured Tasks
From previous article, we know that to switch gears from sync context to async context we need to use tasks. However, that is not the only use case for tasks. Tasks are, in fact, a much broader concept in Swift Concurrency.
💡: Tasks are units of asynchronous work you can run from your code.
Every async function is executing in a task. In other words, a task is to asynchronous functions, what a thread is to synchronous functions.
A task runs one function at a time; a single task has no concurrency.
Synchronous functions do not necessarily run as part of a task, but they can.
In terms of Structured Concurrency, there are 2 kinds of tasks:
Structured Concurrency Tasks (we will call them simply Structured Tasks)
- Represented by async let and task group
Unstructured Concurrency Tasks (we will call them simply Unstructured Tasks)
- Represented by regular unstructured task (Task { … }) and detached task (Task.detached { … })
⁉️ : What is the difference between Structured and Unstructured tasks?
Why they are actually called “structured” and “unstructured”? What does it mean? To understand the difference we need to understand the parent and child relation for tasks.
Parent and Child Tasks
Parent Task is a task that spawns one or more subtasks (child tasks). Parent task is responsible for managing its child tasks.
Child Task is a subtask created within the context of a parent task. Child tasks return their results (if any) to the parent task (if explicitly awaited).
Parent and child tasks together form a task tree structure, that simplifies control over related tasks completion, cancellation and resource management.
Root Task is an initial task in task tree (root node), which can be parent if it spawns child tasks.
Structured task can be a child or parent task (or both). But it can’t be root node in the task tree.
Unstructured task can’t be a child task. But it can be a parent task if you create new child structured tasks from them. In other words, unstructured task can only be a root node in task tree structure.
That's why they are called "structured" and "unstructured" tasks:
Structured tasks always join the current task tree structure as child tasks.
Unstructured tasks never join the current task tree. They start a new, independent task tree as a root node with no parent-child relation to the original task, with child tasks created later if needed.
⚠️ : Although we can nest one unstructured task within another, don’t be misled by that - it doesn’t create a parent-child relationship between them, regardless of whether the tasks are regular or detached. Later, we will demonstrate through examples that the behavior defined for a task tree hierarchy is fundamentally different for nested unstructured tasks.
Structured tasks
Structured tasks in Swift are represented by async let and task group.
Although it may not be obvious with async let, both of them create child tasks within the parent task where they are used. The child tasks created by async let and task group are designed to be executed concurrently.
async let
With async let
, you can start multiple tasks that run concurrently.
func fetchData() async {
async let first = fetchPart1()
async let second = fetchPart2()
async let third = fetchPart3()
let result = await (first, second, third)
print(result)
}
Although it is not explicitly stated in code, three new child tasks are created here, one for each async let.
The lifecycle of async let
is bound to the local scope where it is created, such as a function, closure, or do/catch
block.
TaskGroup
If you need to create a dynamic number of concurrent tasks, a task group is the better choice:
func fetchData(count: Int) async {
var results = [String]()
await withTaskGroup(of: String.self) { group in
for index in 0..<count {
group.addTask {
await fetchPart(index)
}
}
for await result in group {
results.append(result)
}
}
print(results)
}
Important difference with async let
is the order of awaiting results. Unlike async let
, where the order is code-driven, task group follows a "first finished → first handled" approach, this is how an AsyncSequence
works.
Lifecycle of task group is bound to the closure inside withTaskGroup
function.
💡: You can find more insights about async lets and task groups here.
Unstructured tasks
Unstructured tasks are usually used as a bridge between sync and async contexts.
If we create some structured child tasks inside of unstructured task, unstructured task will be a root task (node) in the task tree, and parent task for structured tasks.
Unstructured tasks are represented by regular (it is not a common term, we will call it regular just to give it a distinctive name) and detached tasks.
Task { // regular task
let result = await fetchData()
print(result)
}
Task.detached { // detached task
let result = await fetchData()
print(result)
}
💡: Lifecycle of unstructured tasks does not bound to the local scope or a single closure like async let or task group. When execution leaves local scope unstructured task will simply continue it’s execution.
💡: Unstructured tasks can be nested, but nested task doesn’t have parent/child relation with outer unstructured task. You can think about unstructured task like about initiating new task tree root. It could have no child tasks at all but it is independent from parent/child relation with context where it is created.
⁉️ : What is the difference between regular and detached tasks? When to use each of them?
We’ll discuss this in more detail later, but for now, we can say that a regular task inherits the execution context, while a detached task does not. It's quite hard to think of a specific scenario where a detached task would be useful, so in most cases, using a regular task is the better choice.
Rules of Structured Concurrency
The task tree structure enables cooperative operations, offering features such as priority escalation, which means when setting a higher priority on a child task, the parent task’s priority is automatically escalated.
We can consider some of those features as some kind of structured concurrency rules:
Error Propagation Rule
Group Cancellation Rule
Group Completion Rule
💡 : If you like acronyms for better memorization, you can use EGG 🥚.
We will explore how these rules work within structured concurrency tasks and compare them with the behavior of nested unstructured tasks. You will see that these rules apply to tasks that are part of a single task tree structure, but they do not work for nested unstructured tasks.
Group Completion Rule
👉 Group Completion Rule: In a parent task, you can’t forget to wait for its child tasks to complete. Parent task can’t complete until child tasks are completed.
let parentTask = Task {
async let first = fetchPart1() // returns 1
async let second = fetchPart2() // returns 2
async let third = fetchPart3() // returns 3
let result = await (first, second, third)
print(result)
}
let result = await parentTask.value
print("parentTask completes")
// Prints:
// (1, 2, 3)
// parentTask completes
Awaiting parentTask
’s value means waiting until it completes. "parentTask completes"
will not be printed until all child tasks created by async let
s are completed first. For task group inside of unstructured parent task, behavior will be the same.
But it doesn’t work the same way for nested unstructured tasks once there are no parent/child relation there:
let rootTask = Task {
let nestedTask = Task {
print("nestedTask started")
try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 secs
print("nestedTask ended")
}
}
let result = await rootTask.value
print("rootTask completes")
// Prints:
// rootTask completes
// nestedTask started
// ...after 10 seconds
// nestedTask ended
As you can see in this example, rootTask
completes even before nestedTask
is started.
But we can achieve the desired behavior if we explicitly await nestedTask
inside root task:
let rootTask = Task {
let nestedTask = Task {
print("nestedTask started")
try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 secs
print("nestedTask ended")
}
let result = await nestedTask.value
print("nestedTask completes")
}
let result = await rootTask.value
print("rootTask completes")
// Prints:
// nestedTask started
// ...after 10 seconds
// nestedTask ended
// nestedTask completes
// rootTask completes
We just need to await both nested and root unstructured tasks.
To sum up:
async lets will be implicitly awaited when execution leaves the local scope, which allows parent task to complete after that as well, so group completion works as expected. ✅
Task group will implicitly await the completion of its child tasks when execution exits the task group closure, so parent task could complete after that as well, ensuring that group completion rule works as expected. ✅
Nested unstructured task, if not awaited, will not cause the external task (
rootTask
) to wait for its completion. It behaves like a "spawn and forget" mechanism, which means it doesn’t follow the group completion rule. ❌ (which is expected, because it is unstructured and has no parent).
Group Cancellation Rule
👉 Group Cancellation Rule: If parent task is canceled, each of its child tasks is also automatically canceled.
Structured Concurrency promotes us to think about operation cancellation. But we need to understand precisely how it works.
⚠️ : Keep in mind that cancelling the task does not stop the task, it only marks that results are not gonna be needed. Swift concurrency uses a cooperative cancellation model. Each task checks (with Task.isCancelled or Task.checkCancellation()) whether it has been canceled at the appropriate points in its execution, and responds to cancellation appropriately. Depending on what work the task is doing, responding to cancellation usually means one of the following:
Throwing an error like
CancellationError
Returning
nil
or an empty collectionReturning the partially completed work
Let’s consider an example where we have child structured task and parent unstructured task:
func asyncWork() async {
do {
/// If current task is canceled before the time ends,
/// sleep function throws `CancellationError`.
try await Task.sleep(nanoseconds: 10_000_000_000) // 10 secs
} catch {
print("Cancelled as a child task with \\(error)")
return
}
print("Not cancelled -> not child task!")
}
let parentTask = Task {
async let result = asyncWork()
await result
}
parentTask.cancel()
// Prints:
// "Cancelled as a child task with CancellationError()"
// right after parentTask cancellation
Once unstructured task can only be a root task in a task tree, it can’t be cancelled automatically (has no parent). It can be cancelled only explicitly with cancel()
method. Doing that will cancel all child structured tasks in the task tree. That means 👉 Group Cancellation Rule works as expected ✅ for structured tasks.
⁉️ : Can we cancel single structured child task without cancelling it’s parent task? To cancel single async lets child task, we have to cancel parent task, which will cancel all child tasks together. For task group we also have an option without parent task cancellation - call group’s
cancelAll()
method.
But 👉 Group Cancellation Rule doesn’t work ❌ for nested unstructured tasks.
let rootTask = Task {
Task {
await asyncWork()
// prints "Not cancelled -> not child task!" after 10 seconds
}
Task.detached {
await asyncWork()
// prints "Not cancelled -> not child task!" after 10 seconds
}
}
rootTask.cancel()
Nested unstructured tasks aren’t cancelled when rootTask is cancelled.
We have only explicit cancellation option with cancel
method called when rootTask is cancelled:
let rootTask = Task {
let nestedTask = Task {
await asyncWork()
// prints "Cancelled as a child task with CancellationError()"
// right after rootTask cancellation
}
if Task.isCancelled {
nestedTask.cancel()
}
}
rootTask.cancel()
Error propagation Rule
👉 Error Propagation Rule: If error is propagated outside of local scope, all child tasks are implicitly cancelled and implicitly awaited.
An async
function can throw errors by being marked with both the async
and throws
. Error propagation in Swift refers to the process of passing errors up the call stack, allowing higher-level code to handle or respond to issues that occur during execution.
Let’ check this rule with examples.
When we leave the local scope due to error, async lets and task group child tasks will be implicitly cancelled and implicitly awaited ✅ :
func fast() async throws {
print("fast started")
do {
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
print("fast cancelled", error)
}
print("fast ended")
throw TestError1() // <- ERROR IS THROWN HERE
}
func slow() async throws {
print("slow started")
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
print("slow cancelled", error)
}
print("slow ended")
throw TestError2() // <- ERROR IS THROWN HERE
}
func testErrorPropagation() async throws {
async let f = fast()
async let s = slow()
try await (f, s)
print("leaving local scope") // <- will not be printed
}
// Prints:
// slow started
// fast started
// fast ended // after 5 secs
// slow cancelled CancellationError()
// slow ended
// external catch TestError1()
After fast
throws TestError1
, execution leaves the local scope because the error is propagated outside of it. As a result, slow
is implicitly cancelled and implicitly awaited.TestError1
is propagated and caught outside of local scope. TestError2
was not propagated because TestError1
was handled first.
What about error propagation in unstructured tasks?
Throwing an error in unstructured nested task doesn’t cancel other unstructured nested tasks:
let externalTask = Task {
let nestedTask = Task {
print("Nested Task 1 started")
do {
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
print("Nested Task 1 cancelled", error)
}
print("Nested Task 1 ended")
throw TestError1()
}
let nestedTask2 = Task {
print("Nested Task 2 started")
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
} catch {
print("Nested Task 2 cancelled", error)
}
print("Nested Task 2 ended")
throw TestError2()
}
try await nestedTask.value
try await nestedTask2.value
}
do {
try await externalTask.value
} catch {
print("Caught error \\(error)")
}
// Prints:
// Nested Task 1 started
// Nested Task 2 started
// Nested Task 1 ended // after 5 secs
// Caught error TestError1()
// Nested Task 2 ended // after 10 secs
As you can see, when we throw TestError1
from nestedTask
, error is propagated and get caught outside. nestedTask2
was not cancelled and ended later after that. That means:
👉 Error Propagation Rule doesn’t work ❌ for nested unstructured tasks.
💡: There is another rule that works for both structured and unstructured tasks (including nested) related to error propagation: 👉 Error is propagated from task only if that task is awaited explicitly. No await → no propagation.
In this example, even though error is thrown we will leave the task group closure without error propagated because child tasks are not awaited:
try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { try await fast() // throws error } group.addTask { try await slow() // throws error } print("leaving task group closure without error propagated”) }
Context Inheritance
Even though it’s not the part of defined structured concurrency rules, let’s also discuss how structured and unstructured tasks inherit properties from the context they are created from.
Regular unstructured task inherits:
Task Priority
In an async context, when you create an unstructured task without specifying a priority, it inherits the priority from the current task.
In sync context it will use the priority of the thread or queue from which it was called.
override func viewDidLoad() { super.viewDidLoad() Task { print(Task.currentPriority) // Prints `.high` } Task { DispatchQueue.global(priority: .low).async { print(Task.currentPriority) // Prints `.low` } } Task(priority: .background) { Task { print(Task.currentPriority) // Prints `.background` } } Task(priority: .background) { DispatchQueue.main.async { Task { print(Task.currentPriority) // Prints `.high` } } } }
Task local values
Swift lets us attach metadata to a task using task-local values, which are small pieces of information that any code inside a task can read. A task-local value is bound and read in the context of a task. It is implicitly carried with the task, and is accessible by any child tasks it creates. If we create unstructured task, is also inherit it from the task it was created from.
enum TaskLocalStorage { @TaskLocal static var requestID: String? } func checkTaskLocalValue() async { await TaskLocalStorage.$requestID.withValue("12345") { print("RequestID: \\(TaskLocalStorage.requestID ?? "No Request ID")") // Prints: Request ID: 12345 Task { // Regular unstructured task inherits task-local values. print("Regular unstructured RequestID: \\(TaskLocalStorage.requestID ?? "No Request ID")") // Prints: Regular unstructured RequestID: 12345 } // async let and task group will inherit task local values as well. // Only detached task is "detached" from TaskLocalStorage. Task.detached { // Detached task does not inherit task-local values. print("Detached Task RequestID: \\(TaskLocalStorage.requestID ?? "No Request ID")") // Prints: Detached Task RequestID: No Request ID } } }
The actor we’re currently running on (if any). Basically it means that we inherit the context’s executor (aka execution context).
Structured task inherits the same as regular task except actor isolation:
⚠️ : Some articles claim that child structured tasks created with
async let
inherit actor isolation from the context in which they are created. This can be misleading. Structured tasks do not inherit actor isolation from their surrounding context. It wouldn’t make sense because structured tasks are designed to parallelize the work, and inheriting actor isolation would force them to run everything on the same actor (if present), sequentially.By default, child structured tasks run on the global concurrent executor. However, keep in mind that any async functions you call within these tasks can have their own actor isolation. For
async let
, once it is assigned with an async function call, it may look like as though it inherits the actor isolation of that function. But again, structured tasks never inherit actor isolation from the context where they are created.
nonisolated func fetchFirst() async -> Int { //... } @MainActor func fetchSecond() async -> Int async { //... } async let first = fetchFirst() async let second = fetchSecond() // is converted under the hood into something like this: let first = ChildTask { // global concurrent executor await fetchFirst() // global concurrent executor } let second = ChildTask { // global concurrent executor await fetchSecond() // main executor }
Detached task inherits nothing. On practice it means that it always runs on the global concurrent executor, so it will run on any thread from cooperative thread pool. But not main thread for sure.
When to use structured and unstructured concurrency?
When to use structured concurrency?
When you need to run multiple concurrent operations at once. But keep in mind it’s “implicit awaiting” behavior: when execution exits this scope — either normally or due to an error — all child tasks will be implicitly awaited.
When to use async let vs task group?
Several factors to consider:
Amount of tasks: static count for async let, dynamic count for task groups.
If you have error propagation logic, a task group may be a better choice than
async let
. The task group’s "first thrown, first caught" logic is more predictable and won’t be affected by the order of awaiting, unlikeasync let
, where error propagation can be influenced by the awaiting order. Task groups are more suitable for cases where you want to "fail fast" compared toasync let
.async let can be easier to use with heterogeneous results and step-by-step initialization patterns.
When to use unstructured concurrency?
Obviously, use it when you need to switch gears from sync to async context.
Regular unstructured task might also be used if you want to perform a piece of work independently of the function you’re in, without awaiting. Some “fire-and-forget” style operation that will allow the function to not wait for this task completion.
func someWorkThatDontWantToWait() async { Task { await work() } }
Detached Tasks use case is kind of tricky, because hard to say exactly when you need it:
That’s why it’s often referenced as a last resort, when nothing else would suit.
Anyway, make sure you really understand why usually it is not needed.
💡: Usually regular task is enough. Even though it inherits actor isolation, even if you want to avoid it and run some function on global concurrent executor, remember that this function also has it’s own actor isolation context which will define where it will be executed, not the task from where we call it. For example:
class ViewController: UIViewController { // Main actor isolated func someWorkOnMainThread() async { Task.detached { await self.asyncMethod() await self.syncMethod() } } func asyncMethod() async { // called on the main thread } func syncMethod() { // called on the main thread } }
Although we called methods from detached task, it didn’t make them to be called on global concurrent executor. Both method are main actor isolated as methods of ViewController which is main actor isolated.
Finally, for quick reference, keep this the helper table from Apple WWDC session:
For structured tasks lifecycle when local scope is left, this scheme should be useful as well (you can find more info on that matter here):
If you spot any inaccuracies or misleading points in this post, please share them in the comments - your feedback helps everyone!
Good luck on your Swift Concurrency journey! See you in the next article!
Subscribe to my newsletter
Read articles from Vitaly Batrakov directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Vitaly Batrakov
Vitaly Batrakov
Senior iOS Engineer at Skip