We can use async await in Swift for asynchronous programming. This language feature was introduced in Swift 5.5 and allows developers to write asynchronous code in a more synchronous-like way. The main characteristics of async/await
include:
- Cooperative thread pool: We can forget about creating and destroying threads, as well as the usual performance problems caused by a thread explosion. This new model contains a pool of threads, and when performing work in an asynchronous context, the work is executed on one of these threads. It’s also optimized for quick suspension and resumption in any other thread.
- Structured concurrency: Personally, this point is one of the best benefits of using this model instead of others like Combine. We can concatenate multiple asynchronous instructions one after another, avoiding the use of completion closures. This significantly improves readability, and finally, we can say goodbye to the arrow anti-pattern.
- Hierarchy: When executing asynchronous instructions in async/await, we need to create an asynchronous context. Inside this context, we can create child contexts. This allows us to establish a hierarchy of parent-child tasks, which is exceptionally useful when handling task cancellations.
- Preventing unsafe concurrent code: I don’t know how many bugs I’ve found related to data races. The main problem is having shared mutable state that can be mutated by different threads at any moment. This model introduces the concept of
Actor
and Sendable
closures, which prevent even compiling code if we are not controlling how shared mutable state is accessed.
Syntax
Now let’s talk about the syntax. To declare a function that will perform some asynchronous work, we add async
after the parentheses. The throw
keyword is optional, depending on whether our function can throw errors or not.
Swiftfunc loadData() async throws -> Data {
...
}
func loadData() async -> Data {
...
}
Inside an asynchronous context, we need to add the keyword await
to call a function marked as async
. When the runtime is executing an instruction and reaches the await
keyword, it will create a suspension point. This means the runtime will execute the await
call at a later time(depending on the priority of other tasks as well), using one of the threads from the thread pool, and when it’s finished, the runtime will continue with the next instruction. Therefore, when we concatenate multiple ‘await’ instructions one after another, they are executed in a serial order. This way, we can use the result from the previous ‘await’ calls for the next instruction
Swiftfunc main() async throws {
let data = try await loadData()
let anotherData = try await loadData()
let anotherCall = try await loadData()
}
func loadData() async throws -> Data {
let url = URL(string: "http://any-url.com")!
return try await URLSession.shared.data(for: URLRequest(url: url)).0
}
What about Parallel work?
The previous example seems quite useful, but in some situations, we may want to perform multiple asynchronous tasks simultaneously, especially when these tasks are unrelated. We can achieve this by marking the results returned from asynchronous methods with async let
. This way, we can declare multiple asynchronous code blocks to be executed concurrently, and to obtain their results, we need to read them using await
as demonstrated in the last instruction.
As a note, loadData
and getType
calls are executed as soon as possible, and they do not wait for the await
instruction to start their execution.
Swiftfunc load() async throws {
async let data = try loadData()
async let type = getType()
let (dataResult, typeResult) = try await (data, type)
}
We can use this structure for simple cases. However, when we need to perform a lot of parallel work, we have better tools available such as task groups, which we will explain at some point (I hope).
Creating the asynchronous context
To be able to use async await in swift, we need to be inside an asynchronous context. For example, if we try to perform an await
instruction directly in the main thread, we will get a compiler error since we cannot just suspend the main thread. We can create an asynchronous context using a Task
:
Swiftfunc onAppear() {
Task {
let data = try await loadData()
}
}
Inside a task, we can perform any asynchronous work. The tasks start their job immediately after creation and we don’t need to keep a reference to the task if we don’t want to. However, we have the ability to do so:
Swiftfunc onAppear() {
//task: Task<Data, Error>
let task = Task {
let data = try await loadData()
return data
}
//...
task.cancel()
Task {
let result = try await task.value
}
}
We can see that it is generic over two types: the returned type from the task and the error. Having a reference to the task allows us to cancel the task and also await for the returned result by awaiting the task value.
Usage inside SwiftUI
We can create an asynchronous context inside a SwiftUI view using the Task type, but there is a modifier called ‘task’ that is specially designed for this purpose. The closure of the task
modifier is executed similarly to the onAppear
modifier, but it is inherently an asynchronous context that handles cancellation for us. The lifecycle of the task is linked to the lifecycle of the view, so if the screen is dismissed for any reason, the task will be cancelled.
Swiftimport SwiftUI
struct ContentViewModel {
func getTitle() async -> String {
return "Loaded"
}
}
struct ContentView: View {
let viewModel: ContentViewModel
@State var title = "Loading..."
var body: some View {
Text(title)
.task {
title = await viewModel.getTitle()
}
}
}
Conclusion
This concludes our introduction to the async await world in swift. We have only scratched the surface of what we can do, just to give you a general idea of how this works. In future posts, we will delve into much more detail about how it works under the hood and explore many more tools that we can use, such as cancellation, task groups, async sequences, actors, and more. See you in my next article about how to perform UI updates when working with async await.