© Andrés Ibañez Kautsch 2023
A. I. KautschModern Concurrency on Apple Platformshttps://doi.org/10.1007/978-1-4842-8695-1_9

9. AsyncSequence

Andrés Ibañez Kautsch1  
(1)
La Paz, Bolivia
 

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.

We have actually used an AsyncSequence before. Back in Chapter 5, when we were talking about Structured Concurrency, we learned about task groups. Task groups allow us to run a dynamic number of concurrent tasks and observe their results in an awaited loop. To recap, Listing 9-1 is one of the original examples we used back then.
/// Downloads all the images in the array and returns an array of URLs to the local files
func download(serverImages: [ServerImage]) async throws -> [URL] {
    var urls: [URL] = []
    try await withThrowingTaskGroup(of: URL.self) { group in
        for image in serverImages {
            group.addTask(priority: .userInitiated) {
                let imageUrl = try await self.download(image)
                return imageUrl
            }
        }
        for try await imageUrl in group {
            urls.append(imageUrl)
        }
    }
    return urls
}
Listing 9-1

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).

Imagine you have the array in Listing 9-2, and you iterate over it, printing a value from it on each iteration.
let myArray = [1, 2, 3, 4, 5]
for item in myArray {
    print(item)
}
Listing 9-2

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.

The following example reads a real URL which returns some data. For reference, the file we will read line by line is in Listing 9-3.
The Legend of Zelda: Ocarina of Time|1998|10
The Legend of Zelda: Majora's Mask|2000|10
The Legend of Zelda: The Wind Waker|2003|10
Tales of Vesperia|2008|8
Tales of Graces|2011|9
Tales of the Abyss|2006|10
Tales of Xillia|2013|10
Listing 9-3

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.

Each line will be parsed into a Videogame object, which is in Listing 9-4.
Struct Videogame {
    let title: String
    let year: Int?
    let score: Int?
    init(rawLine: String) {
        let splat = rawLine.split(separator: "|")
        self.title = String(splat[0])
        self.year = Int(splat[1])
        self.score = Int(splat[2])
    }
}
Listing 9-4

A new Videogame object

Now let’s get to the interesting part. Listing 9-5 will oversee loading the videogames from a webserver, and it will parse the whole file in an array of Videogames.
Func loadVideogames() async {
    let url = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part11/videogames.csv")!
    var videogames: [Videogame] = []
    do {
        for try await videogameLine in url.lines {
            if rawVg.contains("|") {
                // Valid videogame
                videogames += [Videogame(rawLine: videogameLine)]
            }
        }
    } catch {
        // Handle the error
    }
}
Listing 9-5

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.

In Listing 9-6, we have a new implementation for loadVideogames , which will use higher-order functions.
Func loadVideogames() async {
    let url = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part11/videogames.csv")!
    let videogames =
        url
        .lines
        .filter { $0.contains("|") }
        .map { Videogame(rawLine: $0) }
    do {
        for try await videogame in videogames {
            print("(videogame.title) ((videogame.year ?? 0))")
        }
    } catch {
    }
}
Listing 9-6

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>.

This is the reason I said you don’t generally concern yourself with the underlying type of an AsyncSequence , but while this signature looks complicated, it’s easy to understand. Going from last to first in the higher-order functions that created the videogames variable :
  1. 1.

    We got the AsyncMapSequence from calling map().

     
  2. 2.

    We got the AsyncFilterSequence by calling filter().

     
  3. 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.

Because awaited loops can be treated like normal loops, you can do anything you can do in such loops, including using continue and break statements to alter the execution of the loop. Listing 9-7 makes use of the continue statement to only print videogames with a score of 10.
For try await videogame in videogames {
    if videogame.score == 10 {
        continue
    }
    print("(videogame.title) ((videogame.year ?? 0))")
}
Listing 9-7

Using continue statements in awaited loops

In this specific example, we could just add a new filter call to the videogames variable. This would return only the videogames with a score of 10 without having to use the continue statement in the loop. Listing 9-8 has the required changes to eliminate the continue statement.
Let videogames =
    url
    .lines
    .filter { $0.contains("|") }
    .map { Videogame(rawLine: $0) }
    .filter { $0.score != 10 } // Apply the filter here
do {
    for try await videogame in videogames {
        print("(videogame.title) ((videogame.year ?? 0))")
    }
} catch {
}
Listing 9-8

Calling more higher-order functions on the videogames library

Native APIs That Use AsyncSequence

Apple has added many APIs across their SDKs that make use of AsyncSequence. URL.lines is one, but there’s more. This is not an exhaustive list, but these are the ones I find myself using more often:
  • 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

Create a new SwiftUI project called “CoreLocationAsyncStream ”. This app will receive locations and show them in a list as they are updated. Figure 9-1 shows a screenshot of the final product.

A screenshot of the mobile includes time, WIFI signal, full charge battery, and the screen depicts the stop updating and locations.

Figure 9-1

The UI will show a list of locations as they become available

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

Create a new class called LocationUpdater.swift . We will use this class to manage everything to do with location, from asking permission to getting location updates to delivering those updates to interested parties. Make sure you import CoreLocation, and give it the contents outlined in Listing 9-9.
import Foundation
import CoreLocation
class LocationUpdater: NSObject, CLLocationManagerDelegate {
    private(set) var authorizationStatus: CLAuthorizationStatus
    private let locationManager: CLLocationManager
    override init() {
        locationManager = CLLocationManager()
        authorizationStatus = locationManager.authorizationStatus
        super.init()
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
    }
    func start() {
        locationManager.startUpdatingLocation()
    }
    func stop() {
        locationManager.stopUpdatingLocation()
    }
    func requestPermission() async -> CLAuthorizationStatus {
    }
    // MARK: - Location Delegate
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    }
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        authorizationStatus = manager.authorizationStatus
    }
}
Listing 9-9

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.

Start by adding a property that will keep track of the continuation that will deliver the authorization status. You can find it in Listing 9-10.
private var permissionContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
Listing 9-10

This property will help us bridge the delegate call to async/await for permission status changes

Go back to the empty requestPermission() method, and complete it with the code Listing 9-11.
func requestPermission() async -> CLAuthorizationStatus {
    locationManager.requestWhenInUseAuthorization()
    if authorizationStatus != .notDetermined {
        return authorizationStatus
    }
    return await withCheckedContinuation { continuation in
        permissionContinuation = continuation
    }
}
Listing 9-11

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.

You will also need to add one more line to the locationManagerDidChangeAuthorization delegate method . The finished implementation for this method is in Listing 9-12.
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    authorizationStatus = manager.authorizationStatus
    permissionContinuation?.resume(returning: authorizationStatus)
}
Listing 9-12

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.

This is all we need to do to request permission one time asynchronously using async/await . Listing 9-13 shows how you can use this logic to request permission.
let locationUpdater = LocationUpdater()
// ...
let authorized = await locationUpdater.requestPermission()
if [CLAuthorizationStatus.authorizedAlways, .authorizedWhenInUse].contains(authorized) {
    // Permission authorized
}
Listing 9-13

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.

Add a property that will hold the AsyncStream continuation. You can see it in Listing 9-14.
private var streamContinuation: AsyncStream<CLLocation>.Continuation?
Listing 9-14

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.

Now, we are going to implement a locations property. Its type will be AsyncStream<CLLocation>. This is the property for-in loops will use to get new location objects as they become available. Add the code in Listing 9-15 to the LocationUpdater class.
var locations: AsyncStream<CLLocation> {
    let stream = AsyncStream(CLLocation.self) { continuation in
        continuation.onTermination = { @Sendable _ in
            self.stop()
            self.streamContinuation = nil
        }
        self.streamContinuation = continuation
        self.start()
    }
    return stream
}
Listing 9-15

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.

We will stop streaming when the stop() method gets called. Modify this method so it looks like the one in Listing 9-16.
func stop() {
    locationManager.stopUpdatingLocation()
    streamContinuation?.finish()
}
Listing 9-16

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.

Now we can do the required changes to the locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]). We need to deliver the locations we receive to the continuation. Listing 9-17 shows its final implementation.
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    locations.forEach { streamContinuation?.yield($0) }
Listing 9-17

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.

Listing 9-18 shows how callers can make use of this object to receive location objects in a for-in loop.
self locationUpdater = LocationUpdater()
//... After confirming we are authorized.
for await newCoordinate in locationUpdater.locations {
    // Do something with the newly received newCoordinate object.
}
Listing 9-18

Using the locations AsyncStream

Finishing the Project
We can now continue writing the rest of the app. Go to the ContentView.swift file and create the ContentViewViewModel class there. You can create it on a different file if you wish. Its entire implementation is small, so I will put all of it in Listing 9-19.
@MainActor
class ContentViewViewModel: ObservableObject {
    @Published private(set) var locations: [CLLocation] = []
    let locationUpdater = LocationUpdater()
    func startUpdating() async {
        let authorized = await locationUpdater.requestPermission()
        if [CLAuthorizationStatus.authorizedAlways, .authorizedWhenInUse].contains(authorized) {
            for await newCoordinate in locationUpdater.locations {
                locations += [newCoordinate]
            }
        }
    }
    func stopUpdating() {
        locationUpdater.stop()
    }
}
Listing 9-19

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.

Finally, we can implement the UI. The UI is also rather small, so I can post it in a single listing. Listing 9-20 shows our completed UI.
@MainActor
struct ContentView: View {
    @StateObject var viewModel = ContentViewViewModel()
    var body: some View {
        NavigationView {
            List(viewModel.locations, id: .hash) { location in
                Text("(location.coordinate.longitude), (location.coordinate.latitude)")
            }
            .navigationTitle("Locations")
            .task {
                await viewModel.startUpdating()
            }
            .navigationBarItems(
                leading: EmptyView(),
                trailing: Button("Stop Updating") {
                    viewModel.stopUpdating()
                }
            )
            .navigationViewStyle(.stack)
        }
    }
}
Listing 9-20

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.141.12.209