Chapters

Hide chapters

Modern Concurrency in Swift

Second Edition · iOS 16 · Swift 5.8 · Xcode 14

Section I: Modern Concurrency in Swift

Section 1: 11 chapters
Show chapters Hide chapters

3. AsyncSequence & Intermediate Task
Written by Marin Todorov

Throughout this book, you’ll use async sequences to make your life easier when it comes to asynchronous programming. Async sequences make consuming asynchronous results as simple as iterating over a Swift sequence.

You’ve already tried async sequences briefly in Chapter 1, “Why Modern Swift Concurrency?”, but you’ll take a more detailed deep dive into them now.

You’ll do this while continuing to work on the SuperStorage app from the last chapter, so you don’t need an introduction to the project; you can jump right in. When you’ve finished working through this chapter, you’ll have given SuperStorage parallel download superpowers.

Getting to Know AsyncSequence

AsyncSequence is a protocol describing a sequence that can produce elements asynchronously.

Its surface API is identical to the Swift standard library’s Sequence, with one difference: You need to await the next element, since it might not be immediately available, as it would in a regular Sequence

Here are some common tasks you’d use an asynchronous sequence for:

  • Iterating over the sequence in a for loop, using await and, if the AsyncSequence is throwing, try. The code suspends at each loop iteration to get the next value:
for try await item in asyncSequence {
  // Next item from `asyncSequence`
}
  • Using the asynchronous alternative of a standard library iterator with a while loop. This is similar to using a synchronous sequence: You need to make an iterator and repeatedly call next() using await until the sequence is over:
var iterator = asyncSequence.makeAsyncIterator()
while let item = try await iterator.next() {
  ...
}
  • Using standard sequence methods like dropFirst(_:), prefix(_:) and filter(_:):
for await item in asyncSequence
  .dropFirst(5)
  .prefix(10)
  .filter { $0 > 10 }
  .map { "Item: \($0)" } {
    ...
  }
  • Using special raw-byte sequence wrappers, such as reading file contents or fetching data from a server URL:
let bytes = URL(fileURLWithPath: "myFile.txt").resourceBytes

for await character in bytes.characters {
  ...
}

for await line in bytes.lines {
  ...
}
  • Creating custom sequences by adopting AsyncSequence in your own types.

  • Finally, you can create your very own custom async sequences by leveraging AsyncStream. You’ll learn all about this option in the next chapter.

For an overview of all the Apple-provided types that are asynchronous sequences, visit AsyncSequence‘s online documentation. You’ll find the available types listed under Conforming Types.

Getting Started With AsyncSequence

So far, you’ve done good work on the SuperStorage app including building the main screen, which displays a list of files. When the user selects a file, they see its details with three download options, one for each of the cloud service’s subscription plans:

In the previous chapter, you coded the Silver download option which fetches the complete file in one go.

You’ll start this chapter by implementing the Gold download option, which provides progressive UI updates as the file downloads.

You’ll achieve this by reading the file as an asynchronous sequence of bytes from the server. This lets you update the progress bar as you receive the file’s contents.

Adding Your Asynchronous Sequence

Open the SuperStorage project and go to SuperStorageModel.swift, scrolling to downloadWithProgress(fileName:name:size:offset:). It already contains code to create the correct server URL. It also calls addDownload(name:) to add the download onscreen.

Next, insert this code directly before downloadWithProgress(fileName:name:size:offset:)’s return line:

let result: (downloadStream: URLSession.AsyncBytes, response: URLResponse)

Note: Be mindful of the exact method signature — there’s a similarly called method but with less arguments. You’ll want to insert the code inside the method with four arguments.

Unlike before, when you used URLSession.data(for:delegate:) to return Data, you’ll use an alternative API that returns URLSession.AsyncBytes. This sequence gives you the bytes it receives from the URL request, asynchronously.

The HTTP protocol lets a server define that it supports a capability called partial requests. If the server supports it, you can ask it to return a byte range of the response, instead of the entire response at once.

To make things a little more interesting, you’ll support both standard and partial requests in the app.

Request: file.png Response: file.png Server Device Request 1: file.png [ ...20,000] Request 2: file.png [20,001...60,000] Request 3: file.png [60,001...77,345] Request 1: file.png [ ...20,000] Request 2: file.png [20,001...60,000] Request 3: file.png [60,001...77,345] Server Device

Using partial response functionality lets you split the file into parts and download them in parallel. You’ll need this functionality when implementing the Cloud 9 download option later in this chapter.

Continue by adding the code below to make a partial file request:

if let offset = offset {
  let urlRequest = URLRequest(url: url, offset: offset, length: size)
  result = try await
    URLSession.shared.bytes(for: urlRequest, delegate: nil)

  guard (result.response as? HTTPURLResponse)?.statusCode == 206 else {
    throw "The server responded with an error."
  }
}

If the code specifies an offset, you create a URL request and pass it to URLSession.bytes(for:delegate:), which returns a tuple of the response details and an async sequence that enumerates the bytes of the file.

This time, you check if the response code is 206, indicating a successful partial response.

Next, append the following else block, which handles a regular, non-partial request, to complete your if statement:

else {
  result = try await URLSession.shared.bytes(from: url, delegate: nil)

  guard (result.response as? HTTPURLResponse)?.statusCode == 200 else {
    throw "The server responded with an error."
  }
}

The code above is similar to what you did previously except that, this time, you’re checking for a 200 status and a successful server response.

Regardless of whether you make a partial or standard request, you end up with an asynchronous byte sequence available in result.downloadStream.

Using ByteAccumulator

Now, it’s time to start iterating over the response bytes. In this chapter, you’ll implement custom logic to iterate over the bytes. You’ll use a type called ByteAccumulator, which is included with the starter project, to fetch batches of bytes from the sequence.

Why do you need to process the file in batches? Not only is it more fun, but a file can contain millions or billions of bytes. You don’t want to update the UI after getting each byte because that’ll completely overwhelm the system and potentially block the main thread for the duration of the download.

ByteAccumulator will help you collect all of the file’s contents and update the UI only periodically, after fetching each batch of bytes:

iterating file byte by byte collect a given amount of bytes updated progress bar after each batch

Note: If you’re curious to see how the accumulator works, peek into ByteAccumulator.swift.

To start using ByteAccumulator, append this code before the return statement for downloadWithProgress(fileName:name:size:offset:) in SuperStorageModel:

var asyncDownloadIterator = result.downloadStream.makeAsyncIterator()

AsyncSequence features a method called makeAsyncIterator(), which returns an asynchronous iterator for the sequence. You’ll use the returned asyncDownloadIterator to iterate over the bytes, one at a time.

Now, to add the accumulator and collect all the bytes, add the code that creates and uses the accumulator:

var accumulator = ByteAccumulator(name: name, size: size)

while await !stopDownloads, !accumulator.checkCompleted() {

}

The first condition in the while loop checks if the model’s stopDownloads flag isn’t set. You’ll use this flag again in a bit.

Please note that to read stopDownloads‘s current value you need to use awaitstopDownloads is isolated to the main actor so you must use await to access its value. You’ll start learning about actors in Chapter 8, “Getting Started with Actors”.

The second condition checks if checkCompleted() returns false, which means the accumulator can still collect more bytes.

The combination of these two conditions gives you the flexibility to run the loop until either the external flag stopDownloads is lifted or the accumulator completes the download. With this design, you’re looking ahead and making the download code easily cancellable by using an external flag, if needed.

Next, insert the following code inside the while body:

while !accumulator.isBatchCompleted,
  let byte = try await asyncDownloadIterator.next() {
  accumulator.append(byte)
}

You use a second while loop that runs until the particular batch is full. The loop condition is similar to the outer while — you keep collecting bytes until either the isBatchCompleted flag is true or the byte sequence completes.

The code is so simple that if it weren’t for the single await keyword in there, you couldn’t tell it’s asynchronously processing a file while downloading it at the same time.

Updating the Progress Bar

After a batch completes, the execution continues after the while loop. This is the perfect place to update the download progress bar. Insert the following code after the inner while loop, still inside the body of the outer while loop:

let progress = accumulator.progress
Task.detached(priority: .medium) {
  await self
    .updateDownload(name: name, progress: progress)
}

print(accumulator.description)

In the code above, you might spot at least a couple of novelties that you haven’t covered yet.

First, you use Task.detached(...). This is the rogue version of creating a task with Task(priority:operation:). A detached task doesn’t inherit the parent’s priority, task storage or execution actor.

Note: Generally speaking, the documentation recommends against using Task.detached(...) because it negatively affects the concurrency model’s efficiency. In this case, you can use it in order to learn the syntax.

You create the task by explicitly setting a medium priority so there’s no chance of it slowing down the ongoing download task. That task runs with userInitiated priority because it’s a task started by a user action.

Another interesting aspect is that you capture self in the closure. Earlier, you learned that the new syntax mostly eliminates the need to manage memory manually. However, SuperStorageModel is a class — and, therefore, a reference type.

That means the usual memory management rules apply when you use escaping closures. It’s not the case here, but if there’s a chance you’ll create a reference cycle, be sure to use [weak self] to capture a safe-to-use weak reference.

Last but not least, you calculate the current progress, hand it over to updateDownload(...) and you print the current accumulator state to the output console so you can keep track of downloads during development.

To make sure you inserted the code in the right places, you’ll find the completed loop code below, so you can compare your code to it:

while await !stopDownloads, !accumulator.checkCompleted() {
  while !accumulator.isBatchCompleted,
    let byte = try await asyncDownloadIterator.next() {
    accumulator.append(byte)
  }
  let progress = accumulator.progress
  Task.detached(priority: .medium) {
    await self
      .updateDownload(name: name, progress: progress)
  }
  print(accumulator.description)
}

Returning the Accumulated Result

To wrap up the the method you worked on in the last sections, replace the line return Data() with:

return accumulator.data

The newly completed method iterates over the download sequence and collects all the bytes. It then updates the file progress at the end of each batch.

Switch to DownloadView.swift and scroll to the closure parameter called downloadWithUpdatesAction. Insert this code inside the empty closure:

isDownloadActive = true
Task {
  do {
    fileData = try await model.downloadWithProgress(file: file)
  } catch { }
  isDownloadActive = false
}

The code above is identical to the code you added to downloadSingleAction in the previous chapter. This time, however, it calls downloadWithProgress(file:) to provide real-time updates to the progress bar.

This project interacts with the book-server web API. Before testing your code in the iOS Simulator, make sure you’re running the server app on your computer. To start the server, navigate to the server folder in the book materials-repository and enter swift run. The detailed steps are covered in more detail in Chapter 1, “Why Modern Swift Concurrency?”.

Build and run. Select a file and tap the Gold option to test your new code. You’ll see the progress bar update repeatedly as the file downloads:

Even more reassuring is the console output that the while loop prints after each batch:

[graphics-project-ver-1.jpeg] 0.9 MB
[graphics-project-ver-1.jpeg] 1 MB
[graphics-project-ver-1.jpeg] 1.2 MB
[graphics-project-ver-1.jpeg] 1.3 MB
[graphics-project-ver-1.jpeg] 1.4 MB
[graphics-project-ver-1.jpeg] 1.6 MB
...

This output tells you at what point in the download you’re updating the progress bar, and you can easily calculate the size of each batch.

Congratulations, you now know most of what you need to know about using AsyncSequence!

Canceling Tasks

Canceling unneeded tasks is essential for the concurrency model to work efficiently.

When you use one of the new APIs, like TaskGroup (which you’ll cover later in the book) or async let, the system can usually cancel the task automatically when needed.

You can, however, implement a finer-grained cancellation strategy for your task-based code by using the following Task APIs:

  • Task.isCancelled: Returns true if the task is still alive but has been canceled since the last suspension point.
  • Task.currentPriority: Returns the current task’s priority.
  • Task.cancel(): Attempts to cancel the task and its child tasks.
  • Task.checkCancellation(): Throws a CancellationError if the task is canceled, making it easier to exit a throwing context.
  • Task.yield(): Suspends the execution of the current task, giving the system a chance to cancel it automatically to execute some other task with higher priority.

When writing your asynchronous tasks, you’ll choose which APIs to use depending on whether you need a throwing function like checkCancellation() or if you’d like to manage the control flow yourself by checking isCancelled.

In the next section, you’ll implement your own custom logic to cancel download tasks that you don’t need anymore.

Canceling an Asynchronous Task

To demonstrate why canceling tasks in a timely manner is important, run through the following scenario in the app:

Select one of the TIFF files and tap Gold to start a download with progress updates. Logs will appear in Xcode’s console as the accumulator collects more and more of the file’s content.

While the file is still downloading, tap the < Back button and observe the console. That download keeps going until it downloads the whole file!

Manually Canceling Tasks

So far, you wrote your async code inside a .task(...) view modifier, which is responsible for automatically canceling your code when the view disappears. But the actions for the download buttons aren’t in a .task(), so there’s nothing to cancel your async operations.

To fix this issue, you’ll manually cancel your download tasks. Start by adding a new state property to DownloadView:

@State var downloadTask: Task<Void, Error>?

In downloadTask, you’ll store an asynchronous task that returns no result and could throw an error. Task is a type like any other, so you can also store it in your view, model or any other scope. Task doesn’t return anything if it’s successful, so success is Void; likewise you return an Error if there’s a failure.

Next, scroll back to downloadWithUpdatesAction and replace the line Task { with:

downloadTask = Task {

This stores the task in downloadTask so you can access it later. Most importantly, it lets you cancel the task at will.

You’ll cancel the task when the user navigates back to the main screen. You already have some code in .onDisappear(...). Add this line immediately after model.reset():

downloadTask?.cancel()

Canceling downloadTask will also cancel all its child tasks — and all of their children, and so forth. Build and run the app. Then, try to run the test scenario from above. You’ll notice that the progress logs in the console stop when you navigate back to the main screen.

Storing State in Tasks

Each asynchronous task executes in its own context, which consists of its priority, actor and more. But don’t forget — a task can call other tasks. Because each might interact with many different functions, isolating shared data at runtime can be difficult.

To address this, Swift offers a new property wrapper that marks a given property as task-local. Think for a moment about injecting an object into the environment in SwiftUI, which makes the object available not only to the immediate View, but also to all of its child views.

Similarly, binding a task-local value makes it available not only to the immediate task, but also to all its child tasks:

Root task bind $myVar myVar myVar myVar Task 1 Task 2 Task 3 Task 4 Task 5 Task 6

In this section of the chapter, you’ll learn how to use task-local storage to make a function’s behavior vary depending on the calling context.

More specifically, you’ll code the action for the Cancel All button on the download screen.

Adding a Partial Image Preview

The JPEG format allows for partially decoding images, but other formats, such as TIFF, don’t allow for partial preview. So you’ll only support partial preview for JPEG files.

You’ll develop the following custom logic: If the user is downloading a JPEG image and cancels before it finishes, you’ll show the partially downloaded preview. For other image types, you’ll just abort the download.

Open SuperStorageModel.swift and add a new property to SuperStorageModel:

@TaskLocal static var supportsPartialDownloads = false

If the user initiates a JPEG download, you’ll set supportsPartialDownloads to true. You’ll then add some new code in SuperStorageModel to provide the appropriate behavior based on the flag’s value.

Note: Task-local properties need to be either static for the type, or global variables.

The @TaskLocal property wrapper offers a method called withValue() that allows you to bind a value to an async task — or, simply speaking, inject it into a task hierarchy.

Open DownloadView.swift. In the downloadWithUpdatesAction closure parameter, replace the line fileData = try await model.downloadWithProgress(file: file) with these lines:

try await SuperStorageModel
  .$supportsPartialDownloads
  .withValue(file.name.hasSuffix(".jpeg")) {
    fileData = try await model.downloadWithProgress(file: file)
  }

Here, you use withValue(_:) to bind whether or not the download supports partial downloads, based on the file’s extension. With the value bound, you call downloadWithProgress(file:).

You can bind multiple values this way, and you can also overwrite the values from inner bindings, like so (don’t enter this code):

try await $property1.withValue(myData) {
  ...
  try await $property2.withValue(myConfig1) {
    ...
    try await serverRequest()
    try await $property2.withValue(myConfig2) {
      ...
    }
  }
}

In any case, you can see that using too many task-local storage properties might become difficult to read and reason about since you’ll need to wrap the code in a closure for each binding.

Note: In that sense, task storage is useful for binding fewer values: complete configuration objects or whole data models, rather than separate single values or flags as in the example above.

Adding the “Cancel All” Functionality

While still in DownloadView.swift, scroll down toward the bottom of the file and find .toolbar(...). This is where you define the Cancel All button. Its action closure is empty, so add the following code inside:

model.stopDownloads = true

This time, instead of canceling the download task altogether, like you did in .onDisappear(...), you turn on the stopDownloads flag on SuperStorageModel. You’ll observe this flag while downloading. If it changes to true, you’ll know that you need to cancel your tasks internally.

To do that, open SuperStorageModel.swift and scroll to downloadWithProgress(fileName:name:size:offset:). Toward the bottom of that function, insert this code just before the return line:

if await stopDownloads, !Self.supportsPartialDownloads {
  throw CancellationError()
}

This is the task-specific behavior for your custom cancellation. After each downloaded batch, you check if stopDownloads is true and, if so, also check whether the download supports partial preview.

Then:

  • If Self.supportsPartialDownloads is false, you throw a CancellationError to exit the function with an error. This stops the download immediately.

  • If Self.supportsPartialDownloads is true, you continue the execution and return the partially downloaded file content.

It’s time to give that new feature a try. Build and run. Select a TIFF image, then start a Gold plan download. After a moment, tap Cancel All.

As you can see, the download stops. Your existing error-handling code catches the CancellationError and hides the spinner view without further updating the progress bar.

Now, try picking a JPEG image and running through the same routine:

Here, your special behavior for JPEG files returns the partially downloaded image, while DownloadView displays a preview of the successfully downloaded part.

Bridging Combine and AsyncSequence

Combine is Apple’s reactive programming framework, based on the reactive streams specification. Its syntax is similar to RxSwift and other frameworks based on or inspired by the Rx standard. You can learn more about Combine by working through the book Combine: Asynchronous Programming With Swift.

Apple has integrated Combine into several key frameworks like Foundation and Core Data, among others:

FetchedRequest @State @Binding @observedObject NSManagedObject Timer NotificationCenter Combine SwiftUI Foundation Core Data

The simplicity of the Publisher protocol, which is the cornerstone of Combine, makes it universal and easy to adopt. A publisher can asynchronously emit zero, one or more values. It can, optionally, complete with a success or a failure:

1 2 3 4 Success values over time

Wait a minute!

That looks more or less precisely like an async sequence. Wouldn’t it be fantastic if you could use async/await with all the existing Combine integrations from Apple, as well as with your own Combine code?

Yes, it would! And, fortunately, Apple offers an easy interface to do just that.

Adding a Progress Timer

In this section of the chapter, you’ll add a timer showing how long the user’s download is taking in real time:

As usual, the starter project already includes the necessary UI code. DownloadView has a state property called duration that displays the duration below the progress bar.

The plan for the timer is to create a new async task whenever isDownloadActive changes to true and, in that task, create a Combine timer to update the UI periodically.

First, since you’re using Combine, add one more import line at the top of DownloadView.swift:

import Combine

Then, add a new property in DownloadedView to store the timer task so you can cancel it when you’re done:

@State var timerTask: Task<Void, Error>?

Next, find the property @State var isDownloadActive = false. The first thing you’d like to do when this flag is set to true or false is to cancel any previously running timer task. Add the following didSet accessor to downloadTask, so it looks like so:

@State var downloadTask: Task<Void, Error>? {
  didSet {
    timerTask?.cancel()
  }
}

Then, if the user just started a new download, you want to note the start time. Add this code next, still inside the didSet accessor:

guard isDownloadActive else { return }
let startTime = Date().timeIntervalSince1970

You’ll use this later to calculate the duration based on the timer’s start time.

Creating the Combine-based Timer

It’s time to create the Combine timer and use the asynchronous values property to proxy your Combine code so you can use it with await.

Add this code to create a Timer publisher immediately after setting startTime:

let timerSequence = Timer
  .publish(every: 1, tolerance: 1, on: .main, in: .common)
  .autoconnect()
  .map { date -> String in
    let duration = Int(date.timeIntervalSince1970 - startTime)
    return "\(duration)s"
  }
  .values

Here’s what this code is doing, step-by-step:

  1. Timer.publish creates a Combine publisher that emits the current date every second.
  2. autoconnect makes the publisher start ticking automatically whenever someone subscribes to it.
  3. map calculates the elapsed time in seconds and returns the duration as a String.
  4. Finally, and most importantly, values returns an asynchronous sequence of the publisher’s events, which you can loop over as usual.

In fact, you can use for await with any Combine publisher by accessing its values property, which automatically wraps the publisher in an AsyncSequence.

Note: Similarly, the Future type in Combine offers an async property called value. This lets you await the future result asynchronously.

Completing the Timer

Finally, still in the didSet accessor, add this code to create a new asynchronous task, store it in timerTask and loop over the sequence:

timerTask = Task {
  for await duration in timerSequence {
    self.duration = duration
  }
}

Here, you iterate over timerSequence. Inside that loop, you assign each value to self.duration. As mentioned in the beginning of the section, duration is already wired to the UI, so the only thing left to do is test it.

Build and run. Select a file and choose the Gold download button. You’ll see that the duration summary appears below the progress bar and updates every second.

Before wrapping up this chapter, there’s one final easy-to-miss issue: Tap Cancel All, and you’ll notice the timer is still running.

To fix this, scroll down to the toolbar modifier and add the following line to the Button’s action closure:

timerTask?.cancel()

This will cancel the pending timer whenever you tap the Cancel All button. That’s it!

By completing the last task for this chapter, you’ve really become an async sequence pro user. If you’re hungry for more practice, however, stay around and work through this chapter’s challenge.

Challenges

Challenge: Implementing Concurrent Downloads for the “Cloud 9” Plan

To try creating local structured concurrency on your own, your challenge is to implement the Cloud 9 download option. Don’t worry, the starter project already comes with all the non-concurrency code, letting you focus on the essential bits and pieces.

First, head to DownloadView.swift and, inside the downloadMultipleAction closure parameter, add a call to multiDownloadWithProgress(file:) on your model. This is just like what you did for the other download buttons.

Once you do that, head to SuperStorageModel.swift and find multiDownloadWithProgress(file:).

You’ll see that the function already includes code to break the file download into four parts and store them in the parts array. Each array element has the following properties:

  • name: The part name.
  • size: The part size in bytes.
  • offset: The part’s offset within the complete file.

Luckily, these are also the exact parameters that downloadWithProgress(fileName:name:size:offset) expects, so putting the pieces together shouldn’t be too difficult.

On your own, you should:

  1. Define four promises with async let that each use downloadWithProgress(fileName:name:size:offset) to download a part of the file. Use the data in parts.
  2. Await all four downloads together to execute them concurrently.
  3. Combine the data from all the downloads to return the complete file content as the return value of multiDownloadWithProgress(file:).

Once you’ve done all of the above, verify that tapping the Cloud 9 button kicks off four simultaneous downloads, like so:

The most telling sign that everything works as expected will be that the image preview appears correctly at the end of the download:

If you get stuck or want to compare your solution with what I had in mind for this code, look at the completed challenge project in this chapter’s materials.

Key Points

  • AsyncSequence is a protocol which resembles Sequence and allows you to iterate over a sequence of values asynchronously.
  • You iterate over a sequence asynchronously by using the for await ... in syntax, or directly creating an AsyncIterator and awaiting its next() method in the context of a while loop.
  • Task offers several APIs to check if the current task was canceled. If you want to throw an error upon cancellation, use Task.checkCancellation(). To safely check and implement custom cancellation logic, use Task.isCancelled.
  • To bind a value to a task and all its children, use the @TaskLocal property wrapper along with withValue().

Earlier in this chapter, you learned there are a few ways to create your very own custom asynchronous sequence. Next, it seems like the perfect time to take a deep dive into AsyncStream, which lets you succinctly create your own asynchronous streams.

See you in the next chapter!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.