Wrapping Existing Async and Closure Based code in async-await structure (Advanced Swift Topic)

If you haven't yet read couple of my posts that introduce a new async-await construct in Swift, below are the links you can follow to know more about it
How to use async/await in Swift using Examples
Using async/await APIs in Swift 5.5. Examples of using async await and concurrent programming. How to write structured concurrency. Best tutorial
Running async network operations in parallel on iOS using async-await
How to run async network operations fast and parallel on iOS using async wait in Swift 5.5 with examples. Send parallel iOS requests faster Swift

With the introduction of async-await construct in Swift 5.5, it's easier to structure your code with simply async-await keyword and eliminate bug-prone delegate or closure-based callback mechanism. This has the added benefit of not only making your code readable but the resulting code is also cleaner and free from bugs.

The Swift community strongly recommends using async-await construct for new projects as well as wherever iOS and Swift versions allow, refactor the legacy async code handling to use new APIs. In order to make it easy for developers to migrate to these new APIs, Apple has provided async-await variants of many of their existing APIs so that the developers don't have to write it on their own.

However, there are still some APIs that haven't been converted to use async-await yet. In such cases, what do you do? Don't convert them to async-await at all? What are our options? How do we handle this legacy code then? Is there any support Apple provides to make this migration easy?

The answer is async Alternatives and Continuations. With these APIs, you can wrap the existing legacy asynchronous code - Whether it is closure-based or delegate-based to use the async-await construct so that the caller won't know they use legacy code under the hood. Let's see how to use them with examples - One with closure and the other with delegate callbacks.

Converting closure-based legacy asynchronous code to use async-await construct

I have the following code that uses a closure-based API to perform the asynchronous operations. Since this is a third-party API, I cannot modify it by hand to directly use async-await, but I also don't want to wait forever until someone converts this API to use modern syntax.


override func viewDidLoad() {
	super.viewDidLoad()
    
	performExpensiveOperation(id: 100) { output in
        print("Operation successful with result \(output)")
	}        
}

func performExpensiveOperation(id: Int, completion: (String) -> Void) {
    ThirdPartyUtility.performOperation(with: id, completion: completion)
}
Please note how performOperation resides in the third-party code and there is no way for client to directly modify it by hand

With the Swift API, withCheckedThrowingContinuation we can easily add a wrapper to use async-await on top of closure-based async code.

withCheckedThrowingContinuation API returns a continuation object of type CheckedContinuation. This object provides a resume  function into which we can either place a result of the successful operation or an error if the operation fails.

resume API also provides a missing link to resume the suspended awaitable object returned by the withCheckedThrowingContinuation API. As soon as the closure returns either an error or a result, we call the resume method which in turn returns the result or the error back to the waiting caller of async function.

With these concepts clear, let's write a wrapper around the legacy function to use async-await API to perform async tasks,


func performExpensiveOperation(id: Int) async throws -> String {
    return try await withCheckedThrowingContinuation { continuation in
        ThirdPartyUtility.performOperation(with: id) { output, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else {
            	continuation.resume(returning: output)
            }
        }
    }
}


override func viewDidLoad() {
    super.viewDidLoad()
    
    Task(priority: .medium) {
        do {
            let output = try await performExpensiveOperation(id: 100)
        } catch {
            print("Error Occurred \(error.localizedDescription)")
        }
    }
}

Converting delegate-based legacy asynchronous code to use async-await construct

Using async-await construct is not limited to legacy closure-based APIs. You can also use them to replace legacy delegate-based APIs. Let's take a look at a similar legacy example as before, but this example uses delegates instead of closures to perform the async operations.


protocol ExpensiveOperationPerformable: AnyObject {
    func didSucceed(with output: String)
    func didFail(with error: Error)
}

class ViewController: UIViewController {
	override func viewDidLoad() {
        super.viewDidLoad()
    
        performExpensiveOperation(id: 100)
    }
    
    func performExpensiveOperation(id: Int) {
        let thirdPartyUtility = ThirdPartyUtility()
        thirdPartyUtility.delegate = self
        thirdPartyUtility.performOperation(with: id)
    }    
}


extension ViewController: ExpensiveOperationPerformable {
    func didSucceed(with output: String) {
        print("Succeeded with output \(output)")
    }
    
    func didFail(with error: Error) {
        print("Failed with error \(error.localizedDescription)")
    }
}

In the above example, before calling performOperation method in third-party utility, we set up the current class as its delegate. Once the third-party finishes doing heavy work, it will call the delegate methods - Either didSucceed or didFail depending on whether the data loading operation succeeded or failed.

Similar to the previous example, we will use withCheckedThrowingContinuation API to migrate to using async-await. Since the delegate callback is scattered in the file, we will store the object of type CheckedContinuation in the local variable and use its resume method to resume the suspended async task with either error or a returned result when respective delegate methods are called after the operation succeeds.


class ViewController: UIViewController {

    var activeContinuation: CheckedContinuation<String, Error>?
    ....
    ...
    ..
    
	override func viewDidLoad() {
    	super.viewDidLoad()
    
	    Task(priority: .medium) {
    	    do {
        	    let result = try await performExpensiveOperation(id: 100)
	        } catch {
    	        print("Error Occurred \(error.localizedDescription)")
	        }
    	}
    }
    
    func performExpensiveOperation(id: Int) async throws -> String {
        let thirdPartyUtility = ThirdPartyUtility()
        
        return try await withCheckedThrowingContinuation { continuation in
            self.activeContinuation = continuation
            thirdPartyUtility.delegate = self
            thirdPartyUtility.performOperation(with: id)
        }
    }
}

extension ViewController: ExpensiveOperationPerformable {
    func didSucceed(with output: String) {
        activeContinuation?.resume(returning: output)
    }
    
    func didFail(with error: Error) {
        activeContinuation?.resume(throwing: error)
    }
}

Caveats

Although this is an extremely useful API to convert legacy APIs to use modern syntax, there are a few caveats you should be mindful of before starting using it.

CheckedContinuation instance must call the resume with the result or the error of the operation

withCheckedThrowingContinuation method returns an instance of CheckedContinuation. When the completion handler completes with the result or the delegate methods are called either with the result or the error of the operation, we must call the result method associated with CheckedContinuation object. If this method is not called, the calling function will suspend forever blocking the execution and wasting system resources.

Make sure the resume method is called under any circumstances. If you aren't sure about the result value to pass to this method, either pass the default value or raise an error. If async method returns without calling resume on CheckedContinuation object, the console will throw a warning with the following message,


SWIFT TASK CONTINUATION MISUSE: <your_function_name> leaked its continuation!
<project_name>[9735:622352] SWIFT TASK CONTINUATION MISUSE: <your_function_name> leaked its continuation!
If you see a message like this, review the code again and make sure you're calling resume on CheckedContinuation object in all the code paths

Don't call the resume method multiple times

In order to prevent app data from corrupting, you must not call resume method on CheckedContinuation object more than once. If you end up calling this method multiple times from the same code block, the Swift runtime will throw a fatal error at the second entry point halting the program execution.

Referring to our delegate example above, we can prevent this from happening by setting activeContinuation object to nil as soon as the resume method is called.


extension ViewController: ExpensiveOperationPerformable {
    func didSucceed(with output: String) {
        activeContinuation?.resume(returning: output)
        activeContinuation = nil
    }
    
    func didFail(with error: Error) {
        activeContinuation?.resume(throwing: error)
        activeContinuation = nil
    }
}

Summary

So that was all about a painless way to convert legacy asynchronous code to use async-await API. My initial qualms and excuses about using this API were how am I doing to make the legacy code outside of my control to use this new construct.? But as soon as I came to know about this API, I breathed a sigh of relief and it pulverized all the doubts.

It's extraordinary and a bit surprising to see Apple taking care of this for developers and pushing them to use new API. The interface is straightforward, the readability is great, and the WWDC talk was full of related examples and deep explanations on how to use it and its benefits.

While you may be tempted to use this API, also pay attention to a couple of caveats I mentioned at the end of this article. Make sure to always call the resume method and call it just once for a given case of either success or error. In case you still miss them, Apple still warns you about failing to follow these guidelines so they won't make it in the production code.

That's all from me on this topic. If you still have any questions, feedback, or comment about this article, feel free to reach out to me on Twitter @jayeshkawli.


Reference:

Meet async/await in Swift - WWDC21 - Videos - Apple Developer
Swift now supports asynchronous functions — a pattern commonly known as async/await. Discover how the new syntax can make your code...