Swift Async Sequences

Mar 08, 2023#swift#concurrency

This post will go over what async sequences are and the fundamentals behind them. Then about how you can use them in your code and go over a few of the new AsyncSequence APIs. And finally, we’ll explore how you can build your own async sequences.

AsyncSequence

The new AsyncSequence protocol enables a natural, simple syntax for iterating over a sequence of values over time as easy as writing a for loop. An AsyncSequence resembles the Sequence type — offering a list of values you can step through one at a time — and adds asynchronicity.

public protocol AsyncSequence {
    associatedtype AsyncIterator: AsyncIteratorProtocol where AsyncIterator.Element == Element
    associatedtype Element
    __consuming func makeAsyncIterator() -> AsyncIterator
}

public protocol AsyncIteratorProtocol {
    associatedtype Element
    mutating func next() async throws -> Element?
}

An AsyncSequence doesn’t generate or contain the values; it just defines how you access them. Along with defining the type of values as an associated type called Element, the AsyncSequence defines a makeAsyncIterator() method. This returns an instance of type AsyncIterator.

AsyncSequence will suspend on each element and resume when the underlying iterator produces a value or throws. When an error occurs, that’s also a point at which the async sequence is at a terminal state, and after an error happens, they’ll return nil for any subsequent calls to next on their iterator.

Given a source that is an async sequence, you can await each value by using the for-await-in syntax. This works just like regular sequences. This means you can use break to terminate iteration early from inside the loop, or continue to skip some value.

AsyncSequence is a really powerful tool that is both safe and familiar for dealing with more than one asynchronous value. If you know how to use Sequence, you already know how to use AsyncSequence.

Tour some AsyncSequence APIs

There are many AsyncSequence APIs that are available as of macOS Monterey, iOS 15, tvOS 15, and watchOS 8. Reading from files is often a prime use case for asynchronous behavior. It’s a convenience property on URL to return an AsyncSequence of lines from the contents, either from a file or from the network. Sometimes getting things from the network requires a bit more control over the responses and authentication.

// Read bytes asynchronously from a FileHandle
for try await line in FileHandle.standardInput.bytes.lines {
    // ...
}

// Read lines asynchronously from a URL
let url = URL(fileURLWithPath: "/tmp/foo.txt")
for try await line in url.lines {
    // ...
}

// Await notifications asynchronously
let center = NotificationCenter.default
let notification = await center.notifications(named: .NSPersistentStoreRemoteChange).first {
    // ...
}

Create your own AsyncSequence

This is really just one example and there are likely numerous others that you can adapt in your own code.

struct Counter: AsyncSequence {
    typealias Element = Int
    let howHigh: Int

    struct AsyncIterator: AsyncIteratorProtocol {
        let howHigh: Int
        var current = 1

        mutating func next() async -> Int? {
            guard current <= howHigh else {
                return nil
            }

            let result = current
            current += 1
            return result
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        return AsyncIterator(howHigh: howHigh)
    }
}

for await i in Counter(howHigh: 10) {
    print(i, terminator: " ")
    // Prints "1 2 3 4 5 6 7 8 9 10"
}

Convenience Methods

The AsyncSequence protocol provides default implementations for many common operations. You can use many generic algorithms for types that conform to AsyncSequence. There are two categories of functions:

  • Those that return a single value (and are thus marked as async): first, contains, min, max, reduce, and more.
  • And those that return a new AsyncSequence (and are not marked as async themselves): filter, map, and compactMap, and more.

The new swift-async-algorithms package includes many more functions to work with async sequences, primarily about time, like debounce and throttle, but also algorithms about order like combineLatest and merge. Operations that work with multiple inputs (like zip does on Sequence) can be surprisingly complex to implement, with subtle behaviors and many edge cases to consider.

AsyncStream & AsyncThrowingStream

Swift continuations provide a great experience for APIs that asynchronously produce a single result. but some operations produce many values over time instead. Rather than being adapted to an async function, the appropriate solution for these operations is to create an AsyncSequence.

Swift 5.5 introduces AsyncStream and AsyncThrowingStream conform to AsyncSequence, providing a convenient way to create an asynchronous sequence without manually implementing an asynchronous iterator.


extension QuakeMonitor {
    static var quakes: AsyncStream<Quake> {
        AsyncStream { continuation in
            let monitor = QuakeMonitor()
            monitor.quakeHandler { quake in
                continuation.yield(quake)
            }
            monitor.onTermination = { _ in
                monitor.stopMonitoring()
            }
            monitor.startMonitoring()
        }
    }

    static var throwingQuakes: AsyncThrowingStream<Quake, Error> {
        AsyncThrowingStream { continuation in
            let monitor = QuakeMonitor()
            monitor.quakeHandler = { quake in
                continuation.yield(quake)
            }
            monitor.errorHandler = { error in
                continuation.finish(throwing: error)
            }
            continuation.onTermination = { @Sendable _ in
                monitor.stopMonitoring()
            }
            monitor.startMonitoring()
        }
    }
}

for await quake in QuakeMonitor.quakes {
    print(quake)
}

The two AsyncStream types each include a nested Continuation type; these outer and inner types represent the consuming and producing sides of operation, respectively.

AsyncStream handles all of the things you would expect from an async sequence, like safety, iteration, and cancellation; but they also handle buffering. It is a solid way of building your own async sequences and a suitable return type from your own APIs.

The future of Combine

Apple introduced the Combine framework in the iOS 13. Since then, we’ve had the opportunity to learn how Combine has been used in real-world scenarios. With AsyncSequence, we are applying these lessons as well as embracing the new structured concurrency features of Swift.

Combine’s API is based on the Publisher and Subscriber interfaces, with operators to connect between them. Its design focuses on providing a way to declaratively specify a chain of these operators, transforming data as it moves from one end to the other. This requires thinking differently about intermediate state.

AsyncSequence and Publisher from the Combine are two different implementations of the same idea. A publisher will obtain or generate its values (asynchronously) over time, and it will send these values to subscribers whenever they are available.

// Using Combine
var cancellables = Set<AnyCancellable>()
func notificationCenter() {
    NotificationCenter.default.publisher(
        for: UIDevice.orientationDidChangeNotification
    ).sink(receiveValue: { notification in
        // handle notification
    })
    .store(in: &cancellables)
}

// Using AsyncSequence
func notificationCenter() async {
    for await notification in await NotificationCenter.default.notifications(
        named: UIDevice.orientationDidChangeNotification
    ) {
        // handle notification
    }
}

It’s pretty clear that iterating over an async sequence looks much cleaner than subscribing to a publisher, and doesn’t require any lifecycle management. We can now write asynchronous code that is split into smaller pieces and reads from top-to-bottom instead of as a series of chained transforms.