Chapters

Hide chapters

Modern Concurrency in Swift

First Edition · iOS 15 · Swift 5.5 · Xcode 13

Section I: Modern Concurrency in Swift

Section 1: 11 chapters
Show chapters Hide chapters

2. Getting Started With async/await
Written by Marin Todorov

Now that you know what Swift Concurrency is and why you should use it, you’ll spend this chapter diving deeper into the actual async/await syntax and how it coordinates asynchronous execution.

You’ll also learn about the Task type and how to use it to create new asynchronous execution contexts.

Before that, though, you’ll spend a moment learning about pre-Swift 5.5 concurrency as opposed to the new async/await syntax.

Pre-async/await asynchrony

Up until Swift 5.5, writing asynchronous code had many shortcomings. Take a look at the following example:

class API {
  ...
  func fetchServerStatus(completion: @escaping (ServerStatus) -> Void) {
    URLSession.shared
      .dataTask(
        with: URL(string: "http://amazingserver.com/status")!
      ) { data, response, error in
        // Decoding, error handling, etc
        let serverStatus = ...
        completion(serverStatus)
      }
      .resume()
  }
}

class ViewController {
  let api = API()
  let viewModel = ViewModel()

  func viewDidAppear() {
    api.fetchServerStatus { [weak viewModel] status in
      guard let viewModel = viewModel else { return }
      viewModel.serverStatus = status
    }
  }
}

This is a short block of code that calls a network API and assigns the result to a property on your view model. It’s deceptively simple, yet it exhibits an excruciating amount of ceremony that obscures your intent. Even worse, it creates a lot of room for coding errors: Did you forget to check for an error? Did you really invoke the completion closure in every code path?

Since Swift used to rely on Grand Central Dispatch (GCD), a framework designed originally for Objective-C, it couldn’t integrate asynchrony tightly into the language design from the get-go. Objective-C itself only introduced blocks (the parallel of a Swift closure) in iOS 4.0, years after the inception of the language.

Take a moment to inspect the code above. You might notice that:

  • The compiler has no clear way of knowing how many times you’ll call completion inside fetchServerStatus(). Therefore, it can’t optimize its lifespan and memory usage.
  • You need to handle memory management yourself by weakly capturing viewModel, then checking in the code to see if it was released before the closure runs.
  • The compiler has no way to make sure you handled the error. In fact, if you forget to handle error in the closure, or don’t invoke completion altogether, the method will silently freeze.
  • And the list goes on and on…

The modern concurrency model in Swift works closely with both the compiler and the runtime. It solves many issues, including those mentioned above.

The modern concurrency model provides the following three tools to achieve the same goals as the example above:

  • async: Indicates that a method or function is asynchronous. Using it lets you suspend execution until an asynchronous method returns a result.
  • await: Indicates that your code might pause its execution while it waits for an async-annotated method or function to return.
  • Task: A unit of asynchronous work. You can wait for a task to complete or cancel it before it finishes.

Here’s what happens when you rewrite the code above using the modern concurrency features introduced in Swift 5.5:

class API {
  ...
  func fetchServerStatus() async throws -> ServerStatus {
    let (data, _) = try await URLSession.shared.data(
      from: URL(string: "http://amazingserver.com/status")!
    )
    return ServerStatus(data: data)
  }
}

class ViewController {
  let api = API()
  let viewModel = ViewModel()

  func viewDidAppear() {
    Task {
      viewModel.serverStatus = try await api.fetchServerStatus()
    }
  }
}

The code above has about the same number of lines as the earlier example, but the intent is clearer to both the compiler and the runtime. Specifically:

  • fetchServerStatus() is an asynchronous function that can suspend and resume execution. You mark it by using the async keyword.

  • fetchServerStatus() either returns Data or throws an error. This is checked at compile time — no more worrying about forgetting to handle an erroneous code path!

  • Task executes the given closure in an asynchronous context so the compiler knows what code is safe (or unsafe) to write in that closure.

  • Finally, you give the runtime an opportunity to suspend or cancel your code every time you call an asynchronous function by using the await keyword. This lets the system constantly change the priorities in the current task queue.

Separating code into partial tasks

Above, you saw that “the code might suspend at each await” — but what does that mean? To optimize shared resources such as CPU cores and memory, Swift splits up your code into logical units called partial tasks, or partials. These represent parts of the code you’d like to run asynchronously.

let log = try await line in log. lines try await line. isConnected await sendLogLine (line) func for if myFunction ( ) async throws { { { } } } try await serverLog ( )

The Swift runtime schedules each of these pieces separately for asynchronous execution. When each partial task completes, the system decides whether to continue with your code or to execute another task, depending on the system’s load and the priorities of the pending tasks.

That’s why it’s important to remember that each of these await-annotated partial tasks might run on a different thread at the system’s discretion. Not only can the thread change, but you shouldn’t make assumptions about the app’s state after an await; although two lines of code appear one after another, they might execute some time apart. Awaiting takes an arbitrary amount of time, and the app state might change significantly in the meantime.

To recap, async/await is a simple syntax that packs a lot of punch. It lets the compiler guide you in writing safe and solid code, while the runtime optimizes for a well-coordinated use of shared system resources.

Executing partial tasks

As opposed to the closure syntax mentioned at the beginning of this chapter, the modern concurrency syntax is light on ceremony. The keywords you use, such as async, await and let, clearly express your intent. The foundation of the concurrency model revolves around breaking asynchronous code into partial tasks that you execute on an Executor.

Executor other code other code other code let log = try await serverLog ( ) try await line in log. lines try await serverLog ( ) try await line in log. lines try await line isConnected await sendLogLine (line) func for if myFunction ( ) async throws { { { } } } let log =

Executors are similar to GCD queues, but they’re more powerful and lower-level. Additionally, they can quickly run tasks and completely hide complexity like order of execution, thread management and more.

Controlling a task’s lifetime

One essential new feature of modern concurrency is the system’s ability to manage the lifetime of the asynchronous code.

A huge shortcoming of existing multi-threaded APIs is that once an asynchronous piece of code starts executing, the system cannot graciously reclaim the CPU core until the code decides to give up control. This means that even after a piece of work is no longer needed, it still consumes resources and performs its work for no real reason.

A good example of this is a service that fetches content from a remote server. If you call this service twice, the system doesn’t have any automatic mechanism to reclaim resources that the first, now-unneeded call used, which is an unnecessary waste of resources.

The new model breaks your code into partials, providing suspension points where you check in with the runtime. This gives the system the opportunity to not only suspend your code but to cancel it altogether, at its discretion.

Thanks to the new asynchronous model, when you cancel a given task, the runtime can walk down the async hierarchy and cancel all the child tasks as well.

Root task Cancel() Task 1 Task 2 Task 3 Task 4 Task 5 Task 6

But what if you have a hard-working task performing long, tedious computations without any suspension points? For such cases, Swift provides APIs to detect if the current task has been canceled. If so, you can manually give up its execution.

Finally, the suspension points also offer an escape route for errors to bubble up the hierarchy to the code that catches and handles them.

Root task throw MyError Task 1 Task 2 Task 3 Task 4 Task 5 Task 6

The new model provides an error-handling infrastructure similar to the one that synchronous functions have, using modern and well-known throwing functions. It also optimizes for quick memory release as soon as a task throws an error.

You already see that the recurring topics in the modern Swift concurrency model are safety, optimized resource usage and minimal syntax. Throughout the rest of this chapter, you’ll learn about these new APIs in detail and try them out for yourself.

Getting started

SuperStorage is an app that lets you browse files you’ve stored in the cloud and download them for local, on-device preview. It offers three different “subscription plans”, each with its own download options: “Silver”, “Gold” and “Cloud 9”.

Open the starter version of SuperStorage in this chapter’s materials, under projects/starter.

Like all projects in this book, SuperStorage’s SwiftUI views, navigation and data model are already wired up and ready to go. This app has more UI code compared to LittleJohn, which you worked on in the previous chapter, but it provides more opportunities to get your hand dirty with some asynchronous work.

Note: The server returns mock data for you to work with; it is not, in fact, a working cloud solution. It also lets you reproduce slow downloads and erroneous scenarios, so don’t mind the download speed. There’s nothing wrong with your machine.

While working on SuperStorage in this and the next chapter, you’ll create async functions, design some concurrent code, use async sequences and more.

A bird’s eye view of async/await

async/await has a few different flavors depending on what you intend to do:

  • To declare a function as asynchronous, add the async keyword before throws or the return type. Call the function by prepending await and, if the function is throwing, try as well. Here’s an example:
func myFunction() async throws -> String { 
  ... 
}

let myVar = try await myFunction()
  • To make a computed property asynchronous, simply add async to the getter and access the value by prepending await, like so:
var myProperty: String {
  get async {
    ...
  }
}

print(await myProperty)
  • For closures, add async to the signature:
func myFunction(worker: (Int) async -> Int) -> Int { 
  ... 
}

myFunction {
  return await computeNumbers($0)
}

Now that you’ve had a quick overview of the async/await syntax, it’s time to try it for yourself.

Getting the list of files from the server

Your first task is to add a method to the app’s model that fetches a list of available files from the web server in JSON format. This task is almost identical to what you did in the previous chapter, but you’ll cover the code in more detail.

Open SuperStorageModel.swift and add a new method anywhere inside SuperStorageModel:

func availableFiles() async throws -> [DownloadFile] {
  guard let url = URL(string: "http://localhost:8080/files/list") else {
    throw "Could not create the URL."
  }
}

Don’t worry about the compiler error Xcode shows; you’ll finish this method’s body momentarily.

You annotate the method with async throws to make it a throwing, asynchronous function. This tells the compiler and the Swift runtime how you plan to use it:

  • The compiler makes sure you don’t call this function from synchronous contexts where the function can’t suspend and resume the task.
  • The runtime uses the new cooperative thread pool to schedule and execute the method’s partial tasks.

In the method, you fetch a list of decodable DownloadFiles from a given url. Each DownloadedFile represents one file available in the user’s cloud.

Making the server request

At the end of the method’s body, add this code to execute the server request:

let (data, response) = try await 
  URLSession.shared.data(from: url)

You use the shared URLSession to asynchronously fetch the data from the given URL. It’s vital that you do this asynchronously because doing so lets the system use the thread to do other work while it waits for a response. It doesn’t block others from using the shared system resources.

Each time you see the await keyword, think suspension point. await means the following:

  • The current code will suspend execution.
  • The method you await will execute either immediately or later, depending on the system load. If there are other pending tasks with higher priority, it might need to wait.
  • If the method or one of its child tasks throws an error, that error will bubble up the call hierarchy to the nearest catch statement.

Using await funnels each and every asynchronous call through the central dispatch system in the runtime, which:

  • Prioritizes jobs.
  • Propagates cancellation.
  • Bubbles up errors.
  • And more.

let value = try await getValue() await [value1, value2, value3] await URLSession.shared.data(...) for await item in items { ... }

Verifying the response status

Once the asynchronous call completes successfully and returns the server response data, you can verify the response status and decode the data as usual. Add the following code at the end of availableFiles():

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

guard let list = try? JSONDecoder()
  .decode([DownloadFile].self, from: data) else {
  throw "The server response was not recognized."
}

You first inspect the response’s HTTP status code to confirm it’s indeed HTTP 200 OK. Then, you use a JSONDecoder to decode the raw Data response to an array of DownloadFiles.

Returning the list of files

Once you decode the JSON into a list of DownloadFile values, you need to return it as the asynchronous result of your function. How simple is it to do that? Very.

Simply add the following line to the end of availableFiles():

return list

While the execution of the method is entirely asynchronous, the code reads entirely synchronously which makes it relatively easy to maintain, read through and reason about.

Displaying the list

You can now use this new method to feed the file list on the app’s main screen. Open ListView.swift and add one more view modifier directly after .alert(...), near the bottom of the file:

.task {
  guard files.isEmpty else { return }
  
  do {
    files = try await model.availableFiles()
  } catch {
    lastErrorMessage = error.localizedDescription
  }
}

As mentioned in the previous chapter, task is a view modifier that allows you to execute asynchronous code when the view appears. It also handles canceling the asynchronous execution when the view disappears.

In the code above, you:

  1. Check if you already fetched the file list; if not, you call availableFiles() to do that.
  2. Catch and store any errors in lastErrorMessage. The app will then display the error message in an alert box.

Testing the error handling

If the book server is still running from the previous chapter, stop it. Then, build and run the project. Your code inside .task(...) will catch a networking error, like so:

Asynchronous functions propagate errors up the call hierarchy, just like synchronous Swift code. If you ever wrote Swift code with asynchronous error handling before async/await‘s arrival, you’re undoubtedly ecstatic about the new way to handle errors.

Viewing the file list

Now, start the book server. If you haven’t already done that, navigate to the server folder 00-book-server in the book materials-repository and enter swift run. The detailed steps are covered in Chapter 1, “Why Modern Swift Concurrency?”.

Restart the SuperStorage app and you’ll see a list of files:

Notice there are a few TIFF and JPEG images in the list. These two image formats will give you various file sizes to play with from within the app.

Getting the server status

Next, you’ll add one more asynchronous function to the app’s model to fetch the server’s status and get the user’s usage quota.

Open SuperStorageModel.swift and add the following method to the class:

func status() async throws -> String {
  guard let url = URL(string: "http://localhost:8080/files/status") else {
    throw "Could not create the URL."  
  }
}

A successful server response returns the status as a text message, so your new function asynchronously returns a String as well.

As you did before, add the code to asynchronously get the response data and verify the status code:

let (data, response) = try await 
  URLSession.shared.data(from: url, delegate: nil)

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

Finally, decode the response and return the result:

return String(decoding: data, as: UTF8.self)

The new method is now complete and follows the same pattern as availableFiles().

Showing the service status

For your next task, you’ll use status() to show the server status in the file list.

Open ListView.swift and add this code inside the .task(...) view modifier, after assigning files:

status = try await model.status()

Build and run. You’ll see some server usage data at the bottom of the file list:

Everything works great so far, but there’s a hidden optimization opportunity you might have missed. Can you guess what it is? Move on to the next section for the answer.

Grouping asynchronous calls

Revisit the code currently inside the task modifier:

files = try await model.availableFiles()
status = try await model.status()

Both calls are asynchronous and, in theory, could happen at the same time. However, by explicitly marking them with await, the call to status() doesn’t start until the call to availableFiles() completes.

Sometimes, you need to perform sequential asynchronous calls — like when you want to use data from the first call as a parameter of the second call.

let result1 = await serverCall1() let result2 = await serverCall2(result1) let result3 = await serverCall3(result2)

This isn’t the case here, though!

For all you care, both server calls can be made at the same time because they don’t depend on each other. But how can you await both calls without them blocking each other? Swift solves this problem with a feature called structured concurrency, via the async let syntax.

Using async let

Swift offers a special syntax that lets you group several asynchronous calls and await them all together.

Remove all the code inside the task modifier and use the special async let syntax to run two concurrent requests to the server:

guard files.isEmpty else { return }

do {
  async let files = try model.availableFiles()
  async let status = try model.status()
} catch {
  lastErrorMessage = error.localizedDescription
}

An async let binding allows you to create a local constant that’s similar to the concept of promises in other languages. Option-Click files to bring up Quick Help:

The declaration explicitly includes async let, which means you can’t access the value without an await.

The files and status bindings promise that either the values of the specific types or an error will be available later.

To read the binding results, you need to use await. If the value is already available, you’ll get it immediately. Otherwise, your code will suspend at the await until the result becomes available:

async let value = ... await value print(await value) fetch value from server code code The promised value is resolved before await: immediately suspends until the value is available execution async let value = ... print(await value) fetch value from server The promised value is NOT resolved before await: execution await value code code code code

Note: An async let binding feels similar to a promise in other languages, but in Swift, the syntax integrates much more tightly with the runtime. It’s not just syntactic sugar but a feature implemented into the language.

Extracting values from the two requests

Looking at the last piece of code you added, there’s a small detail you need to pay attention to: The async code in the two calls starts executing right away, before you call await. So status and availableFiles run in parallel to your main code, inside the task modifier.

To group concurrent bindings and extract their values, you have two options:

  • Group them in a collection, such as an array.
  • Wrap them in parentheses as a tuple and then destructure the result.

The two syntaxes are interchangeable. Since you have only two bindings, you’ll use the tuple syntax here.

Add this code at the end of the do block:

let (filesResult, statusResult) = try await (files, status)

And what are filesResult and statusResult? Option-Click filesResults to check for yourself:

This time, the declaration is simply a let constant, which indicates that by the time you can access filesResult and statusResult, both requests have finished their work and provided you with a final result.

At this point in the code, if an await didn’t throw in the meantime, you know that all the concurrent bindings resolved successfully.

Updating the view

Now that you have both the file list and the server status, you can update the view. Add the following two lines at the end of the do block:

self.files = filesResult
self.status = statusResult

Build and run. This time, you execute the server requests in parallel, and the UI becomes ready for the user a little faster than before.

Take a moment to appreciate that the same async, await and let syntax lets you run non-blocking asynchronous code serially and also in parallel. That’s some amazing API design right there!

Asynchronously downloading a file

Open SuperStorageModel.swift and scroll to the method called download(file:). The starter code in this method creates the endpoint URL for downloading files. It returns empty data to make the starter project compile successfully.

SuperStorageModel includes two methods to manage the current app downloads:

  • addDownload(name:): Adds a new file to the list of ongoing downloads.
  • updateDownload(name:progress:): Updates the given file’s progress.

You’ll use these two methods to update the model and the UI.

Downloading the data

To perform the actual download, add the following code directly before the return line in download(file:):

addDownload(name: file.name)

let (data, response) = try await 
  URLSession.shared.data(from: url, delegate: nil)
  
updateDownload(name: file.name, progress: 1.0)

addDownload(name:) adds the file to the published downloads property of the model class. DownloadView uses it to display the ongoing download statuses onscreen.

Then, you fetch the file from the server. Finally, you update the progress to 1.0 to indicate the download finished.

Adding server error handling

To handle any possible server errors, also append the following code before the return statement:

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

Finally, replace return Data() with:

return data

Admittedly, emitting progress updates here is not very useful because you jump from 0% directly to 100%. However, you’ll improve this in the next chapter for the premium subscription plans — Gold and Cloud 9.

For now, open DownloadView.swift. Scroll to the code that instantiates the file details view, FileDetails(...), then find the closure parameter called downloadSingleAction.

This is the action for the leftmost button — the cheapest download plan in the app.

So far, you’ve only used .task() in SwiftUI code to run async calls. But how would you await download(file:) inside the downloadSingleAction closure, which doesn’t accept async code?

Add this inside the closure to double-check that the closure expects synchronous code:

fileData = try await model.download(file: file)

The error states that your code is asynchronous — it’s of type () async throws -> Void — but the parameter expects a synchronous closure of type () -> Void.

One viable solution is to change FileDetails to accept an asynchronous closure. But what if you don’t have access to the source code of the API you want to use? Fortunately, there is another way.

Running async requests from a non-async context

While still in DownloadView.swift, replace fileData = try await model.download(file: file) with:

Task {
  fileData = try await model.download(file: file)
}

It seems like the compiler is happy with this syntax! But wait, what is this Task type you used here?

A quick detour through Task

Task is a type that represents a top-level asynchronous task. Being top-level means it can create an asynchronous context — which can start from a synchronous context.

Long story short, any time you want to run asynchronous code from a synchronous context, you need a new Task.

You can use the following APIs to manually control a task’s execution:

  • Task(priority:operation): Schedules operation for asynchronous execution with the given priority. It inherits defaults from the current synchronous context.
  • Task.detached(priority:operation): Similar to Task(priority:operation), except that it doesn’t inherit the defaults of the calling context.
  • Task.value: Waits for the task to complete, then returns its value, similarly to a promise in other languages.
  • Task.isCancelled: Returns true if the task was canceled since the last suspension point. You can inspect this boolean to know when you should stop the execution of scheduled work.
  • Task.checkCancellation(): Throws a CancellationError if the task is canceled. This lets the function use the error-handling infrastructure to yield execution.
  • Task.sleep(nanoseconds:): Makes the task sleep for at least the given number of nanoseconds, but doesn’t block the thread while that happens.

In the previous section, you used Task(priority:operation:), which created a new asynchronous task with the operation closure and the given priority. By default, the task inherits its priority from the current context — so you can usually omit it.

You need to specify a priority, for example, when you’d like to create a low-priority task from a high-priority context or vice versa.

Don’t worry if this seems like a lot of options. You’ll try out many of these throughout the book, but for now, let’s get back to the SuperStorage app.

Creating a new task on a different actor

In the scenario above, Task runs on the actor that called it. To create the same task without it being a part of the actor, use Task.detached(priority:operation:).

Note: Don’t worry if you don’t know what actors are yet. This chapter mentions them briefly because they’re a core concept of modern concurrency in Swift. You’ll dig deeper into actors later in this book.

For now, remember that when your code creates a Task from the main thread, that task will run on the main thread, too. Therefore, you know you can update the app’s UI safely.

Build and run one more time. Select one of the JPEG files and tap the Silver plan download button. You’ll see a progress bar and, ultimately, a preview of the image.

However, you’ll notice that the progress bar glitches and sometimes only fills up halfway. That’s a hint that you’re updating the UI from a background thread.

And just as in the previous chapter, there’s a log message in Xcode’s console and a friendly purple warning in the code editor:

But why? You create your new async Task from your UI code on the main thread — and now this happens!

Remember, you learned that every use of await is a suspension point, and your code might resume on a different thread. The first piece of your code runs on the main thread because the task initially runs on the main actor. But after the first await, your code can execute on any thread.

You need to explicitly route any UI-driving code back to the main thread.

Routing code to the main thread

One way to ensure your code is on the main thread is calling MainActor.run(), as you did in the previous chapter. The call looks something like this (no need to add this to your code):

await MainActor.run {
  ... your UI code ...
}

MainActor is a type that runs code on the main thread. It’s the modern alternative to the well-known DispatchQueue.main, which you might have used in the past.

While it gets the job done, using MainActor.run() too often results in code with many closures, making it hard to read. A more elegant solution is to use the @MainActor annotation, which lets you automatically route calls to given functions or properties to the main thread.

Using @MainActor

In this chapter, you’ll annotate the two methods that update downloads to make sure those changes happen on the main UI thread.

Open SuperStorageModel.swift and prepend @MainActor to the definition of addDownload(file:):

@MainActor func addDownload(name: String)

Do the same for updateDownload(name:progress:):

@MainActor func updateDownload(name: String, progress: Double)

Any calls to those two methods will automatically run on the main actor — and, therefore, on the main thread.

Running the methods asynchronously

Offloading the two methods to a specific actor (the main actor or any other actor) requires that you call them asynchronously, which gives the runtime a chance to suspend and resume your call on the correct actor.

Scroll to download(file:) and fix the two compile errors.

Replace the synchronous call to addDownload(name: file.name) with:

await addDownload(name: file.name)

Then, prepend await when calling updateDownload:

await updateDownload(name: file.name, progress: 1.0)

That clears up the compile errors. Build and run. This time, the UI updates smoothly with no runtime warnings.

Note: To save space on your machine, the server always returns the same image.

Updating the download screen’s progress

Before you wrap up this chapter, there’s one loose end to take care of. If you navigate back to the file list and select a different file, the download screen keeps displaying the progress from your previous download.

You can fix this quickly by resetting the model in onDisappear(...). Open DownloadView.swift and add one more modifier to body, just below toolbar(...):

.onDisappear {
  fileData = nil
  model.reset()
}

In here, you reset the file data and invoke reset() on the model too, which clears the download list.

That’s it, you can now preview multiple files one after the other, and the app keeps behaving.

Challenges

Challenge: Displaying a progress view while downloading

In DownloadView, there’s a state property called isDownloadActive. When you set this property to true, the file details view displays an activity indicator next to the filename.

For this challenge, your goal is to show the activity indicator when the file download starts and hide it again when the download ends.

Be sure to also hide the indicator when the download throws an error. Check the projects/challenges folder for this chapter in the chapter materials to compare your solution with the suggested one.

Key points

  • Functions, computed properties and closures marked with async run in an asynchronous context. They can suspend and resume one or more times.
  • await yields the execution to the central async handler, which decides which pending job to execute next.
  • An async let binding promises to provide a value or an error later on. You access its result using await.
  • Task() creates an asynchronous context for running on the current actor. It also lets you define the task’s priority.
  • Similar to DispatchQueue.main, MainActor is a type that executes blocks of code, functions or properties on the main thread.

This chapter gave you a deeper understanding of how you can create, run and wait for asynchronous tasks and results using the new Swift concurrency model and the async/await syntax.

You might’ve noticed that you only dealt with asynchronous pieces of work that yield a single result. In the next chapter, you’ll learn about AsyncSequence, which can emit multiple results for an asynchronous piece of work. See you there!

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.