/ swift

Async/Await and the Future of Combine

Swift 5.5 is here with Xcode 13 Beta and with it comes my favorite new addition to Swift: Async/Await.

Async/await is a high level, structured model for concurrency in Swift that allows you to write expressive async code in shockingly little ceremony.

A code sample is probably best for this, so consider this example where we want to fetch an image showing the current weather conditions for the user’s location.

To do this requires multiple steps:

  • First get the user’s location. If that gives an error, fail early.
  • Then use that to get the weather conditions
  • Look in that response to get an image URL for the current condition

The callback way

Using traditional callbacks we might have these methods:

func getLocation(completion: @escaping (Result<CLLocation, Error>) -> Void) {
   // ...
}

func getWeather(for location: CLLocation, completion: @escaping (Result<WeatherCondition, Error>) -> Void) {
   // ...
}

Putting these together gives you:

getLocation { result in 
    switch result {
    case .failed(let error):
       // do something with location error

    case .success(let location):
       getWeather(for: location) { result in 
		    switch result {
    		case .failed(let error):
       		// do something with weather error

		    case .success(let conditions):
             // fetch image
				let imageTask = URLSession.shared.dataTask(with: conditions.imageURL) { (data, response, error) in
               // you get the idea
      	}
		}	  
	 }
}

Nested completion callbacks each with their own failure path is a giant pile of yuck. If we fail to call back with a failure from a previous step we might end up with an application that seems to get stuck (maybe showing a spinner forever). There’s nothing the compiler does here to help us. And if we have to ensure to call back any completion handler on a specific queue, there’s quite a lot of boilerplate which makes the above code even worse.

Of course, Combine makes this much nicer, but more on that in a minute.

The Async/Await Way

Now with Swift 5.5 this gets a whole lot better.

func userLocation() async throws -> CLLocation {
    // ...
}

func weatherConditions(for location: CLLocation) async throws -> WeatherConditions {
   // ...
}

Notice how these functions return actual values. They also indicate that they are async and they can throw errors.

There’s also lots of built-in SDK functions that have been adapted to be async, like URLSession.data(for:) .

Putting this all together:

let location = try await userLocation()
let conditions = try await weatherConditions(for: location)
let (imageData, response) = try await URLSession.shared.data(from: conditions.imageURL)
if (response as? HTTPURLResponse)?.statusCode == 200 {
    let image = UIImage(data: imageData)
} else {
    // failed
}

Notice now the code reads from top to bottom. When we get to the await keyword, our execution is suspended without blocking the thread, so the system is free to do other work.

There’s a lot of nuance here that I’m hand-waving over, but the key point is this is objectively better:

  • It’s much easier to write
  • It’s much easier to read
  • The compiler can enforce we handle all execution paths and errors

So this looks like the future, right? What about Combine?

What about Combine?

Back in iOS 13 we saw the release of Combine, Apple’s take on functional reactive programming. With Combine you can leverage Publishers that produces values over time, and then transform those values using Operators.

Combine provides a ton of power, but comes at a steep learning curve. This is what led me to create a full course on Combine. So you can imagine that many of us were wondering what would be added to Combine this year in light of all the async/await stuff we’ve seen in Swift Evolution over the past few months.

But searching the API diffs for anything Combine related yielded 0 results. Nothing.

Searching the API diffs fro Combine yields nada

Of course Apple will not tell us what the future plans are, but if we read the tea leaves a bit, it seems to me that this is pretty telling.

Combine’s strengths lie in the fact that it is modeled after streams of events or values. The assumption is that a Publisher can (and often does) emit multiple values before finishing. Many Publishers fire once and complete, but that is an implementation detail.

Contrast that with your typical completion callback or the async example above. Each of these produce a single value and return it.

So what does async/await do for multiple values?

Meet AsyncSequence

AsyncSequence is a new type that allows you to concurrently iterate over a series of async values. If you look at the API docs, it sure does have a lot of the same operations you’d find in a Combine pipeline:

  • map
  • filter
  • prefix
  • contains
  • reduce
  • flatMap
  • etc

I’ve yet to play around with AsyncSequence (at the time of writing this the session is not yet available), but it certainly appears that this is shaping up to be an eventual replacement for Combine.

Looking Ahead at AsyncStream

The best way to get an idea of what is coming is to take a look at Swift Evolution. Here we can see a new proposal, SE-0314 AsyncStream and AsyncThrowingStream.

The continuation types added in  SE-0300  act as adaptors for synchronous code that signals completion by calling a delegate method or callback function. For code that instead yields multiple values over time, this proposal adds new types to support implementing an AsyncSequence interface.

The key point that this proposal suggests is to bridge the async/await continuation model - which currently assumes a single result or error - into working with multiple values over time.

So it certainly seems that Apple is creating an async model capable of potentially replacing Combine, at least for the majority of use cases.

Is Combine Going Away?

My previous stance was basically: “You should use Combine because it makes complex async code easier to write, reason about, and Apple will support it”.

I’m now second-guessing that statement somewhat.

Combine won’t go away (at least, not for a long while) but I think it’s use may be limited to the more Functional Reactive programming style and most of the async ergonomics we get from Swift 5.5 will be preferred by most developers. Things like back-pressure and demand management are important concepts for a fully reactive programming framework, but not every async pipeline needs that complexity.

Also, SwiftUI currently depends on Combine for reacting to state changes in your view models.

Maybe next year we will see some Publisher additions that adopt some of the async/await features to make working with Combine simpler. Or perhaps we’ll see AsyncStream take over the majority of these use-cases.

So should you avoid learning Combine? No! I think it’s extremely useful still, and will be more available than async/await since that currently requires macOS 12 / iOS 15. So for my projects that need to support older versions, I’ll still be leaning on Combine for my async needs.

And in the meantime, if you'd like to learn Combine, I'd love for you to check out my course.