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.