Connecting async/await to other Swift code
Discover page available: ConcurrencySwift 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.
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 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() {
Task {
do {
let document = try await loader.loadDocument(withID: documentID)
display(document)
} catch {
display(error)
}
}
}
private func display(_ document: Document) {
...
}
private func display(_ error: Error) {
...
}
}
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
continuation.resume(with: result)
}
}
}
}
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 async sequences and streams), if all that we’re looking to do is to await a single asynchronous output value from a Combine pipeline, then there are a few quick ways that we could make that happen.
One way would be to use the same continuation
-based technique that we used earlier in order to extend Combine’s Publisher
protocol with a singleOutput
method that’ll resume our continuation with the first value that was emitted by that 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 singleOutput() 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’re working on an app that uses iOS 15, macOS Monterey, or any of Apple’s other 2021 operating systems as its minimum deployment target, then we could also choose to implement a simpler version of the above extension — which uses the Publisher
protocol’s new values
property to convert the current publisher into an async sequence:
extension Publisher {
func singleOutput() async throws -> Output {
for try await output in values {
// Since we're immediately returning upon receiving
// the first output value, that'll cancel our
// subscription to the current publisher:
return output
}
throw Publishers.MissingOutputError()
}
}
With either of the above two extensions in place, 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 our new singleOutput
method:
struct CommentLoader {
...
func loadCommentsForDocument(
withID id: Document.ID
) -> AnyPublisher<[Comment], Error> {
...
}
}
...
let comments = try await loader
.loadCommentsForDocument(withID: documentID)
.singleOutput()
Of course, like its name implies, our new singleOutput
method will only return the first output 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).
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!