Modern Concurrency: Beyond the Basics
Oct 20 2022 · Swift 5.5, iOS 15, Xcode 13.4
Part 1: AsyncStream & Continuations
About this episode Leave a rating/review See forum comments Cinema mode Mark complete Download course materials Previous episode: 01. Introduction Next episode: 03. Using AsyncStream to Count Down
Notes: 02. AsyncStream
- For more about push-based vs pull-based AsyncStreams, see AsyncSequence & AsyncStream Tutorial for iOS.
- This video uses Xcode 14’s
Task.sleep(until:clock:). If you use Xcode 13, replace this with
Transcript: 02. AsyncStream
In this episode, you'll learn about AsyncStream, an easier way to create a custom AsyncSequence. In the course materials, locate the starter playground and open it. In the preceding course, you created this simple typewriter with a custom AsyncSequence that types a phrase, adding a character every second. The AsyncSequence protocol requires only two things, the element type of the sequence and an iterator that returns the next element in the sequence. Each call to next returns a substring of the initial string that is one character longer than the last one. When it reaches the end of the phrase, next returns nil to signify the end of the sequence. You tried out your typewriter sequence with this task. Run this task to remind yourself how it works. I promised you I'd show you a much easier way, and now it's time to learn about AsyncStream. Comment out this task so it doesn't interfere with the tasks you're about to write. In the next section, the starter has already set up test phrase and index properties. You'll create a pull-based AsyncStream first. Instead of a type alias, you just initialize your AsyncStream with your element type String. This trailing closure is the unfolding argument of the AsyncStream. It just has to return the next element. So scroll up to the TypewriterIterator next method, copy its code, and paste this into the AsyncStream closure. Xcode complains because AsyncStream doesn't throw. In TypewriterIterator, you were able to declare that next throws. You can do a similar thing here. Change AsyncStream to AsyncThrowingStream. The throwing version of AsyncStream allows its closure to throw errors. You add an Error type parameter placeholder inside the angle brackets, or you could wrap the try await in a do catch. Undo back to just AsyncStream, then add your do catch. Now, to try out your AsyncStream, create a task. The task loops over the stream items. For each iteration, the stream's closure gets executed. This continues until the closure returns nil. Run this task. You get the same results as before with a lot less code, and you didn't have to add new types to your codebase. Option click AsyncStream, then open in Developer Documentation. Scroll down to Topics. There are two kinds of AsyncStream. The one you just created has an unfolding closure. The closure is marked as async. Like the sequence iterator you wrote for custom sequences in the preceding course, it supplies the next element. It creates a sequence of values, one at a time, only when the task asks for one. Think of it as pull-based or demand-driven. Use this when you have control over when elements are generated. Click the other initializer. This AsyncStream has a build closure. This closure is not marked as async because this version is meant to interface with non-asynchronous APIs. This closure creates a sequence of values and buffers them until someone asks for them. Think of it as push-based or supply-driven. Now, scroll down to see some sample code. The trailing closure receives a continuation that yields stream values or nil to finish the sequence. Notice that Task.sleep is wrapped in a task because it's asynchronous while the closure is not. In episode four, you'll create a push-based AsyncStream to manage notifications which arrive on their own schedule. In this episode, you'll create a push-based version of the typewriter sequence so you can compare the two kinds of AsyncStream. Close the documentation window. Just above the pull-based code, create a push-based stream. This closure receives a continuation. You're about to write some asynchronous code, so you wrap it in a task. Inside the task closure, add a while loop. While the index hasn't reached the end of phrase. Now, copy the do catch code from the pull-based AsyncStream into this while loop. Delete the return nil in the catch closure. Next, in the do closure, yield a sequence value. Copy the pull-based return value and copy the index increment from the pull-based defer. For a push-based AsyncStream, you don't have to defer incrementing index because you don't return from this closure. You just use continuation yield to create each value. If there's no process waiting for the values, they go into the buffer. Finally, in the catch closure, finish the continuation and also do this after the while loop ends. If there's an error, or when you reach phrase.endindex, you use continuation.finish to push nil into the buffer to indicate the end of the sequence. The task to use this stream is the same as for the pull-based task, so scroll down to copy it, paste it, and change pull to push. You try await items in this push-based stream just like for the pull-based stream. The stream push closure is called once. It creates all the stream elements, buffering them until the task loop asks for them. Run this push-based task. As you expect, the output is the same. To see how these two AsyncStream closures differ, comment out the tasks that print out the streams and run the playground. The pull-based AsyncStream code never runs at all. It runs only when the task asks for the next value. The while loop in the push-based AsyncStream closure runs 13 times, creating the stream of strings. It does this even though there's no task asking for the values. In the next three episodes, you'll use both types of AsyncStream to add features to the Blabber app.