AsyncSequence & AsyncStream Tutorial for iOS

Learn how to use Swift concurrency’s AsyncSequence and AsyncStream protocols to process asynchronous sequences. By Audrey Tam.

5 (6) · 4 Reviews

Download materials
Save for later
Share

You’ve embraced async/await as the newest and safest way to code for concurrency in Swift. You’re loving how eliminating a lot of the nested completion handlers reduces the amount of code you write and simplifies that code’s logic so it’s easier to get it right.

And what’s the next step in your Swift concurrency journey? Asynchronous loops. Using Swift concurrency’s AsyncSequence and AsyncStream protocols, this is as easy as looping over an ordinary sequence.

In this tutorial, you’ll:

  • Compare the speed and memory use when synchronously and asynchronously reading a very large file.
  • Create and use a custom AsyncSequence.
  • Create and use pull-based and push-based AsyncStreams.
Note: This is an intermediate-level tutorial. You should be familiar with “traditional” concurrency — GCD (Grand Central Dispatch) and URLSession — and with basic Swift concurrency features like those presented in async/await in SwiftUI and SwiftUI and Structured Concurrency.

Getting Started

Use the Download Materials button at the top or bottom of this tutorial to download the starter project. Open it in Xcode to see what you have to work with.

Data Files

The purpose of ActorSearch is to help you solve puzzles that ask for actor names by searching the name.basics.tsv.gz dataset from IMDb Datasets. This file contains a header line to describe the information for each name:

  • nconst (string) – alphanumeric unique identifier of the name/person
  • primaryName (string)– name by which the person is most often credited
  • birthYear – in YYYY format
  • deathYear – in YYYY format if applicable, else ‘\N’
  • primaryProfession (array of strings) – the top three professions of the person
  • knownForTitles (array of tconsts) – titles the person is known for

To reduce the demand on your network and make it straightforward to read in line by line, the starter project already contains data.tsv: This is the unzipped name.basics.tsv.gz, with the header line removed. It’s a tab-separated-values (TSV) file, formatted in the UTF-8 character set.

Note: Don’t try to view data.tsv by selecting it in the project navigator. It takes a long time to open, and Xcode becomes unresponsive.

In this tutorial, you’ll explore different ways to read the file contents into an array of Actor values. data.tsv contains 11,445,101 lines and takes a very long time to read in, so you’ll use it only to compare memory use. You’ll try out most of your code on the smaller files data-100.tsv and data-1000.tsv, which contain the first 100 and 1000 lines, respectively.

Note: These files are only in the starter project. Copy them into the final project if you want to build and run that project.

Models

Open ActorAPI.swift. Actor is a super-simple structure with only two properties: id and name.

In this file, you’ll implement different methods to read a data file. The ActorAPI initializer takes a filename argument and creates the url. It’s an ObservableObject that publishes an Actor array.

The starter contains a basic synchronous method:

func readSync() throws {
  let start = Date.now
  let contents = try String(contentsOf: url)
  var counter = 0
  contents.enumerateLines { _, _ in
    counter += 1
  }
  print("\(counter) lines")
  print("Duration: \(Date.now.timeIntervalSince(start))")
}

This just creates a String from the contentsOf the file’s url, then counts the lines and prints this number and how long it took.

Note: enumerateLines(invoking:) is a StringProtocol method bridged from the NSString method enumerateLines(_:).

View

Open ContentView.swift. ContentView creates an ActorAPI object with a specific filename and displays the Actor array, with a search field.

First, add this view modifier below the searchable(text:) closure:

.onAppear {
  do {
    try model.readSync()
  } catch let error {
    print(error.localizedDescription)
  }
}

You call readSync() when the view appears, catching and printing any errors readSync() throws.

Now, look at the memory use when you run this app. Open the Debug navigator, then build and run. When the gauges appear, select Memory and watch:

Synchronous read memory spike

On my Mac, reading in this 685MB file took 8.9 seconds and produced a 1.9GB spike in memory use.

Next, you’ll try out a Swift concurrency way to read the file. You’ll iterate over an asynchronous sequence.

AsyncSequence

You work with the Sequence protocol all the time: arrays, dictionaries, strings, ranges and Data are all sequences. They come with a lot of convenient methods, like next(), contains(), filter() and more. Looping over a sequence uses its built-in iterator and stops when the iterator returns nil.

The AsyncSequence protocol works like Sequence, but an asynchronous sequence returns each element asynchronously (duh!). You can iterate over its elements asynchronously as more elements become available over time.

  • You await each element, so the sequence can suspend while getting or calculating the next value.
  • The sequence might generate elements faster than your code can use them: One kind of AsyncStream buffers its values, so your app can read them when it needs them.

AsyncSequence provides language support for asynchronously processing collections of data. There are built-in AsyncSequences like NotificationCenter.Notifications, URLSession.bytes(from:delegate:) and its subsequences lines and characters. And you can create your own custom asynchronous sequences with AsyncSequence and AsyncIteratorProtocol or use AsyncStream.

Note: Apple’s AsyncSequence documentation page lists all the built-in asynchronous sequences.

Reading a File Asynchronously

For processing a dataset directly from a URL, the URL foundation class provides its own implementation of AsyncSequence in URL.lines. This is useful for creating an asynchronous sequence of lines directly from the URL.

Open ActorAPI.swift and add this method to ActorAPI:

// Asynchronous read
func readAsync() async throws {
  let start = Date.now

  var counter = 0
  for try await _ in url.lines {
    counter += 1
  }
  print("\(counter) lines")

  print("Duration: \(Date.now.timeIntervalSince(start))")
}

You iterate asynchronously over the asynchronous sequence, counting lines as you go.

Here’s some Swift concurrency magic: url.lines has its own asynchronous iterator, and the for loop calls its next() method until the sequence signals it’s finished by returning nil.

Note: URLSession has a method that gets an asynchronous sequence of bytes and the usual URLResponse object. You can check the response status code, then call lines on this sequence of bytes to convert it into an asynchronous sequence of lines.
let (stream, response) = try await URLSession.shared.bytes(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
  throw "The server responded with an error."
}
for try await line in stream.lines { 
  // ... 
}
let (stream, response) = try await URLSession.shared.bytes(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
  throw "The server responded with an error."
}
for try await line in stream.lines { 
  // ... 
}