Articles, podcasts and news about Swift development, by John Sundell.

Connecting async/await to other Swift code

Published on 21 Jun 2021

Swift 5.5’s new suite of concurrency features definitely played a major role at this year’s edition of WWDC. Particularly, the newly introduced async/await pattern could not just be seen in the more Swift-focused sessions and announcements, but all over the new APIs and features that were unveiled at the conference.

While async/await is very likely to become the primary way to write asynchronous code on Apple’s platforms going forward, like with all major technology transitions, it’s going to take a while for us to get there. So, in this article, let’s explore a few ways to “bridge the gap” between the new world of async/await and other kinds of asynchronous Swift code.

Note that this article will cover APIs and language features that are currently in beta as part of Xcode 13.

Essential Developer

Essential Developer: If you’re a mid/senior iOS developer who’s looking to improve both your skills and your salary level, then join this 100% free online crash course, starting on August 2nd. Learn how to apply truly scalable iOS app architecture patterns through a series of lectures and practical coding sessions.

What’s async/await?

Let’s start with a quick recap what async/await actually is. I’ll go into much more detail in future articles as I gain more hands-on experience with this new pattern and the way it’s integrated into Apple’s SDKs, but — at the most basic level — async/await enables us to annotate asynchronous functions (or computed properties) with the async keyword, which in turn requires us to use the await keyword when calling them. At that point, the system will automatically manage all of the complexity that’s involved in waiting for such an asynchronous call to complete, without blocking any other, outside code from executing.

For example, the following DocumentLoader has an async-marked loadDocument method, which uses Foundation’s URLSession API to perform an asynchronous network call by awaiting the data that was downloaded from a given URL:

struct DocumentLoader {
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()

    func loadDocument(withID id: Document.ID) async throws -> Document {
        let url = urlForForLoadingDocument(withID: id)
        let (data, _) = try await urlSession.data(from: url)
        return try decoder.decode(Document.self, from: data)
    }

    ...
}

So async-marked functions can, in turn, call other asynchronous functions just by prefixing those calls with the await keyword. At that point, the local execution will be suspended until that nested await completes, again without blocking any other code from being executed in the meantime.

Calling async functions from a synchronous context

But then the question is — how do we call an async-marked function from within a context that’s not itself asynchronous? For example, what if we wanted to use the above DocumentLoader within something like a UIKit-based view controller? That’s where tasks come in. What we’ll need to do is to wrap our call to loadDocument in an async closure, which in turn will create a Task within which we can perform our asynchronous calls — like this:

class DocumentViewController: UIViewController {
    private let documentID: Document.ID
    private let loader: DocumentLoader
    
    ...

    private func loadDocument() {
        async {
            do {
                let document = try await loader.loadDocument(withID: documentID)
                display(document)
            } catch {
                display(error)
            }
        }
    }

    private func display(_ document: Document) {
        ...
    }

    private func display(_ error: Error) {
        ...
    }
}

Note that the above closure-based async API is likely going to be turned into a proper Task initializer in an upcoming Xcode 13 beta. At that point, I’ll update this article as soon as possible.

What’s really neat about the above pattern is that we can now use Swift’s default do/try/catch error handling mechanism even when performing asynchronous calls. We also no longer have to do any kind of “weak self-dance” in order to avoid retain cycles, and we don’t even need to manually dispatch our UI updates on the main queue, since that’s now being taken care of for us by the main actor.

Retrofitting existing APIs with async/await support

Next, let’s take a look at how we can go the other way — that is, how we can make some of our existing asynchronous code compatible with the new async/await pattern.

As an example, let’s say that our app contains the following CommentLoader type that lets us load all of the comments that have been attached to a given document using a completion handler-based API:

struct CommentLoader {
    ...

    func loadCommentsForDocument(
        withID id: Document.ID,
        then handler: @escaping (Result<[Comment], Error>) -> Void
    ) {
        ...
    }
}

Initially, it might seem like we’ll need to significantly change the above API in order to make it async/await-compatible, but that’s not actually the case. All that we really have to do is to use the new withCheckedThrowingContinuation function to wrap a call to our existing method within an async-marked version of it — like this:

extension CommentLoader {
    func loadCommentsForDocument(
        withID id: Document.ID
    ) async throws -> [Comment] {
        try await withCheckedThrowingContinuation { continuation in
            loadCommentsForDocument(withID: id) { result in
                switch result {
                case .success(let comments):
                    continuation.resume(returning: comments)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

Note that the continuation that’s passed into our wrapping closure can only be called once. If we accidentally call it twice, that’ll result in a fatal error. In this case, there’s zero chance of that happening, though, since our completion handler is only called once, but it’s definitely something that’s worth keeping in mind when using this technique. To learn more, check out my friend Vincent’s WWDC article about this topic.

With the above in place, we can now easily call our loadCommentsForDocument method using the await keyword, just like when calling system-provided asynchronous APIs. For example, here’s how we could update our DocumentLoader to now automatically fetch the comments for each document that it loads:

struct DocumentLoader {
    var commentLoader: CommentLoader
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()

    func loadDocument(withID id: Document.ID) async throws -> Document {
        let url = urlForForLoadingDocument(withID: id)
        let (data, _) = try await urlSession.data(from: url)
        var document = try decoder.decode(Document.self, from: data)
        document.comments = try await commentLoader.loadCommentsForDocument(
    withID: id
)
        return document
    }
}

What’s really nice about async/await is that even as we add additional, nested calls, our code doesn’t really grow much in complexity. It keeps looking more or less just like good old fashioned synchronous code, plus a few await keywords.

Adapting single-output Combine publishers

Finally, let’s also take a look at a way to make certain Combine-powered code async/await-compatible as well. While Swift’s new concurrency system includes other, more “Combine-like” ways to emit dynamic streams of values over time (such as AsyncSequence, and the upcoming AsyncStream), if all that we’re looking to do is to await a single asynchronous result from a Combine pipeline, then here’s a way that we could make that happen in a quite lightweight manner.

Using the same continuation-based technique that we used earlier, let’s extend Combine’s Publisher protocol with a singleResult method that’ll resume our continuation with the first value that was emitted by a given publisher. We’ll also use Swift’s closure capturing mechanics to retain our Combine subscription’s AnyCancellable instance until our operation has been completed — like this:

extension Publishers {
    struct MissingOutputError: Error {}
}

extension Publisher {
    func singleResult() async throws -> Output {
        var cancellable: AnyCancellable?
        var didReceiveValue = false

        return try await withCheckedThrowingContinuation { continuation in
            cancellable = sink(
                receiveCompletion: { completion in
                    switch completion {
                    case .failure(let error):
                        continuation.resume(throwing: error)
                    case .finished:
                        if !didReceiveValue {
                            continuation.resume(
                                throwing: Publishers.MissingOutputError()
                            )
                        }
                    }
                },
                receiveValue: { value in
                    guard !didReceiveValue else { return }

                    didReceiveValue = true
                    cancellable?.cancel()
                    continuation.resume(returning: value)
                }
            )
        }
    }
}

If we now imagine that the CommentLoader that we used earlier instead had a Combine-powered API (rather than a closure-based one), then we could now easily use async/await to call it using the above extension:

struct CommentLoader {
    ...

    func loadCommentsForDocument(
        withID id: Document.ID
    ) -> AnyPublisher<[Comment], Error> {
        ...    
    }
}

...

let comments = try await loader
    .loadCommentsForDocument(withID: documentID)
    .singleResult()

Of course, like its name implies, our new singleResult method will only return the first value that a given Combine publisher emitted, so it should only be used on publishers which aren’t expected to produce multiple values over time (unless we’re only interested in the very first value).

We’ll take a much closer look at how we can bridge the gap between Combine, async/await, and the rest of Swift’s new concurrency system in upcoming articles.

Support Swift by Sundell by checking out this sponsor:

Essential Developer

Essential Developer: If you’re a mid/senior iOS developer who’s looking to improve both your skills and your salary level, then join this 100% free online crash course, starting on August 2nd. Learn how to apply truly scalable iOS app architecture patterns through a series of lectures and practical coding sessions.

Conclusion

Async/await offers an exciting new way to write asynchronous code in Swift, and is likely to become a very key part of Apple’s overall API design going forward. However, since it’s not backward compatible with older operating system versions, and since we’ll very likely also need to interact with other code that doesn’t yet use async/await, finding ways to connect such code with Swift’s new concurrency system is going to be incredibly important for many teams.

Hopefully, this article has given you a few ideas on how to do just that, and if you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.

Thanks for reading!