Understanding Swift’s AsyncSequence

Published on: November 1, 2021

The biggest features in Swift 5.5 all revolve around its new and improved concurrency features. There's actors, async/await, and more. With these features folks are wondering whether async/await will replace Combine eventually.

While I overall do not think that async/await can or will replace Combine on its own, Swift 5.5 comes with some concurrency features that provide very similar functionality to Combine.

If you're curious about my thoughts on Combine and async/await specifically, I still believe that what I wrote about this topic earlier is true. Async/await will be a great tool for work that have a clearly defined start and end with a single output while Combine is more useful for observing state and responding to this state.

In this post, I would like to take a look at a Swift Concurrency feature that provides a very Combine-like functionality because it allows us to asynchronously receive and use values. We'll take a look at how an async sequence is used, and when you might want to choose an async sequence over Combine (and vice versa).

Using an AsyncSequence

The best way to explain how Swift's AsyncSequence works is to show you how it can be used. Luckily, Apple has added a very useful extension to URL that allows us to asynchronously read lines from a URL. This can be incredibly useful when your server might stream data as it becomes available instead of waiting for all data to be ready before it begins sending an HTTP body. Alternatively, your server's response format might allow you to begin parsing and decoding its body line by line. An example of this would be a server that returns a csv file where every line in the file represents a row in the returned dataset.

Iterating over an async sequence like the one provided by URL looks as folllows:

let url = URL(string: "https://www.donnywals.com")!
for try await line in url.lines {
    print(line)
}

This code only works when you're in an asynchronous context, this is no different from any other asynchronous code. The main difference is in how the execution of the code above works.

A simple network call with async/await would look as follows:

let (data, response) = try await URLSession.shared.data(from: url)

The main difference here is that URLSession.shared.data(from:) only returns a single result. This means that the url is loaded asynchronously, and you get the entire response and the HTTP body back at once.

When you iterate over an AsyncSequence like the one provided through URL's lines property, you are potentially awaiting many things. In this case, you await each line that's returned by the server.

In other words, the for loop executes each time a new line, or a new item becomes available.

The example of loading a URL line-by-line is not something you'll encounter often in your own code. However, it could be a useful tool if your server's responses are formatted in a way that would allow you to parse the response one line at a time.

A cool feature of AsyncSequence is that it behaves a lot like a regular Sequence in terms of what you can do with it. For example, you can transform your the items in an AsyncSequence using a map:

let url = URL(string: "https://www.donnywals.com")!
let sequence = url.lines.map { string in
    return string.count
}

for try await line in sequence {
    print(line)
}

Even though this example is very simple and naive, it shows how you can map over an AsyncSequence.

Note that AsyncSequence is not similar to TaskGroup. A TaskGroup runs multiple tasks that each produce a single result. An AsyncSequence on the other hand is more useful to wrap a single task that produces multiple results.

However, if you're familiar with TaskGroup, you'll know that you can obtain the results of the tasks in a group by looping over it. In an earlier post I wrote about TaskGroup, I showed the following example:

func fetchFavorites(user: User) async -> [Movie] {
    // fetch Ids for favorites from a remote source
    let ids = await getFavoriteIds(for: user)

    // load all favorites concurrently
    return await withTaskGroup(of: Movie.self) { group in
        var movies = [Movie]()
        movies.reserveCapacity(ids.count)

        // adding tasks to the group and fetching movies
        for id in ids {
            group.addTask {
                return await self.getMovie(withId: id)
            }
        }

        // grab movies as their tasks complete, and append them to the `movies` array
        for await movie in group {
            movies.append(movie)
        }

        return movies
    }
}

Note how the last couple of likes await each movie in the group. That's because TaskGroup itself conforms to AsyncSequence. This means that we can iterate over the group to obtain results from the group as they become available.

In my post on TaskGroup, I explain how a task that can throw an error can cause all tasks in the group to be cancelled if the error is thrown out of the task group. This means that you can still catch and handle errors inside of your task group to prevent your group from failing. When you're working with AsyncSequence, this is slightly different.

AsyncSequence and errors

Whenever an AsyncSequence throws an error, your for loop will stop iterating and you'll receive no further values. Wrapping the entire loop in a do {} catch {} block doesn't work; that would just prevent the enclosing task from rethrowing the error, but the loop still stops.

This is part of the contract of how AsyncSequence works. A sequence ends either when its iterator returns nil to signal the end of the sequence, or when it throws an error.

Note that a sequence that produces optional values like String? can exist and if a nil value exists this wouldn't end the stream because the iterator would produce an Optional.some(nil). The reason for this is that an item of type String? was found in the sequence (hence Optional.some) and its value was nil. It's only when the iterator doesn't find a value and returns nil (or Optional.none) that the stream actually ends.

In the beginning of this post I mentioned Combine, and how AsyncSequence provides some similar features to what we're used to in Combine. Let's take a closer look at similarities and differences between Combine's publishers and AsyncSequence.

AsyncSequence and Combine

The most obvious similarities between Combine and AsyncSequence are in the fact that both can produce values over time asynchronously. Furthermore, they both allow us to transform values using pure functions like map and flatMap. In other words, we can use functional programming to transform values. When we look at how thrown errors are handled, the similarities do not stop. Both Combine and AsyncSequence end the stream of values whenever an error is thrown.

To sum things up, these are the similarities between Combine and AsyncSequence:

  • Both allow us to asynchronously handle values that are produced over time.
  • Both allow us to manipulate the produced values with functions like map, flatMap, and more.
  • Both end their stream of values when an error occurs.

When you look at this list you might thing that AsyncSequence clearly replaces Combine.

In reality, Combine allows us to easily do things that we can't do with AsyncSequence. For example, we can't debounce values with AsyncSequence. We also can't have one asynchronous iterator that produces values for multiple for loops because iterators are destructive which means that if you loop over the same iterator twice, you should expect to see the second iterator return no values at all.

I'm sure there are ways to work around this but we don't have built-in support at this time.

Furthermore, at this time we can't observe an object's state with an AsyncSequence which, in my opinion is where Combine's value is the biggest. Again, I'm sure you could code up something that leverages KVO to build something that observes state but it's not built-in at this time.

This is most obvious when looking at an ObservableObject that's used with SwiftUI:

class MyViewModel: ObservableObject {
  @Published var currentValue = 0
}

SwiftUI can observe this view model's objectWillChange publisher to be notified of changes to any of the ObservableObject's @Published properties. This is really powerful, and we currently can't do this with AsyncSequence. Furthermore, we can use Combine to take a publisher's output, transform it, and assign it to an @Published property with the assign(to:) operator. If you want to learn more about this, take a look at this post I wrote where I use the assign(to:) operator.

Two other useful features we have in Combine are CurrentValueSubject and PassthroughSubject. While AsyncSequence itself isn't equivalent to Subject in Combine, we can achieve similar functionality with AsyncStream which I plan to write a post about soon.

The last thing I'd like to cover is the lifetime of an iterator versus that of a Combine subscription. When you subscribe to a Combine publisher you are given a cancellable to you must persist to connect the lifetime of your subscription to the owner of the cancellable. To learn more about cancellables in Combine, take a look at my post on AnyCancellable.

You can easily subscribe to a Combine publisher in a regular function in your code:

var cancellables = Set<AnyCancellable>()

func subscribeToPublishers() {
  viewModel.$numberOfLikes.sink { value in 
    // use new value
  }.store(in: &cancellables)

  viewModel.$currentUser.sink { value in 
    // use new value
  }.store(in: &cancellables)
}

The lifetime of these subscriptions is tied to the object that holds my set of cancellables. With AsyncSequence this lifecycle isn't as clear:

var entryTask: Task<Void, Never>?

deinit {
  entryTask.cancel()
}

func subscribeToSequence() {
  entryTask = Task {
    for await entry in viewModel.fetchEntries {
      // use entry
    }
  }
}

We could do something like the above to cancel our sequence when the class that holds our task is deallocated but this seems very error prone, and I don't think its as elegant as Combine's cancellable.

Summary

In this post, you learned about Swift's AsyncSequence and you've learned a little bit about how it can be used in your code. You learned about asynchronous for loops, and you saw that you can transform an AsyncSequence output.

In my opinion, AsyncSequence is a very useful mechanism to obtain values over time from a process that has a beginning and end. For more open ended tasks like observing state on an object, I personally think that Combine is a better solution. At least for now it is, who knows what the future brings.

Subscribe to my newsletter