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 are currently viewing page 3 of 3 of this article. Click here to view the first page.

AsyncStream: Push-based

The other kind of AsyncStream has a build closure. It creates a sequence of values and buffers them until someone asks for them. Think of it as push-based or supply-driven.

Add this method to ActorAPI:

// AsyncStream: push-based
func pushActors() async {
  // 1
  let actorStream = AsyncStream<Actor> { continuation in
    // 2
    Task {
      for try await line in url.lines {
        let name = line.components(separatedBy: "\t")[1]
        // 3
        continuation.yield(Actor(name: name))
      }
      // 4
      continuation.finish()
    }
  }

  for await actor in actorStream {
    await MainActor.run {
      actors.append(actor)
    }
  }
}

Here’s what you’re doing in this method:

  1. You don’t need to create an iterator. Instead, you get a continuation.
  2. The build closure isn’t asynchronous, so you must create a Task to loop over the asynchronous sequence url.lines.
  3. For each line, you call the continuation’s yield(_:) method to push the Actor value into the buffer.
  4. When you reach the end of url.lines, you call the continuation’s finish() method.
Note: Because the build closure isn’t asynchronous, you can use this version of AsyncStream to interact with non-asynchronous APIs like fread(_:_:_:_:) .

In ContentView, call pushActors() instead of pullActors():

await model.pushActors()

Build and run and confirm that it works.

Since Apple first introduced Grand Central Dispatch, it has advised developers on how to avoid the dangers of thread explosion.

When there are more threads than CPUs, the scheduler timeshares the CPUs among the threads, performing context switches to swap out a running thread and swap in a blocked thread. Every thread has a stack and associated kernel data structures, so context-switching takes time.

When an app creates a very large number of threads — say, when it’s downloading hundreds or thousands of images — the CPUs spend too much time context-switching and not enough time doing useful work.

In the Swift concurrency system, there are at most only as many threads as there are CPUs.

When threads execute work under Swift concurrency, the system uses a lightweight object known as a continuation to track where to resume work on a suspended task. Switching between task continuations is much cheaper and more efficient than performing thread context switches.

Threads with continuations

Note: This image of threads with continuations is from WWDC21 Session 10254.

When a task suspends, it captures its state in a continuation. Its thread can resume execution of another task, recreating its state from the continuation it created when it suspended. The cost of this is a function call.

This all happens behind the scenes when you use async functions.

But you can also get your hands on a continuation to manually resume execution. The buffering form of AsyncStream uses a continuation to yield stream elements.

A different continuation API helps you reuse existing code like completion handlers and delegate methods. To see how, check out Modern Concurrency in Swift, Chapter 5, “Intermediate async/await & CheckedContinuation”.

Push or Pull?

Push-based is like a factory making clothes and storing them in warehouses or stores until someone buys them. Pull-based is like ordering clothes from a tailor.

When choosing between pull-based and push-based, consider the potential mismatch with your use case:

  • Pull-based (unfolding) AsyncStream: Your code wants values faster than the asynchronous sequence can make them.
  • Push-based (buffering) AsyncStream: The asynchronous sequence generates elements faster than your code can read them, or at irregular or unpredictable intervals, like updates from background monitors — notifications, location, custom monitors

When downloading a large file, a pull-based AsyncStream — downloading more bytes only when your code asks for them — gives you more control over memory and network use. A push-based AsyncStream — downloading the whole file without pausing — could create spikes in memory or network use.

To see another difference between the two kinds of AsyncStream, see what happens if your code doesn’t use actorStream.

In ActorAPI, comment out this code in both pullActors() and pushActors():

for await actor in actorStream {
  await MainActor.run {
    actors.append(actor)
  }
}

Next, place breakpoints at this line in both methods:

let name = line.components(separatedBy: "\t")[1]

Edit both breakpoints to log the breakpoint name and hit count, then continue:

Log breakpoint name and hit count.

Now, in ContentView, set task to call pullActors():

.task {
  await model.pullActors()
}

Build and run, then open the Debug console:

Pull-based actorStream: No log messages

No log messages appear because the code in the pull-based actorStream doesn’t run when your code doesn’t ask for its elements. It doesn’t read from the file unless you ask for the next element.

Now, switch the task to call pushActors():

.task {
  await model.pushActors()
}

Build and run, with the Debug console open:

Push-based actorStream: Log message for every data line

The push-based actorStream runs even though your code doesn’t ask for any elements. It reads the entire file and buffers the sequence elements.

Where to Go From Here?

Download the final project using the Download Materials button at the top or bottom of the tutorial.

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

In this tutorial, you:

  • Compared the speed and memory use when synchronously and asynchronously reading a very large file.
  • Created and used a custom AsyncSequence.
  • Created and used pull-based and push-based AsyncStreams.
  • Showed that the pull-based AsyncStream does nothing until the code asks for sequence elements, while the push-based AsyncStream runs whether or not the code asks for sequence elements.

You can use AsyncSequence and AsyncStream to generate asynchronous sequences from your existing code — any closures that you call multiple times, as well as delegate methods that just report new values and don’t need a response back. You’ll find examples in our book Modern Concurrency in Swift.

Additional Resources:

If you have any comments or questions, feel free to join in the forum discussion below!