How AsyncSequence works internally in Swift

How AsyncSequence works internally in Swift

As part of my series of articles about Swift's new Structured Concurrency features, let's take a look at how AsyncSequence works behind the curtains!

Sequences vs AsyncSequences

I wrote back in 2019 an article detailing how regular Sequence types work in Swift, and like its older counterpart, the purpose of AsyncSequence is to allow async objects representing a collection of values to be used in for loops:

let sequence = SomeDataProvider()

for await value in sequence {
    print(value)
}

You should take a look at the article about Sequence before reading this one, but if you don't want to, one way we can summarize it is that for loops aren't a hardcoded feature of Swift, but a syntax sugar that is built on top of two Swift protocols: Sequence and IteratorProtocol. Implementing these protocols allows you to use the relevant type as the right-hand value in a for loop, but deep down everything will simply be a syntax sugar of calling the methods you've implemented from the protocols.

When I first saw AsyncSequence I was wondering how that one would work, and as it turns out, it's roughly the same thing as a regular Sequence. Here's how the Swift protocols for the feature look like:

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?
}

Just like Sequence, an asynchronous sequence must define the object type used by it, as well as an "iteration object" (often the sequence itself) which provides these objects in the first place. Deep down, for await loops are converted by the compiler to an iteration on top of the AsyncIteratorProtocol.

while let line = try await it.next() {
  // Do something with each line
}

To be specific, this conversion happens when emitting the SIL for the loop. While for loops exist within the compiler as a ForEachStmt type, they stop existing when compiler is going to write the optimized code that is later going to be transformed into assembly code. In this case Sequence and AsyncSequence will behave exactly the same, with the only difference being that asynchronous for loops will result in the ForEachStmt having a special flag that is later used to craft the asynchronous version of the optimized code.

One thing that is different here is that AsyncIteratorProtocol can throw, which is something that regular sequences can't do. As a fun side-effect this means that you can add the try keyword to regular for-loops, but they will do nothing because the regular Sequence can't throw.

for try number in 0..<5 {} // Doesn't do anything special but is also not a warning.

What's __consuming?

One thing you might have noticed is that makeAsyncIterator() has a special modifier called __consuming, which we haven't mentioned here in previous articles. The regular Sequence also has it, but I didn't notice it when I wrote its article!

I was first made aware of it when Txai Wieser asked me about it a while ago, and it seems that this is an internal compiler feature that was ninja'd into Swift back in 2018. To be specific, the feature itself doesn't exist yet, but the modifiers were added to make sure the types that would make use of it would be ready by the time the feature is fully implemented.

The feature in question here is something called move-only types, which describes objects that can be cheaply moved around to different memory addresses instead of being copied, which can be both a performance and a memory optimization technique when passing objects around different layers of abstraction. This is especially relevant for Sequences as the IteratorProtocol implementation in practice requires duplicating some or all of the information in the original Sequence, which today is not possible if the Sequence cannot be copied without consuming the original sequence. If the Sequence was able to provide move-only objects, the cost of iterating would be reduced. Unfortunately it seems that this feature was never actually implemented, with old forum posts being the only piece of information we have on it.