DEV Community

Gualtiero Frigerio
Gualtiero Frigerio

Posted on

Introduction to async await

When I found out about the addition of async await in Swift at 2021 WWDC I was eager to try it out, but the fact that is was iOS 15 only was a bummer, since some of my apps still support back to iOS 10.
The good news is that the amazing team behind Swift was able to back port the feature to iOS 13, and that means many more developers will be able to start adding the feature to their code base.
I’m not rushing to replace all of my asynchronous code just because I can, but I’m going to do is adopt it whenever I add something new, and slowly replace old implementations while refactoring code. There’s even a refactoring option in Xcode to automatically create an async version of an existing function and I'll show you how easy it is.

The sample app

I went back to a GitHub project I used for some articles in the past about Combine and Property Wrappers and I thought it would be interesting to see the use of async await as an alternative to the code I wrote back then. If you feel to, you can read Networking example with Combine to get an idea of the Combine implementation.
I’ve already described the APIs on the article about Future and Promises so here’s going to find only a quick recap. I make 3 calls: one for the pictures, the second for the albums and the third one to retrieve the users. Then I merge the pictures with the albums, and the albums with the users. 
In order to return the array of users, the calls to pictures and albums must be completed, so I can return from the function with success, or with an error if one of the call fails.
All the code is available on the project, in particular in the class DataSourceAsync

Async functions

As I said, the function to retrieve all the data is asynchronous. We have some alternatives to deal with this kind of functions, we can pass a completion handler as a parameter so we'll be called back when the data is ready, use a Promise, or use Combine as described in the articles linked above.
Now in Swift we have a new alternative, a function can be marked as async

func getUsersWithMergedData() async -> [User]? {
    guard let pictures = try? await getPictures(),
          let albums = try? await getAlbums(),
          let users = try? await getUsers() else {
              return nil
          }
    let mergedAlbums = DataSource.mergeAlbums(albums, withPictures: pictures)
    return DataSource.mergeUsers(users, withAlbums: mergedAlbums)
}
Enter fullscreen mode Exit fullscreen mode

in order to make a function async, you have to put the async keyword just after the function name, before the return value. This tells the compiler your function is asynchronous, so it is expected to return a value, but not synchronously. The the caller will be blocked until the async function is done.
In order to call an async function, you have to use the new await keyword. In the example above, 3 async functions are called, and we need to wait (or should I say, await) for them to complete before moving on.
In the example, we try? and wait, because the functions may throw an exception. Let's see one of them

private func getAlbums() async throws -> [Album]  {
    guard let albumsData = try? await getEntity(.Album),
          let albums = try? JSONDecoder().decode([Album].self, from: albumsData) else {
              throw DataSourceError.conversionError
    }
    return albums
}
Enter fullscreen mode Exit fullscreen mode

This function is marked as async throws, as it is asynchronous and may throw an error.
As you can see, in the function definition async comes before throws, while at call site you try and then await.

Error handling

I really like the fact we can define throwing async functions. With a callback base approach, one way to report an error to the caller is using the Result type.
If you're not familiar with it, it is an enum with two cases: success and failure, where failure conforms to Error. So you can return a Result and put the value in success, or the error in failure.
Throwing functions are different, you don't need an if or a switch at the caller site to know if there was an error, but you can use the try catch syntax.
In the code samples above, I actually used the try? syntax to avoid dealing with an error. When you try? you're telling the compiler you want to call a function that my throw an error, but if it does you don't care about it. You get an Optional back, so if an error is thrown the Optional is nil, otherwise you'll have the value.

do {
    let mergedAlbums = try await DataSource.mergeAlbums(albums, withPictures: pictures)
    return try await DataSource.mergeUsers(users, withAlbums: mergedAlbums)
}
catch DataSourceError.conversionError {
    print("error while converting data")
}
catch DataSourceError.urlError {
    print("malformed url")
}
catch (let error) {
    print("generic error \(error.localizedDescription)")
}
return nil
Enter fullscreen mode Exit fullscreen mode

This is an example of handling errors in a do catch. We try await (try without the ? this time) for an async function and if it throws, we can catch the error. The nice thing about catch is you can specify the kind of error you want to handle and you can catch a generic error as you see in the last catch.
I like this, more than the switch over the Result type returned by a callback, as I can use the try? if I don't care about handling a specific error and my function may throw, letting the caller deal with an error.

Call async functions from a synchronous context

As we saw in the examples, if we are awaiting for an async function, our function has to be async as well.
You may wonder, where does it stop? You can't have only async functions on your code.
There is an answer for that, is called Task.
When you need to call an async function but you don't want your function to become async, just wrap the async call into a Task like this

@IBAction func showUsersTap(_ sender: Any) {
    if #available(iOS 15.0, *) {
        let dataSourceAsync = DataSourceAsync(baseURL: "https://jsonplaceholder.typicode.com")
        Task {
            if let users = await dataSourceAsync.getUsersWithMergedData() {
                DispatchQueue.main.async {
                    let usersVC = UsersTableViewControllerPW()
                    usersVC.setUsers(users)
                    self.navigationController?.pushViewController(usersVC, animated: true)
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The IBActions wasn't supposed to be marked async, but Task let us deal with that in an easy and clear way.

Refactoring existing functions

As I said at the beginning, Xcode can help us refactor existing functions so we don't have to define the async versions of them.
If you right click on a function, and chose refactor, you have 3 alternatives as you can see in the picture

you can convert to Async, add a new async function or use a wrapper.
Convert and add alternative are similar, the former will replace your existing function while the latter will leave it there, changing it so it uses a Task, await for the result and then call the completion handler.
This is an example from another project where I implemented an XML parser

func parseXML(atURL url:URL,
              elementName:String,
              completion:@escaping (Array<XMLDictionary>?) -> Void) {
    guard let data = try? Data(contentsOf: url) else {
        completion(nil)
        return
    }
   parseXML(data: data, elementName: elementName, completion: completion)
}
Enter fullscreen mode Exit fullscreen mode

and this is the async alternative made by Xcode

@available(*, deprecated, message: "Prefer async alternative instead")
func parseXML(atURL url:URL,
              elementName:String,
              completion:@escaping (Array<XMLDictionary>?) -> Void) {
    Task {
        let result = await parseXML(atURL: url, elementName: elementName)
        completion(result)
    }
}

func parseXML(atURL url:URL,
              elementName:String) async -> Array<XMLDictionary>? {
    guard let data = try? Data(contentsOf: url) else {
        return nil
    }
    return await parseXML(data: data, elementName: elementName)
}
Enter fullscreen mode Exit fullscreen mode

so the original function was marked as deprecated, Task was inserted to call the the async function to wait for its result in order to call the completion handler.
This function didn't use the Result time mentioned before, this in an example of a refactoring from a function with a Result passed as a completion handler

class func loadData(atURL url:URL, completion: @escaping (Result<data, error="">) -> Void) {
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        if let error = error {
            completion(.failure(error))
            return
        }
        if let data = data {
            completion(.success(data))
        }
    }
    task.resume()
}

@available(*, deprecated, message: "Prefer async alternative instead")
class func loadData(atURL url:URL, completion: @escaping (Result<data, error="">) -> Void) {
    Task {
        do {
            let result = try await loadData(atURL: url)
            completion(.success(result))
        } catch {
            completion(.failure(error))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

as you can see Xcode took care of handling the .success and .failure for us.
What about the third option? In this case, we are doing the other way round. We're not changing the original function to add Task and call the new async function. This time, the new async function will call our existing one.

func parseXML(atURL url:URL,
              elementName:String) async -> Array<XMLDictionary>? {
    return await withCheckedContinuation { continuation in
        parseXML(atURL: url, elementName: elementName) { result in
            continuation.resume(returning: result)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

So this is an async function, but it has to wait for the completion handler of parseXML to return the result and we can do it by using a CheckedContinuation. You can wait for this continuation with withCheckedContinuation, call your sync function with the completion handler and call resume on the CheckedContinuation object, that will make our new function return. If the function throws, you can use withCheckedThrowingContinuation instead.
There is an withUnsafeContinuation if you don't care about some checks performed at runtime, for example if you call resume multiple times (which you shouldn't do). My understanding is that while developing is it better to have checks, so you can easily spot errors, and you can switch to the unsafe version when you're done.

Making calls in parallel

func getUsersWithMergedData() async -> [User]? {
    guard let pictures = try? await getPictures(),
          let albums = try? await getAlbums(),
          let users = try? await getUsers() else {
              return nil
          }
    let mergedAlbums = DataSource.mergeAlbums(albums, withPictures: pictures)
    return DataSource.mergeUsers(users, withAlbums: mergedAlbums)
}
Enter fullscreen mode Exit fullscreen mode

The code is fine, but since we're using await, first we call getPictures, then once is done we call getAlbums and finally getUsers.
There is nothing wrong with that, but each of these function will eventually trigger a network call so we may want to make them at the same time and wait for all of them to finish before merging the results together.
Let's take a look at a different implementation

func getUsersWithMergedDataParallel() async -> [User]? {
    async let pictures = getPictures()
    async let albums = getAlbums()
    async let users = getUsers()

    do {
        let mergedAlbums = try await DataSource.mergeAlbums(albums, withPictures: pictures)
        return try await DataSource.mergeUsers(users, withAlbums: mergedAlbums)
    }
    catch DataSourceError.conversionError {
        print("error while converting data")
    }
    catch DataSourceError.urlError {
        print("malformed url")
    }
    catch (let error) {
        print("generic error \(error.localizedDescription)")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This time, we're using async let to define the variables. Doing so, we can make all this function calls without waiting for the result, as you can see there is no await keyword before getUsers, getPictures and getAlbums.
We still have to use await when we actually need to use the result of these function calls.
Look inside the do statement, we still need to try await DataSource.mergeAlbums, because we're using albums and pictures defined as async let.
The difference is that we were able to fire all the network calls at one, so we can have those running in parallel and hopefully save some time.

Conclusion

This was a quick, but I think exhaustive introduction to async await. There are more topic to explore but I'll leave them to future articles.
Hope you find this useful, let me know what you think about async await and I hope you'll have fun implementing it in your code base.
Happy coding :)

Original article

Top comments (0)