Photo by Mae Mu on Unsplash

From callbacks to async/await in Swift

Rahul Garg
Level Up Coding
Published in
5 min readJan 17, 2021

--

Closures are self-contained blocks of functionality that can be passed around and used in your code. Closures can capture and store references to any constants and variables from the context in which they are defined. Closures let an async function accept a parameter that is actually another function. The function gets called after the closure’s code is finished, with an error or a value.

Modern Swift development involves a lot of asynchronous programming using closures and completion handlers. Closures are the easiest way to write async code. These are complicated, but yet are so powerful and expressive that they are used pervasively in the development of an iOS application.

In this article, we will discuss about async/await, a language extension to make asynchronous programming a lot more natural and less error prone. This draws some inspiration from an earlier proposal written by Oleg Andreev, available here.

Problems with block-based APIs

Asynchronous programming with block-based APIs has many problems.

Problem 1: Pyramid of Doom

We all have come across nested network callbacks which looks something like this:

func makeSandwich(completionBlock: (result: Sandwich) -> Void) {
cutBread { buns in
cutCheese { cheeseSlice in
cutHam { hamSlice in
cutTomato { tomatoSlice in
let sandwich = Sandwich([buns, cheeseSlice, hamSlice, tomatoSlice]
completionBlock(sandwich))
}
}
}
}
}

makeSandwich { sandwich in
eat(sandwich)
}

Problem 2: Verbosity and old-style error handling

Handling of errors becomes difficult and very verbose.

func makeSandwich(completionBlock: (result: Sandwich?, error: NSError?) -> Void) {
cutBread { buns, error in
guard let buns = buns else {
completionBlock(nil, error)
return
}
cutCheese { cheeseSlice, error in
guard let cheeseSlice = cheeseSlice else {
completionBlock(nil, error)
return
}
cutHam { hamSlice, error in
guard let hamSlice = hamSlice else {
completionBlock(nil, error)
return
}
cutTomato { tomatoSlice in
guard let tomatoSlice = tomatoSlice else {
completionBlock(nil, error)
return
}
let sandwich = Sandwich([buns, cheeseSlice, hamSlice, tomatoSlice]
completionBlock(sandwich), nil)
}
}
}
}
}

makeSandwich { sandwich, error in
guard let sandwich = sandwich else {
error("No sandwich today")
return
}
eat(sandwich)
}

Problem 3: Forget to call a completion handler

It’s easy to bail out by simply returning without calling the appropriate block. When forgotten, the issue is very hard to debug.

func makeSandwich(completionBlock: (result: Sandwich?, error: NSError?) -> Void) {
cutBread { buns, error in
guard let buns = buns else {
return // <- forgot to call the block
}
cutCheese { cheeseSlice, error in
guard let cheeseSlice = cheeseSlice else {
return // <- forgot to call the block
}
...
}
}
}

Problem 4: Forget to return after calling a completion handler

When you do not forget to call the block, you can still forget to return after that. Thankfully guard syntax protects against that to some degree, but it's not always relevant.

func makeSandwich(recipient: Person, completionBlock: (result: Sandwich?, error: NSError?) -> Void) {
if recipient.isVegeterian {
if let sandwich = cachedVegeterianSandwich {
completionBlock(cachedVegeterianSandwich) // <- forgot to return after calling the block
}
}
...
}

Problem 5: Continuing on the wrong queue/thread

Many APIs call completion block on their own private queues. User code mostly expects to run on its own private queue (or main thread).

Forgetting to dispatch_async back to the correct queue leads to very hard to debug issues that can manifest sporadically or in some unrelated parts of the program.

func makeSandwich(completionBlock: (result:Sandwich)->Void) {
cutBread { buns in
dispatch_async(dispatch_get_main_queue()) {
cutCheese { cheeseSlice in
dispatch_async(dispatch_get_main_queue()) {
cutHam { hamSlice in
dispatch_async(dispatch_get_main_queue()) {
cutTomato { tomatoSlice in
dispatch_async(dispatch_get_main_queue()) {
completionBlock(Sandwich([buns, cheeseSlice, hamSlice, tomatoSlice]))
}
}
}
}
}
}
}
}
}

Proposed solution: async/await

One of the nicest solutions to the problem of callback hell is the async/await. async/await, often known as Asynchronous functions, allow asynchronous code to be written as if it were straight-line, synchronous code. The concept of async/await is simple: allow the imperative execution of asynchronous code, i.e. instead of using callbacks to handle the return values when an asynchronous operation completes, you just return the a result from a normal function and the compiler does the rest for you.

Functions can opt-in the async semantics by using the async keyword and replacing closure argument with the usual return type:

// Before:
func makeSandwich(completionHandler: (result: Sandwich) -> Void)
// After:
async func makeSandwich() -> Sandwich

We add the word async before returning a value when declaring the function and await before calling it.

The first example can be rewritten in a more natural way using async/await, like this:

async func cutBread() -> Bread
async func cutCheese() -> Cheese
async func cutHam() -> Ham
async func cutTomato() -> Vegetable

async func makeSandwich() -> Sandwich {
let bread = await cutBread()
let cheese = await cutCheese()
let ham = await cutHam()
let tomato = await cutTomato()
return Sandwich([bread, cheese, ham, tomato])
}

Asynchronous code begins execution in the same order as written. Some operations may need to begin upon completion of other operations, others may need to run in parallel. By using await in appropriate places you may specify which operations wait for which ones.

Note that await does not block the thread, it only tells compiler to organize the remaining calls as a continuation of the awaited operation. There is no risk of deadlock when using await and it is not possible to use it to make the current thread wait for the result.

Conclusion

async/await is not something new at all. Microsoft already implemented it in .Net. JavaScript supports this feature as well. Where Swift is concerned, developers proposed to add this feature for many years and it finally got approved.

Asynchronous functions are great news for Swift developers. The great benefit of this approach is that async code is written in the same way as sync code, which is a great, great, great advantage!

I hope this post convinces some of you to take a look at Asynchronous functions and change from callback hell to something that looks a lot more clean and easy to work with.

I hope you have enjoyed this brief introduction. You can catch me at: Rahul Garg.

--

--