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()
}

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()
}

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