August 23, 2021
Swift Concurrency - Part 1 - Async/Await
Before diving straight into using async
and await
let’s take a farewell tour of our past conventions for asynchronous programming.
Callbacks
Callbacks via closures are probably the most prevalent means by which we’ve handled asynchronous events in the past. Using callbacks made preparing some code to respond to an asynchronous event easy because of how simple they are to set up. You pass in a function and it gets called later when ready. Callbacks however, have a propensity for scaling poorly. The pyramid of doom is a series of nested callbacks that become very difficult to make sense of and control flow is also very difficult to see.
fetchCow { cow in
feedCow(cow) { isFull in
guard !isFull else {
completion(.failure(CowError.unhappyCow))
return
}
cow.collectMilk { milk in
completion(.success(milk))
}
}
}
Promises
Promises made asynchronous programming much easier by enabling chaining and transformation of asynchronous events and results. A series of network requests built with callbacks would become much nicer with promises by way of readability. Though for their improvements over callbacks, promises still have the problem of poor local reasoning. The lifetime of a promise could long exceed the scope in which it was defined.
fetchCow()
.then { cow -> Promise<(Cow, Bool), CowError> in
return feedCow(cow)
}
.map { (cow, isFull) -> Promise<Milk, CowError> in
guard isFull else { return CowError.unhappyCow }
return cow.collectMilk()
}
.then { milk in
// do something with milk
}
.catch {
// handle error
}
Async/Await
Swift's new concurrency features makes writing concurrent and asynchronous code faster, less error prone, and easier to reason about. It all starts with async
and await
; two new keywords for expressing asynchronous intent. Functions marked with async
indicate that there is a possibility that it may have to suspend its execution and pick up later. On the calling side you use await
when calling an async function to suspend execution (if needed) while waiting for the function to finish.
For example, we may have a async function for fetching resources over the network. After making the network request it could take a long time to receive those resources. The function can suspend while waiting to receive those resources and allow other code to run.
func fetchCow() async -> Cow {
// does asynchronous network fetch
}
// code before `fetchCow` executes before
// execution is suspended until the result of fetchCastles arrives
let cow = await fetchCow()
// code following `fetchCow` executes after, letting you access `cow`
Awaiting the result of fetchCow
like this creates a sequential binding, meaning that our code will proceed from top to bottom, suspending if/when needed. The result from fetchCow
is required before moving on.
Important note: Async functions are non-blocking when they suspend. Other tasks (see Structured Concurrency) are able to run when an async function suspends. This means our network request from an async function won’t tie up a thread while waiting for the response.
Async/Await & Throws/Try
The call structure of async/await
should feel familiar, the same paradigm is employed for throwing errors. A function marked with throws
denotes that there is a possibility of failure and that the caller must be prepared to handle those errors. This is even enforced by the compiler, when calling a throwing function you must use try
. Async functions work in the same way, the compiler requires that calls to async functions use await
.
I think you will find that the compiler checks around Swift’s new concurrency features go a long way in helping you write correct concurrent code.
A crutial detail to learn is async
and throws
are composable. A function can be both async and throwing. This is one of my favorite parts of Swift’s new concurrency features. What might seem like an odd edge case actually makes writing async code more ergonomic compared to our callback based origins. Let’s look at an earlier example but modified with Result
based error handling:
fetchCow { result in
switch result {
case .success(let cow):
feedCow(cow) { result in
switch result {
case .success(let isFull):
// ...
case .failure(let error):
// ...
}
}
case .failure(error):
// ...
}
}
The problem here is our inevitable pyramid of doom is growing extremely quickly and there are so many logical branches that need to be considered. Let's look at this same example but rewritten with using async await.
do {
let cow = try await fetchCow()
let isFull = try await feedCow(cow)
} catch let error {
// ...
}
Composing async
and throws
makes for much easier to reason about code. There are two clear paths in this example, the happy path where we can get and clean our cow as well as an error handling path. Any error that is thrown from either function is caught there and handled. Our logical branches have been reduced down to just two and we can clearly see the progression of async calls.
Function Suspension
You may notice that async functions in Swift don’t return any sort of future, promise, or handle, it just returns the value itself. This is in counter to other languages that already use the async await keywords. This is an important distinction because the mental model of how we write asynchronous code changes a lot. The function itself doesn’t return anything to be waited on; there is no future, promise, or handle that is returned synchronously. Rather an async function in Swift can suspend and execution for the entire call stack is paused while waiting, giving control over to the system, and later return with the actual value.
Parallel processing with Async Let
So far we have a great solution for handling asynchrony using await
on functions marked with async
. One drawback here is that these calls are all handled sequentially; one after the other. In many cases this is desirable because the calls build upon each other and the result of the previous is required for the next. Sometimes though, the calls are independent of each other and could even be done at the same time to increase efficiency.
This concurrent/parallel processing we’re hoping for can be done using Swift’s async let
syntax. With async let
we are able to kick off multiple pieces of code to run concurrently and when needed await
the result.
func careForCow(_ cow: Cow) async throws -> Milk {
async let isClean = washCow(cow)
async let isFull = feedCow(cow)
async let milk = cow.collectMilk()
// execution continues
guard try await isFull,
try await isClean else {
throw FarmError.unhappyCow
}
return try await milk
}
Fun Fact—Async Everywhere
Functions aren’t the only thing that can be asynchronous, initializers and property getters can also be async as well.
struct WebPage {
enum WebPage: Error {
case emptyContent
}
private let content: String
init(url: URL) async throws {
let (data, _) = try await URLSession.shared.data(from: url)
guard let stringData = String(data: data, encoding: .utf8) else { throw WebPage.emptyContent }
self.content = stringData
}
var title: String {
get async {
// parse content for title
}
}
}
…
let appleDotCom = URL(string: “https://www.apple.com”)!
let page = try await WebPage(url: appleDotCom)
print(await page.title)
Breaking from our cow theme we can see in this example the concept of a webpage. To create an instance of Webpage
we need to provide a url and wait for the content of that url to be fetched before using that instance. In this implementation WebPage
doesn’t do any html parsing when receiving the site content, rather it does parsing as needed. Accessing the title
property of WebPage
requires an await
because the site parsing is done asynchronously.
❖ ❖ ❖