Using Combine to simulate async / await in Swift

 

The Silver Pavilion (Ginkaku-ji), Kyoto Japan

A few years ago, I read Chris Lattner’s Swift Concurrency Manifesto, a concrete proposal to bring async/await support to Swift. async/await is an elegant way to deal with concurrency and to organize concurrent code. It’s already in popular languages like Python and JavaScript. Instead of writing a series of nested callbacks to deal with concurrent processes, you write your code almost as if it were running synchronously. The async keyword indicates code that will run concurrently, and the await keyword indicates a dependency on asynchronous code. If an error is triggered during an asynchronous operation, the entire chain of computations is canceled and an error is returned to the original caller.

It’s nice!

iOS programming can be quite callback heavy, so the proposal to bring this kind of programming to Swift was very exciting. A single level of callbacks is usually no big deal, but two or more nested callbacks quickly become a mess. It can be difficult to make sure you’re handling all potential error paths, and it’s hard to do things where one callback triggers multiple concurrent processes and then waits for them. In general, it’s not easy to compose callbacks into a single pipeline. Even if you manage to pull it off, it can be difficult to catch bugs and can leave you with brittle code that’s difficult to change or test.

The Proposal

The manifesto’s motivating example is a classic example of callback hell:

// from https://gist.github.com/lattner/31ed37682ef1576b16bca1432ea9f782
func processImageData2(completionBlock: (result: Image?, error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
completionBlock(nil, error)
return
}
loadWebResource("imagedata.dat") { imageResource, error in
guard let imageResource = imageResource else {
completionBlock(nil, error)
return
}
decodeImage(dataResource, imageResource) { imageTmp, error in
guard let imageTmp = imageTmp else {
completionBlock(nil, error)
return
}
dewarpAndCleanupImage(imageTmp) { imageResult in
guard let imageResult = imageResult else {
completionBlock(nil, error)
return
}
completionBlock(imageResult)
}
}
}
}
}

As the proposal points out, there are a number of problems with this:

  • It’s very hard to read
  • The indentation pushes the code all the way to the right of the screen (the pyramid of doom)
  • You could very easily miss any of those error-handling guard’s
  • It’s cluttered with lots of boilerplate that doesn’t have much to do with the problem you’re solving.

Lattner’s proposal would let you do this instead:

// from https://gist.github.com/lattner/31ed37682ef1576b16bca1432ea9f782
func loadWebResource(_ path: String) async -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async -> Image
func dewarpAndCleanupImage(_ i : Image) async -> Image
func processImageData1() async -> Image {
let dataResource = await loadWebResource("dataprofile.txt")
let imageResource = await loadWebResource("imagedata.dat")
let imageTmp = await decodeImage(dataResource, imageResource)
let imageResult = await dewarpAndCleanupImage(imageTmp)
return imageResult
}

This is obviously much nicer, but the proposal was never integrated into Swift. In fact, it never even entered the Swift evolution process. Since I first read the manifesto, I’ve been following Swift development closely, hopeful that async/await would soon be included in the language and would let me reorganize some of the messiest and hardest-to-debug parts of my applications. Eventually, I gave up and moved on.

Combine to the Rescue

IMG_5463

Recently, I was spending some time learning Combine and I realized something that I think most Swift programmers don’t realize:

Swift already has async and await: they’re just not spelled that way, and they require iOS 13.

Apple’s new Combine framework lets you create very flexible, reliable wrappers around any callback-based API. You can then use its standard operators (like map, filter, collect, and more) to shape a pipeline of concurrent operations. Combine pipelines have error handling baked in and they let you write your code almost as if it was running sequentially.

Additionally, it’s very easy to test different scenarios when you have a Combine pipeline: instead of running your actual callback code (which might access the Internet or the device camera, for example), you can push predictable values into the pipeline and see if the rest of your code works properly.

When you wrap existing asynchronous API’s, you’ll still have some callback-based code, but it will be limited to a single level, and confined to one function that you’ll need to audit to make sure all return paths either return a value or signal an error. You can then reuse these wraps anywhere in your project. The Combine operators you’ll apply to these futures are well-tested and well-defined by Apple, so there are fewer places your code can go wrong or behave unpredictably.

Wrapping an API

A full introduction to Combine is beyond the scope of this post (but see the Further Reading section below if you want to learn more). For our purposes, the most important aspect of Combine  is that it  includes two operators that can help us easily encapsulate a callback-based API and give it a much, much nicer publisher-based API: Future and flatMap.

Using these tools, we can re-write Lattner’s example like so:

func loadWebResource(_ path: String) -> AnyPublisher<Resource, Error>
func decodeImage(_ r1: Resource, _ r2: Resource) -> AnyPublisher<Image, Error>
func dewarpAndCleanupImage(_ i : Image) -> AnyPublisher<Image, Error>
func processImageData1() -> AnyPublisher<Image, Error> {
loadWebResource("dataprofile.txt").zip(loadWebResource("imagedata.txt")).flatMap { (dataResource, imageResource) in
decodeImage(dataResource, imageResource)
}
.flatMap { imageTmp in
dewarpAndCleanup(imageTmp)
}
.eraseToAnyPublisher()
}

In order to make this work, we need to convert callback-based operations into Combine publishers. Apple’s Future publisher lets us do just that. It creates what Joseph Heck calls a “one-shot publisher” – it runs, publishes a value if there’s no error, then it completes. Each loading function now publishes a value or returns an error. The bodies of these functions would create and return Future publishers that would invoke asynchronous API’s.

flatMap accepts a value from a publisher (for example, a Future) and returns a new Publisher. We can easily chain publishers together this way – the value that comes into flatMap can be used to invoke another Future (a publisher), which then becomes part of the Combine pipeline.

Actually, Combine has even let us improve on the original: the example code in the manifesto doesn’t load the image resource until the data resource has already finished – but since there’s no dependency between the two, our Combine snippet simultaneously fetches the image data and the data profile using the built-in zip operator.

The Future API is based on promises, so you can easily create a future from any kind of asynchronous API. Just invoke the promise the Future creates for you with either a success value or an error when your callback runs.

func loadWebResource(filename: String) -> AnyPublisher<Data, Error> {
URLSession.shared.dataTask(with: URL(string: BASE_URL + filename)!) { data, response, error in
guard error == nil else {
promise(.failure(error!))
}
promise(.success(data))
}.eraseToAnyPublisher()
}
view raw Future Wrapper.swift hosted with ❤ by GitHub

The manifesto code is a simple example, but you can generalize  this to any of the many asynchronous API’s offered by iOS. Additionally, in this particular case, the custom future isn’t even necessary, because URLSession supports Combine natively:

func loadWebResource(filename: String) -> AnyPublisher<Data, Error> {
URLSession.shared.dataTaskPublisher(for: URL(string: BASE_URL + filename)!)
.eraseToAnyPublisher()
}
func processImageData1() -> AnyPublisher<Image, Error> {
loadWebResource("dataprofile.txt").zip(loadWebResource("imagedata.txt")).flatMap { (dataResource, imageResource) in
decodeImage(dataResource, imageResource)
}
.flatMap { imageTmp in
dewarpAndCleanup(imageTmp)
}
.eraseToAnyPublisher()
}
view raw URL Session.swift hosted with ❤ by GitHub

Conclusion

 

The Silver Pavilion (Ginkaku-ji), Kyoto Japan

You can generalize this technique to any asynchronous API. Just create a Future, invoke your asynchronous API and configure a callback, then be sure to call the promise with the result of the API call. You can chain multiple Future’s together using flatMap, or you can combine or transform the results using Combine’s long list of operators. A great example is the very convenient receive operator to quickly ensure that you’re running on the main thread.

I hope this helps you escape from callback hell! The only catch to keep in mind is that Combine requires iOS 13.

Further Reading

If you’d like to learn more about Combine, I recommend Joseph Heck’s Using Combine and Matt Neuberg’s Understanding Combine.

Photos: Arashiyama Bamboo Grove, Kyoto, Japan; Avebury Stone Circle, Avebury, England; the roof of the Silver Pavilion (Ginkaku-ji), Kyoto, Japan

6 thoughts on “Using Combine to simulate async / await in Swift

  1. Jacob Kelly says:

    Nice! Worth noting that for those who are looking to make it even shorter (whether it helps or hurts the clarity of the code is questionable) you can do:

    loadWebResource(“dataprofile.txt”)
    .zip(loadWebResource(“imagedata.txt”))
    .flatMap(decodeImage)
    .flatMap(dewarpAndCleanup)
    .eraseToAnyPublisher()

  2. Really nice blog post!

    I have tried a similar approach previously using ReactiveKit and the straightforward path you describe is great. In my experience the trouble begins when the path is not straight but have some conditionals and error handling.

    Lets say you have a branch midway depending on the returned data from one of the former steps and different paths are taken. Lets say that any of the steps could return an error, each one a different one and any error requires you to stop and return an error.

    With C# async/await that code would still becomes liniear to read but my resulting ReactiveKit is much harder to read.

    Do you have any ideas on how to use extend your Combine example with branching or error handling?

  3. moreindirection says:

    At any point in the chain, a publisher can return an error instead of its expected value. This will stop the chain and return that error for the entire pipeline.

  4. I am not talking about stopping the chain but about multiple equally good, non-error branches.

    Lets say first step in the chain is retrieving a json file from a server. Then depending on the contents of that json we either need to do actions A, B and C or do actions B and D.

  5. aswath says:

    I think what nicolai was saying is something like this: In case a user needs a bearer token to fetch from an API, and I have a locally stored token which would expire at a particular time, I’d check if the token is still valid and if it is I would use that which is action A, otherwise I would need to refresh it which would be action B which would require a network fetch. And it would be a simple matter in async-await syntax. There is not an easy equivalent in Combine, is there?

  6. moreindirection says:

    @nicolai @aswatch This is indeed possible – otherwise, this wouldn’t be a good substitute for async / await. 🙂

    You can return different publishers inside a flatMap’s closure to pass control to other parts of your program. These publishers can then launch asynchronous activities and publish a value when they have a result ready.

    Here’s a gist showing a rough, untested example of this. Note that `Just` and `Failure` are built-in Combine publishers.

    URLSession.shared.dataTaskPublisher(for: URL(string: BASE_URL + filename)!)
    .flatMap { data, response in
    let httpURLResponse = response as! HTTPURLResponse
    switch httpURLResponse.statusCode {
    case 404:
    return Just("Unable to find file").eraseToAnyPublisher()
    case (300..<399):
    // this is a function that returns a publisher – it can make network requests, whatever
    return processRedirect(data)
    case (200..<299):
    // now process data normally
    default:
    return Failure(errorType).eraseToAnyPublisher()
    }
    func processRedirect(redirectData: Data) -> AnyPublisher<String, Error> {
    //
    }

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s