Why use async/await vs completion handlers?

I'm pretty sure I'm missing something completely obvious, but I can't see what. I watched WWDC session, read the Swift evolution blog, and they all make sense, but still it doesn't click for me. Please help me out here :) .

I'm diving into adopting the 'new' async/await style of coding (I know, it's old news at this point, but I could only get to it now), and so I'm all pumped to get my code to go eleven and therefore I wrote a small data-downloader class. It has one method, well two: one oldskool function with a completionHandler, and one new style async/await one.

When using the oldskool one, it works as everyone would expect:

print(1)
dataFetcher.fetchSomeData
{
    print(2)
    let data = $0
    // process data ...
    print(3)
}
print(4)

The output is, unsurprisingly:

1
4
2
3

Now, when I use my new style function:

let data = await dataFetcher.fetchSomeData()
//  process data ...

Xcode gives me an error:

'async' call in a function that does not support concurrency

That makes sense, I am calling this in the viewDidLoad() method of a UIViewController subclass. Can't mark viewDidLoad() as await, as super's implementation is not async. No problem, let me wrap it in Task:

print(1)
Task
{
    print(2)
    let data = await dataFetcher.fetchSomeData()
    // process data ...
    print(3)
}
print(4)

No errors, and this code works exactly as expected, the output is:

1
4
2
3

So now I am wondering: why take the effort of changing/adding code for async/await style function that ultimately end up requiring exactly the same amount of code, and is exactly as non-linear as completionHandlers?

Note that the dataFetcher only has one property, an instance ofURLSession, so I am also not even managing my own queues or threads in the oldskool method vs the new one. They just wrap URLSession's functions:

func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask

and

func download(for request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (URL, URLResponse)

Is async/await useful only with SwiftUI maybe? What am I missing here? Please help me see the light.

Answered by DTS Engineer in 724093022

I think you have identified the benefits, but let me restate them in slightly different words:

  1. When you use completion handlers, especially for multiple successive completions, you end up with indented code getting deeper and deeper nesting in braces. This is familiarly known as the "pyramid of doom". Using async/await clarifies that your code is actually sequential, which is a big win for code readability.

  2. Using Task { … } to transition from a sync to an async context makes it clear that viewDidLoad returns without waiting for the task to run. You already know this — which is why you expect the order 1, 4, 2, 3 — but it's a mental leap that developers new to Swift have to make.

It's also worth adding that adopting async/await also opens up the world of Swift actors, which are the language's way of helping you avoid data races and other concurrency problems.

Yes, you miss something. With such a simple example, little interest in fact. But real case, with multiple calls, are different.

There are tons of articles trying to answer your question. I found this one interesting.

https://www.hackingwithswift.com/articles/233/whats-new-in-swift-5-5

Hope that helps.

Thanks for sharing. I think that article made me see 2 possible benefits, correct me if I’m wrong:

  1. if there is code that never makes its way up to a synchronous context, code will look more linear. Example: downloading some data and persisting it, without directly surfacing the new content in the ui
  2. code that does make its way into a sync context (like my example), will have only a sinlge place where the code is non-linear. Everything behind that function call can be impkemented wit async/await, simplifying the underlying code possibly a lot

Is that about right? Anything else I am missing?

Accepted Answer

I think you have identified the benefits, but let me restate them in slightly different words:

  1. When you use completion handlers, especially for multiple successive completions, you end up with indented code getting deeper and deeper nesting in braces. This is familiarly known as the "pyramid of doom". Using async/await clarifies that your code is actually sequential, which is a big win for code readability.

  2. Using Task { … } to transition from a sync to an async context makes it clear that viewDidLoad returns without waiting for the task to run. You already know this — which is why you expect the order 1, 4, 2, 3 — but it's a mental leap that developers new to Swift have to make.

It's also worth adding that adopting async/await also opens up the world of Swift actors, which are the language's way of helping you avoid data races and other concurrency problems.

Yes right, thank you. Your first point is indeed a better phrased version of mine. The 2nd point make sense too, and did not occur to me. I will dive into Swift actors, and look forward to learning how these will help me simplify and strengthen my code. Thanks for going along with me and help me structure my thoughts!

I've been wondering the same thing lately, and I think the answer depends on the complexity of the problem.

In the example you provided, there might be little (or no) benefit using async/await vs completion handlers. However, consider the following example:

func fetchAllData(firstId: String, completion: @escaping (Result<ImportantData, Error>) -> Void) { 
    fetchInitialData(firstId) { [weak self] result in 
        switch result {
        case .success(let initialData): 
            self?.fetchSecondaryData(initialData.id) { result in 
                switch result { 
                case .success(let secondaryData):
                    self?.fetchFinalData(secondaryData.id) { result in 
                        switch result { 
                        case .success(let imporantData): completion(.success(importantData))
                        case .failure(let finalError): completion(.failure(finalError)
            }
                case .failure(let secondError): completion(.failure(secondError)
                }
        case .failure(let firstError): completion(.failure(firstError)
        }
    }
}

A silly example, to be sure, but I'm sure there are times when developers need to use multiple asynchronous methods in a synchonrous fashion (if that makes sense). each subsequent call requires info from the previous. And the above example is assuming no other work aside from another async call is being made on each success. Before async/await, that nightmare above was probably a 'good' solution.

The same code using async/await:

func fetchAllData(firstId: String) async throws -> ImportantData { 
    let initialData = try await fetchInitialData(firstId)
    let secondaryData = try await fetchSecondaryData(initialData.id)
    
    return try await fetchFinalData(secondaryData.id)
}

It's almost comical how simple async/await can make these complex nested completion handlers. And even if you wanted to keep the outer-most completion handler, you can still clean things up with async/await but using a Task inside of the outermost method alongside a do/catch:

func fetchAllData(firstId: String, completion: @escaping (Result<ImportantData, Error>) -> Void) { 
    Task {
        do {
            let initialData = try await fetchInitialData(firstId)
            let secondaryData = try await fetchSecondaryData(initialData.id)
            let importantData  = try await fetchFinalData(secondaryData.id)

            completion(.success(importantData)) 
        } catch { 
            completion(.failure(error))
        }
    }
}

In the last example, you'll need to decide when to switch back to the MainActor (MainThread) if any UI work is being done with the ImportantData. Ideally Threading concerns should be isolated to a specific file, but I don't think it would be too ridiculous to use await MainActor.run { completion(/*result here*/) } on the final completions if necessary.

I have a follow up question - I see many Apple APIs have auto-generated async counterparts of their old completion block/escaping closure version. Aside from the "sequential" vs "pyramid" styling differences, are there any other underlying differences, such as async/await is based upon Swift Concurrency vs escaping closure is using GCD/Dispatch?

The reason I ask is that sometimes in our codebase people accidentally use the escaping closure version of the API in synchronous context, such as onAppear modifier of a view, while ideally the async version should really be used in a task modifier.

  • Does using completion-block based API cause performance degradation or mess up the Swift Concurrency model?
  • Are all the APIs with async wrappers have been bridged using continuation?

Thanks.

Fundamentally, in either case, you're just letting the system get on with other work until something is ready to be processed, then picking up where it left off. There shouldn't be much difference in performance between calling an asynchronous function and passing a callback.

That said, you can do more advanced stuff with Swift Concurrency, like async let, which can improve performance, by running things in parallel that would otherwise be forced to take it in turns. That could make a big difference in some cases.

are there any other underlying differences, such as async/await is based upon Swift Concurrency vs escaping closure is using GCD/Dispatch?

No. When you use an Objective-C framework from Swift, the Swift compiler has a bunch of complex logic to import the Objective-C declarations in a way that’s palatable to Swift. For example, it adds glue so that you can access an NSString property as a Swift String value. One of those transformations automatically adds an async method for each Objective-C method that ‘looks like’ it has a completion handler.

This process is completely automatic. The compiler just creates glue based on the Swift continuation mechanism [1]. So, the underling implementation remains in Objective-C, and the compiler is simply adding this glue to make your life easier.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] It’s been a while since I looked at the implementation, so I’m not sure whether it uses the CheckedContinuation type or the UnsafeContinuation one.

Why use async/await vs completion handlers?
 
 
Q