Comparing use cases for async sequences and publishers
Published on: April 12, 2022Swift 5.5 introduces async/await and a whole new concurrency model that includes a new protocol: AsyncSequence
. This protocol allows developers to asynchronously iterate over values coming from a sequence by awaiting them. This means that the sequence can generate or obtain its values asynchronously over time, and provide these values to a for-loop as they become available.
If this sounds familiar, that’s because a Combine publisher does roughly the same thing. A publisher will obtain or generate its values (asynchronously) over time, and it will send these values to subscribers whenever they are available.
While the basis of what we can do with both AsyncSequence
and Publisher
sounds similar, I would like to explore some of the differences between the two mechanisms in a series of two posts. I will focus this comparison on the following topics:
- Use cases
- Lifecycle of a subscription / async for-loop
The post you’re reading now will focus on comparing use cases. If you want to learn more about lifecycle management, take a look at this post.
Please note that parts of this comparison will be highly opinionated or be based on my experiences. I’m trying to make sure that this comparison is fair, honest, and correct but of course my experiences and preferences will influence part of the comparison. Also note that I’m not going to speculate on the futures of either Swift Concurrency nor Combine. I’m comparing AsyncSequence
to Publisher
using Xcode 13.3, and with the Swift Async Algorithms package added to my project.
Let’s dive in, and take a look at some current use cases where publishers and async sequences can truly shine.
Operations that produce a single output
Our first comparison takes a closer look at operations with a single output. While this is a familiar example for most of us, it isn’t the best comparison because async sequences aren’t made for performing work that produces a single result. That’s not to say an async sequence can’t deliver only one result, it absolutely can.
However, you typically wouldn’t leverage an async sequence to make a network call; you’d await
the result of a data task instead.
On the other hand, Combine doesn’t differentiate between tasks that produce a single output and tasks that produce a sequence of outputs. This means that publishers are used for operations that can emit many values as well as for values that produce a single value.
Combine’s approach to publishers can be considered a huge benefit of using them because you only have one mechanism to learn and understand; a publisher. It can also be considered a downside because you never know whether an AnyPublisher<(Data, URLResponse), Error>
will emit a single value, or many values. On the other hand, let result: (Data, URLResponse) = try await getData()
will always clearly produce a single result because we don’t use an async sequence to obtain a single result; we await
the result of a task instead.
Even though this comparison technically compares Combine to async/await rather than async sequences, let’s take a look at an example of performing a network call with Combine vs. performing one with async/await to see which one looks more convenient.
Combine:
var cancellables = Set<AnyCancellable>()
func getData() {
let url = URL(string: "https://donnywals.com")!
URLSession.shared.dataTaskPublisher(for: url)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
// handle error
}
}, receiveValue: { (result: (Data, URLResponse)) in
// use result
})
.store(in: &cancellables)
}
Async/Await:
func getData() async {
let url = URL(string: "https://donnywals.com")!
do {
let result: (Data, URLResponse) = try await URLSession.shared.data(from: url)
// use result
} catch {
// handle error
}
}
In my opinion it’s pretty clear which technology is more convenient for performing a task that produces a single result. Async/await is easier to read, easier to use, and requires far less code.
With this somewhat unfair comparison out of the way, let’s take a look at another example that allows us to more directly compare an async sequence to a publisher.
Receiving results from an operation that produces multiple values
Operations that produce multiple values come in many shapes. For example, you might be using a TaskGroup
from Swift Concurrency to run several tasks asynchronously, receiving the result for each task as it becomes available. This is an example where you would use an async sequence to iterate over your TaskGroup
's results. Unfortunately comparing this case to Combine doesn’t make a lot of sense because Combine doesn’t really have an equivalent to TaskGroup
.
💡 Tip: to learn more about Swift Concurrency’s
TaskGroup
take a look at this post.
One example of an operation that will produce multiple values is observing notifications on NotificationCenter
. This is a nice example because not only does NotificationCenter
produce multiple values, it will do so asynchronously over a long period of time. Let’s take a look at an example where we observe changes to a user’s device orientation.
Combine:
var cancellables = Set<AnyCancellable>()
func notificationCenter() {
NotificationCenter.default.publisher(
for: UIDevice.orientationDidChangeNotification
).sink(receiveValue: { notification in
// handle notification
})
.store(in: &cancellables)
}
AsyncSequence:
func notificationCenter() async {
for await notification in await NotificationCenter.default.notifications(
named: UIDevice.orientationDidChangeNotification
) {
// handle notification
}
}
In this case, there is a bit less of a difference than when we used async/await to obtain the result of a network call. The main difference is in how we receive values. In Combine, we use sink
to subscribe to a publisher and we need to hold on to the provided cancellable so the subscription is kept alive. With our async sequence, we use a special for-loop where we write for await <value> in <sequence>
. Whenever a new value becomes available, our for-loop’s body is called and we can handle the notification.
If you look at this example in isolation I don’t think there’s a very clear winner. However, when we get to the ease of use comparison you’ll notice that the comparison in this section doesn’t tell the full story in terms of the lifecycle and implications of using an async sequence in this example. The next part of this comparison will paint a better picture regarding this topic.
Let’s look at another use case where you might find yourself wondering whether you should reach for Combine or an async sequence; state observation.
Observing state
If you’re using SwiftUI in your codebase, you’re making extensive use of state observation. The mix of @Published
and ObservableObject
on data sources external to your view allow SwiftUI to determine when a view’s source of truth will change so it can potentially schedule a redraw of your view.
💡 Tip: If you want to learn more about how and when SwiftUI decided to redraw views, take a look at this post.
The @Published
property wrapper is a special kind of property wrapper that uses Combine’s CurrentValueSubject
internally to emit values right before assigning these values as the wrapped property’s current value. This means that you can subscribe to @Published
using Combine’s sink
to handle new values as they become available.
Unfortunately, we don’t really have a similar mechanism available that only uses Swift Concurrency. However, for the sake of the comparison, we’ll make this example work by leveraging the values
property on Publisher
to convert our @Published
publisher into an async sequence.
Combine:
@Published var myValue = 0
func stateObserving() {
$myValue.sink(receiveValue: { newValue in
}).store(in: &cancellables)
}
Async sequence:
@Published var myValue = 0
func stateObserving() async {
for await newValue in $myValue.values {
// handle new value
}
}
Similar to before, the async sequence version looks a little bit cleaner than the Combine version but as you’ll find in the next post, this example doesn’t quite tell the full story of using an async sequence to observe state. The lifecycle of an async sequence can, in certain case complicate our example quite a lot so I really recommend that you also check out the lifecycle comparison to gain a much better understanding of an async sequence’s lifecycle.
It’s also important to keep in mind that this example uses Combine to facilitate the actual state observation because at this time Swift Concurrency does not provide us with a built-in way to do this. However, by converting the Combine publisher to an async sequence we can get a pretty good sense of what state observation could look like if/when support for this is added to Swift.
Summary
In this post, I’ve covered three different use cases for both Combine and async sequences. It’s pretty clear that iterating over an async sequence looks much cleaner than subscribing to a publisher. There’s also no doubt that tasks with a single output like network calls look much cleaner with async/await than they do with Combine.
However, these examples aren’t quite as balanced as I would have liked them to be. In all of the Combine examples I took into account the lifecycle of the subscriptions I created because otherwise the subscriptions wouldn’t work due to the cancellable that’s returned by sink
being deallocated if it’s not retained in my set of cancellables.
The async sequence versions, however, work fine without any lifecycle management but there’s a catch. Each of the functions I wrote was async
which means that calling those functions must be done with an await
, and the caller is suspended until the async sequence that we’re iterating over completes. In the examples of NotificationCenter
and state observation the sequences never end so we’ll need to make some changes to our code to make it work without suspending the caller.
We’ll take a better look at this in the next post.