Async/await. Structured concurrency

Metakratos Studio
Metakratos Studio
Published in
7 min readJun 15, 2023

Hello everyone! Its Metakratos, and now we’re going to dive into asynchronous Swift!

Today we’ll explore how to write interdependent asynchronous tasks, get to know Task more closely, and examine a few interesting examples.

Task

So, what is Task:

Task is the basic unit of concurrency in the system. Each asynchronous function runs within a task. Apple draws an analogy with Thread: Task is to asynchronous functions what Thread is to synchronous functions.

From this analogy, we can understand that Task is a context of an asynchronous function. It can contain all the information about its execution and call stack. With Task, we can perform different tasks without changing the Thread because the context for any asynchronous task is no longer Thread but Task.

Each task can have three states:

Running — executing on a thread until it reaches either a return or a suspension point.

Suspended — not currently executing but has tasks to be performed.

Also there are two subtypes:

Waiting — waiting for a child task to complete.

Schedulable — ready to be executed and waiting for its turn.

Completed — finished execution. The final state of a task.

Each task has two fields of interest to us at the moment:

  • isCancelled: Bool — indicates whether the task should stop executing its work.
  • priority: TaskPriority — indicates the priority of the task. With this, the Runtime Executor understands how to schedule task execution. It can be created using init(rawValue: UInt8) or using the default values:
  1. High
  2. Medium (previously called default)
  3. Low
  4. UserInitiated
  5. Utility
  6. Background

The task’s priority can be changed by the system to avoid priority inversion. A high-priority task dependent on a lower-priority task can raise the priority of the latter. The work of actors can also influence this, which we’ll discuss later.

In addition to the fields, Task has three main methods:

  1. Task.sleep() — delays the execution of the task for the specified duration.
  2. Task.checkCancellation() — throws a CancellationError if the task has been canceled.
  3. Task.yield() — defers the execution of the task for some time provided by the system. If the task has high priority, it continues execution without interruptions.

Tasks can be of two types: structured and unstructured.

Structured Tasks

An asynchronous function can create child tasks. These child tasks, in turn, can create other tasks, forming a tree-like structure of tasks, a hierarchy. With this structure, we can:

  1. Cancel all child tasks in the runtime if the parent task is canceled. If the parent task changes its isCancelled field either by itself or externally, we traverse the tree and update the same field to match the parent task.

2. Await the completion of all child tasks. If we invoke multiple asynchronous functions sequentially in our code, they will execute in a “sequential” manner, only when the previous one finishes its execution.

3. Propagate errors to parent tasks. If a task is throwable, we can propagate the error to the higher level just like with regular functions. After throwing an error, the function suspends its execution.

Parallelism

In addition to the advantages mentioned above, creating subtasks can help achieve parallelism in our code (if the system and its workload allow it). How can we create subtasks? There are two main ways:

  1. Using async let variables.
  2. Using TaskGroup.

Async Let

Let’s look at an example:

We receive a debit card ID, and we need to load data for a detailed screen, including the card’s main information and a list of recent transactions. The essential requirement is that we can only display the data after both parts are loaded.

struct DetailInfo {
let cardInfo: CreditCard // struct CreditCard - Main card information.
let lastOperations: [Operation] // struct Operation - Recent card transactions.
}

// Loading main card information.
func fetchCreditCardInfo(for id: String) async throws -> CreditCard
// Loading a list of recent card transactions.
func fetchLastOperations(for id: String) async throws -> [Operation]

The basic data types and loading methods are already provided in the condition. We just need to compose them.

Let’s implement the loading of detailed information:

func fetchDetailInfo(for id: String) async throws -> DetailInfo {
let creditCardInfo = try await fetchCreditCardInfo(for: id)
let lastOperations = try await fetchLastOperations(for: id)

return DetailInfo(cardInfo: creditCardInfo, lastOperations: lastOperations)
}

However, this solution has one drawback: the data loading happens sequentially, meaning the transaction loading won’t start until the card information is loaded — is that what we want? No, these tasks are independent of each other, so we can parallelize them using async let.

Let’s rewrite our solution:

// Loading main card information.
func fetchDetailInfo(for id: String) async throws -> DetailInfo {
async let creditCardInfo = fetchCreditCardInfo(for: id)
async let lastOperations = fetchLastOperations(for: id)

return DetailInfo(cardInfo: try await creditCardInfo, lastOperations: try await lastOperations)

Now the network data loading consists of two tasks that are executed in parallel while the task itself is not blocked. This means that if there were code between the creation of async let variables and the return statement, it would continue executing. We’ve transitioned from sequential execution to parallel execution:

This new construct works as follows:

  1. A child task with the same priority is created.
  2. Execution begins.
  3. When accessing this variable, we need to use the keyword ‘await’ to set a suspension point because the evaluation of the async let variable may not be completed, and we have to wait for its completion.

We’ve explored this cool mechanism of creating child tasks using async let variables. However, it has a limitation: the number of child tasks created using this construct is always predetermined in the code and does not depend on external factors. However, when dealing with a large number of tasks, we often need to iterate over a server response, such as an array of URLs. To create multiple tasks that are not known in advance, Apple provides developers with the TaskGroup mechanism.

TaskGroup

Let’s add a new requirement to our credit card task: The backend has changed, and now, along with the card, an array of operation IDs of type [String] is received. To load each operation, we need to send a request. Each operation contains an amount and a date:

struct Operation {
let date: Date
let amount: Double
}

Thus, the additional requirement is that we need to load an array of operations:

func fetchOperations(ids: [String]) async throws -> [Operation] // Loading an array of card transactions.
func fetchOperation(id: String) async throws -> Operation // The method for loading transaction data is already implemented.

To solve this task, let’s use the TaskGroup mechanism. It provides an asynchronous scope where we can create child tasks. An interesting aspect is that if one task encounters an error, others will be automatically canceled.

There are two types of groups: error-propagating and non-error-propagating, represented by withTaskGroup and withThrowingTaskGroup, respectively. If we look at their interfaces, we’ll see:

// Function for creating a task group.
@inlinable public func withTaskGroup<ChildTaskResult, GroupResult>(
of childTaskResultType: ChildTaskResult.Type, // The type returned by a subtask.
returning returnType: GroupResult.Type = GroupResult.self, // The type returned by the group.
body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult // A closure or scope that takes the group and asynchronously returns a result.
) async -> GroupResult

We need the following call to this function:

await withTaskGroup(
of: Operation.self,
returning: [Operation].self // You don't have to explicitly specify it; Swift will infer the closure type automatically
body: { group in
/* Working with the task group */
}

TaskGroup Methods:

  1. func addTask(priority: TaskPriority?, operation: @Sendable () -> ChildTaskResult) — adds a task to the group with an optional priority.
  2. func addTaskUnlessCancelled(priority: TaskPriority?, operation: @Sendable () -> ChildTaskResult) -> Bool — adds a task to the group if the group has not been canceled.
  3. func waitForAll() async throws — an asynchronous method that waits for all tasks to complete.
  4. Methods of the new AsyncSequence protocol, which we’ll discuss a little later.
  5. cancelAll() — cancels all running tasks in the group.

Let’s implement our method for loading data from the backend:

func fetchOperations(ids: [String]) async throws -> [Operation] {
try await withThrowingTaskGroup(of: Operation.self) { group in
for id in ids { // Iterate through the IDs array
group.addTask { // Add a task to the group for each ID
try await fetchOperation(id: id) // Await the completion of loading the transaction information
}
}
// Collect all transactions into a single array, without guaranteeing the order of addition
let unsortedOperations = try await group.reduce(into: [Operation]()) { $0.append($1) }

return unsortedOperations.sorted { $0.date < $1.date } // Sort the transactions by time and return the result
}
}

Conclusion

We’ve learned the basics of the completely new syntax in the Swift language, which is unfamiliar in iOS development. We’ve seen its advantages over working with closures and threads, and we’ve explored error handling. The most interesting part is that this is just the tip of the iceberg — there’s much more to discover!

--

--

Metakratos Studio
Metakratos Studio

Digital Power for Startups and Enterprises to Create Products of the Future