In Swift, we have the concept of Sequences. Formally, Sequence is a protocol that types that need sequential and iterated access to their elements depend on. Arrays conform to Sequence, because you can iterate through them via a for-in loop, the forEach method , and with higher-order functions such as filter, map, and reduce. You can simply access their elements with a numbered index such as myArray[2]. Dictionaries are very similar. You can iterate over their keys and values in the same ways as an array, and to access an element in a dictionary, you can simply call myDictionary[myHashableKey]. Sets behave similarly, and you can even implement your own types that conform to Sequence.
The AsyncSequence protocol, provided by the new concurrency system, allows us to achieve similar functionality, but for asynchronous types. Is a Sequence with asynchronicity added on top – AsyncSequence!
Introducing AsyncSequence
An AsyncSequence behaves almost like a sequence. You can do almost everything you can with a normal sequence, from iterating over it, to applying higher-order functions . There is one key difference to Sequences: AsyncSequences do not store any values of their own. It’s not like an array that is storing its data in memory. Strictly speaking, it isn’t a value generator either. Instead, an AsyncSequence is a mere interface that allows you to access values that are being emitted over time, right as they become available. These values are emitted asynchronously, and as such you need to await on them as they become available.
Task groups deliver their results using an AsyncSequence
Notice the “for try await...” part. This for loop is using an AsyncSequence. We do not know what the concrete type for this AsyncSequence. All we know is that the task group is running a variable number of tasks, and as they finish, they deliver a URL object to this loop. The URLs cannot exist until each task launched by the group is done downloading. But how is this magic done? In order to understand how AsyncSequence works, we need to understand how sequences in general work.
A Short Dive into Sequences and AsyncSequences
for-in loops – and by extension, higher-order functions – expect a Sequence to work. Sequence is a protocol with some constraints that interested types must adopt. One of these requirements is to implement a makeIterator() method that returns an iterator. Understanding iterators is not essential to understanding the basics of how sequences work, so for now, you just need to know that iterators are types that conform to IteratorProtocol, and this protocol has a requirement to implement a method called next() which returns an optional instance of the sequence’s generic type Element. In order words, if your sequence is an array of Ints, the next() method will return an optional Int (Int?). During iterations, this method is called for each run of the loop until there are no more elements to return (until it returns nil).
Declaring an array and iterating over it
This for-in loop will run 5 items, and on each iteration, the item local variable will store a value returned by next() -> Int?. The moment the collection returns nil, the iteration stops, and the code will begin executing below the loop. In short, for-in iterations are simply syntactic sugar provided thanks to the Sequence conformance.
AsyncSequences are not that different. The main differences are that instead of a makeIterator method, they have a makeAsyncIterator method that, like you may have guessed it, returns an iterator that conforms to the AsyncIteratorProtocol protocol. This iterator requires you to implement a next() async -> Element? method. Because the next method is async, we can use it in for-in loops and with higher-order functions.
Therefore, Sequences and AsyncSequences are very similar. The main differences are that Sequences do store their data and AsyncSequences deliver them over time from an asynchronous source, and the underlying protocols are non-async and async, respectively, to support their functionality.
AsyncSequence Concrete Types
There are various types that conform to AsyncSequence . In general, you do not need to concern yourself with the underlying type, but it’s interesting to know, because this is also different to how normal Sequences operate.
Whenever you call a higher-order function in an AsyncSequence, such as drop(while:), filter, map, and reduce, a completely new type that conforms to AsyncSequence is returned to you.
If you call drop(while:) on an async sequence, you will get a AsyncThrowingDropWhileSequence or AsyncDropWhileSequence; using filter will return you an AsyncThrowingFilterSequence or AsyncFilterSequence, and so on for any other higher-order function you perform. There’s too many of them to list, but you get the idea. They can be chained without an issue. Normal sequences return an ArraySlice<Element> when the array is mutated, because an array slice is a “window” of the returned elements, it does not contain a copy of the new data, it just “sees” the elements that should be included for the new array. Because AsyncSequences do not contain data, it doesn’t make sense to have “windows” in the original. Instead, these types will restrict or mutate what will be delivered on each iteration, skipping some iterations altogether. Higher-order functions on arrays that return new data such as map will be normal arrays of type Element.
AsyncSequence Example
While we have used AsyncSequences before, I have prepared an example that does not rely on task groups. Instead, we will use the new lines property of the URL object which opens a file, and reads it line by line, returning a single line on each iteration.
A file we will read line by line
We have a file very similar to a CSV file. When parsing it, we will create different kinds of Videogame objects that each have a title, release year, and score.
A new Videogame object
Reading a file from a server line by line
We begin this function by declaring a URL pointing to the location of the file. Then, we declare an empty [Videogame] array where we will append each new videogame as it becomes available.
The for-in loop waits for lines (videogameLine) which are of type String. This is because url.lines is an AsyncSequence, and we have to await on it.
Each time we get a videogameLine , we will check if it contains the pipe character |, and if it does, we will create a new videogame by passing this raw line to the Videogame initializer. The Videogame initializer will parse this line and create a Videogame instance on each iteration, which will be appended to the videogames array.
We need to do this within a do-catch block, because our file is in a remote server and an error may occur anytime.
If you have been programming in Swift for a while, you have no doubt noticed that we can create prettier code here. By using higher-order functions on url.lines, we can create more idiomatic code and make this more elegant.
Using higher-order functions to create the videogames array
Now, unlike normal sequences, the videogames array is not going to be there instantly. If lines were a non-async collection, videogames would ultimately be an array of Videogames – [Videogame] or Array<Videogame>.
Because lines is an async sequence , our videogames variable will not be of type [Videogame]. Instead, it will be of type, AsyncMapSequence<AsyncFilterSequence<AsyncLineSequence<URL.AsyncBytes>>, Videogame>.
- 1.
We got the AsyncMapSequence from calling map().
- 2.
We got the AsyncFilterSequence by calling filter().
- 3.
We got the AsyncLineSequence, which is the type of the lines property of URL.
Unlike normal Sequences, the filter-map operation does not “kickstart” immediately upon declaration. Instead, we get a concrete type for an AsyncSequence that we must later feed to a for-in loop. AsyncSequences will never “start” until they are used in a for-in loop. When you are building and chaining your async sequences, you are simply building a long instruction set that a loop will later use to deliver its data over time. You can think of it as a “query” that tells the loop what it should deliver as data becomes available.
If you use the same async sequence in another loop, you will get the data instantly, because it will be cached. AsyncSequences will never run multiple times getting the data from the async source, as it would be very inefficient.
So, Listing 9-6 will run synchronously without an issue until it hits the loop itself. As the async sequence is executed, it may suspend multiple times for each iteration, getting the data from an asynchronous source.
As you play around with AsyncSequence , you will discover it has some more limitations compared to normal Sequences. First, you cannot get the count of elements on videogames. This is because the variable is not storing data – as we said, it’s just a “query” of sorts that tells a for-in loop what it should return on each iteration. You necessarily need to execute the sequence in a for-in loop to be able to figure out its count. Next, not all higher-order functions that would expect to see from normal sequences are available. dropFirst is an example.
Using continue statements in awaited loops
Calling more higher-order functions on the videogames library
Native APIs That Use AsyncSequence
FileHandle.standardInput.bytes.lines, which can be used to receive input from the command line or other sources. Each command is delivered in a for-in loop.
URLs can access both the lines and resourceBytes member properties. In this chapter we explored how to use lines. resourceBytes is similar.
URLSession has a bytes(from:) method, which you can use to download data byte by byte from the network.
NotificationCenter now has APIs to await on new notifications of the types you are interested in. Receiving notifications in a for-in loop can greatly help you reduce the logic you need to deal with notification center triggers.
The AsyncStream Object
In this book, we have explored ways to migrate existing code and patterns to use the new features of the modern concurrency system. For example, we have migrated closure-based code and delegate-based call to use async/await instead, simplifying their usage. There are times in which you have an asynchronous data source that delivers data over time. The SDK offers many APIs that receive streams of data in delegates and closures. We can create AsyncSequence wrappers for these and have them deliver their data in a loop. Two examples of this would be Bluetooth, which delivers packet data in a delegate call, and CoreLocation , which delivers coordinates, also in a delegate call. Wrapping them in AsyncStreams will allow us to expose an API that can be used in for-in loops and have a more natural interface.
AsyncStream allows us to achieve stream-like functionality that works with for-in loop without having to implement any iterators ourselves. We can use it to migrate delegate-based and closure-based calls to receive their data in a loop instead. To achieve this, AsyncStream conforms to AsyncSequence.
To show you how you can use AsyncStream, we will create a small project that will receive CoreLocation data as it becomes available and show it in a UI.
The CoreLocationAsyncStream Project
You can make this app by using delegate-based calls, but we want to use AsyncStream to better understand how AsyncStream and AsyncSequence work. It will also help make your code cleaner and keep anything to do with location in one place only.
The LocationUpdater Class
The beginning of the LocationUpdater class
Asking for Location Usage Permission
Before you follow the following steps. Make sure you add the NSLocationWhenInUseUsageDescription (Privacy - Location When In Use Usage Description.) key to your Info.plist file. This is a user-facing string explaining the user why we need to track their location.
CoreLocation delivers the status change of authorization on a delegate call. We can take this chance to use continuations for this. Yes, they are the same continuations we learned about all the way back in Chapter 3.
This property will help us bridge the delegate call to async/await for permission status changes
Starting the continuation to get the authorization status for location access
We are only going to ask for the permission when the authorization status is .notDetermined. If we have a different status, we will return it immediately. The reason for this is that permission is always requested when you initialize a CLLocationManager object , the permission is requested automatically by the system. If you call the request method while the system is fetching the authorization status, you will discard a continuation without calling resume on it, and you will be deadlocked. This is one of the few situations where you can deadlock yourself (if you start a continuation and it goes away before you can resume it), and the good news is that this bug is easy to catch as you run your app to test it.
When the authorization status changes, we will resume the continuation
In this app, we are only interested in knowing when the permission changes once in our program. It may be a good idea to have an infinite loop that always reports authorization changes. We could do this with an AsyncStream, sending location authorization updates every time the delegate method is called and never calling finish(). When the status changes, we resume the permissionContinuation with it, so the awaited calls depending on it can continue executing.
Using the requestPermission method to request location access asynchronously
Receiving Locations in a Loop
Now let’s implement the logic to receive the location objects .
Internally, AsyncStream works with continuations. The idea is that when you create an AsyncStream, it gives you a continuation where you will send an undefined number of events of the type you are interested in. In this project, we will send objects of type CLLocation. The types of these continuations are not strictly the same as that we learned about in Chapter 3, but they behave very similarly. The main difference is that the continuations from Chapter 3 must be resumed exactly once, whereas AsyncStream continuations can yield() any number of objects, and when you are done, you need to call finish() to make the sequence return nil and therefore stop the loop. Not calling finish() will result in your for-in loop never finishing unless it gets explicitly cancelled – which may be what you want in some scenarios, but feel like deadlocks in others.
Creating a continuation for our CLLocation AsyncStream
The generic type of AsyncStream tells us what kind of object the for-in loop will receive in every iteration. In this case, CLLocation.
We can use the locations property to get CLLocation objects in real time
When creating an AsyncStream, you need to define what kind of object it will receive. We will stream CLLocation objects, so we go with CLLocation.self. The initializer also takes a closure that gives you the stream’s continuation, of type AsyncStream<CLLocation>.Continuation. We can set up an optional onTermination closure on the continuation that will get called after we call finish() on an AsyncStream. Use this closure if you need to close data pipes or do any other kind of cleanup. In this project, if the user chooses to no longer receive location updates by pressing a button, we will tell the location manager to stop tracking the location. Notice that the continuation is marked as @Sendable. This closure may safely be used across different concurrent domains. If you don’t mark it as such, you won’t be able to stop the locationManager from delivering updates.
The next line assigns the continuation to our streamContinuation variable. We need to keep this variable around because location events are delivered on a delegate method. After we have configured the continuation, we can start tracking the user’s location, by calling the start() method . This will start the locationManager’s ability to send location updates.
The locations variable can now deliver CLLocation objects in real time, but we are not quite done yet. We are not properly calling the finish() method , so this loop will never end, and we are not actually delivering location objects anywhere.
Finishing the continuation
When we call finish(), the continuation’s onTermination closure will be called as well. In this particular example , it may look weird that stop() can trigger onTermination, and that onTermination calls stop(). We are doing this because in the event the Task that is running the for-in loop is cancelled, we want to stop receiving locations as well. You could directly call locationManager.stopUpdatingLocation() within onTermination, but I prefer the idiomatic approach of calling our own stop(). Because we set streamContinuation to nil within onTermination, there is no danger or recursive calls here.
Delivering the location objects to the continuation
You may have noticed the delegate gives us an array of location objects. Each time this method is called, it may have more than one object. So we will iterate over the array of received objects and yield() them to the continuation on each call. With this, we have finished the functionality for LocationUpdater, and callers can now use the locations property to receive events in real time in a loop.
Using the locations AsyncStream
Finishing the Project
ViewModel for the view that will show the locations
We are making the whole class run on the @MainActor due to the fact it can update the UI. The view will read the @Published locations property to display them in the list.
The startUpdating method will kickstart the operation to receive locations. It will begin by asynchronously requesting permission to track the user’s location. Once we have permission, we will access the locationUpdater’s locations property in the loop. This is the AsyncStream<CLLocation> we created earlier. AsyncStream is an AsyncSequence, so we can iterate over it with an await. This loop will be infinite until the stopUpdating method is called, which will cause the sequence to return nil and hence end the loop.
The UI of the app
We are done! We use the .task modifier to call the view model’s startUpdating method . .task is called as soon as the view appears, and cancelled when it disappears, so it’s useful to use it when working with concurrency. The “Stop Updating” button will stop the stream.
You can download the finished project, called “CoreLocationAsyncStream ”.
The AsyncThrowingStream Object
Like many objects in the new concurrency system, AsyncStream has its throwing variation which you can use if your loop can throw errors. The object is AsyncThrowingStream , and it is as easy to use as AsyncStream.
You will be able to yield values, finish it normally, or finish(with:) with an error. If you are using AsyncThrowingStream, you will need to have a for try await loop.
Summary
In this chapter, we learned about AsyncSequences. AsyncSequences are almost like normal Sequences, except they work with the new concurrency system. AsyncSequences can deliver their data over time in a for-in loop, until the underlying source decides to send a nil value. While you can use many of the higher-order functions such as filter, map, and reduce, these operations won’t start until you use your AsyncSequence in a loop, unlike Sequences where the operations are triggered automatically as they are chained.
We also learned about AsyncStream. We can use AsyncStream to bridge closure-based or delegate-based calls that deliver data over time to the async/await world. We explored how we can use it with the CoreLocation framework, and the considerations you need to keep in mind when using this object. An AsyncStream is an AsyncSequence, so everything you can do with AsyncSequence, you can do with AsyncStream . If your sequence can throw, you can use AsyncThrowingStream instead of AsyncStream.