Concurrency Patterns in Swift with Async/Await
In this post, we will explore the use of async & await patterns of handling asynchronous operations in Swift for those coming from a JavaScript background.
async
& await
are key concurrency tools for most JavaScript developers and have been in common use for many years. In the Swift language, on the other hand, async
and await
are relatively new, appearing in Swift 5.5, and are generally not the most common idiom for dealing with concurrency. While they may not be the most common approach, there are definitely scenarios in which they can be used to craft a cleaner solution. In this post, we will explore the use of async
& await
patterns of handling asynchronous operations in Swift for those coming from a JavaScript background.
Concurrency and Callbacks
Concurrency, broadly speaking, is the ability for a language to manage multiple streams of computation at the same time. Note that concurrency is distinct from parallelism, which is the ability to improve performance by actively performing two tasks at the same time (e.g., on multiple processors or cores).
In both JavaScript and in Swift, simple concurrency can be achieved through the use of functions that accept callbacks, and the two have comparable syntax:
In the two example above, we make use concurrency to do two things at once: read a file, performing some operation with the result, and perform some other expensive computation. In this example, we don't know and don't care which operation completes first–only that both streams of computation are performed.
Why Async/Await?
For simple concurrency, callbacks work just fine. To understand why we need async
and await
, we need to consider some a complicated example. Let's consider a completely fictional (but plausible) chain of asynchronous computation to read a file, parse the contents, fetch & process some metadata, and post the result to a server:
Yikes! We've created "callback hell". This is where async
and await
come into play: they replace callbacks with a linear syntax for perfoming concurrent computations. Let's refactor our code to make use of async
and await
. First, the finished computation using await
:
Behind the scenes, we've also updated the signature for the functions we're using. We'll get into the exact implementations later, but here's how the declarations change:
Async/Await Patterns in Swift & JavaScript
Let's take a look at some common async
/await
patterns you may be familiar with in the JavaScript world, and see how they are implemented in Swift.
Basic async Function Implementation
First, let's take a look at the most basic usage. In order to use await
, you'll need to write async
functions. In both Swift and JavaScript, the simplest possible way to implement an async
function is to await
the result of another async
function!
First, a simple example in JavaScript:
async function loadData(url) {
return await fetch(url)
}
In Swift, we can do the same thing using existing async Foundation function on URLSession
to read data from a URL in async
fashion:
func loadData(url: String) async throws -> Data {
let request = URLRequest(url: URL(string: url)!)
let (data, _) = try await URLSession.shared.data(for: request)
return data
}
Composing Async Functions from Callbacks
What about creating an async function when no existing async function exists? For example, when adapting a function using a callback?
In JavaScript, we can create an async function by returning a Promise. In this case, we wrap an existing apiCall
function in a promise to make it async
:
async function asyncApiCall() {
return new Promise((resolve, reject) => {
apiCall((result, error) => {
if (result) {
resolve(result)
} else {
reject(error)
}
})
})
}
Similarly, in Swift, we can construct an async
call using a Continuation. The full details of continuations in Swift are a much larger topic, but in context of composing an async
function, they can be used in a way similar to the JavaScript Promise in the example above:
func fetch(url: String) async throws -> Data? {
let request = URLRequest(url: URL(string: url)!)
return try await withCheckedThrowingContinuation { continuation in
URLSession.shared.dataTask(with: request) { data, _, err in
if(err == nil) {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: err!)
}
}
}
}
Here we use continuation.resume
in the same way as the JavaScript Promise functions resolve
and reject
: we can resume with either a return value, or an error.
Combining Multiple Async Calls
Combining async
calls works a a bit differently in JavaScript and Swift.
As we alluded to earlier, async calls in JavaScript are just promises under the hood. Because of this, if we call an async
function without await, we can get a Promise object, and thus handle multiple async calls using Promise.all()
:
async function fetchData() { ... }
func fetchMultiple(urls) {
let promises = urls.map(url => {
return fetchData(url) // note -- we're not using await!
})
return Promises.all(promises)
}
In Swift, we can achieve something similar with a TaskGroup:
// assume we have the following:
func fetchData() async -> Data { ... }
func fetchMultiple(urls: [String]) async -> [Data] {
return await withTaskGroup(of: Data.self) { group in
urls.forEach { url in group.addTask { await self.fetch(url: url) } }
var data: [Data] = []
for await i in group { data.append(i) }
return data
}
}
In this example, we add the individual calls to a TaskGroup (using await
while makign the call!) and then assemble the results manually.
Conclusion
async/await
are powerful tools for dealing with asynchronous code and relatively new to the Swift ecosystem. While async
& await
are not the only approach to building concurrency in Swift, they are especially useful when representing a linear flow of logic which relies on complex asynchronous tasks, and helping to unwind the "callback hell" that can occur when building this logic with callbacks.