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

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 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:

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

fetchStatus { [weak viewModel] status in
  guard let viewModel else { return }
  viewModel.serverStatus = status
}

This is a short block of code that performs a network request and assigns its result to a property of your view model. It’s deceptively simple, yet it creates a lot of room for coding errors!

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 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 you get when you rewrite the code from earlier using modern concurrency syntax:

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

Task {
  viewModel.serverStatus = try await api.fetchStatus()
}

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:

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

  • fetchStatus() 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 can protect you from writing unsafe code 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.

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. You shouldn’t make assumptions about the app’s state after an await; although two lines of code appear one after another, they often execute some time apart.

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 =

Note: In the current version of Swift, the built-in executor runs tasks as an asynchronous sequence only. There is already, however, a proposal and a work-in-progress implementation for custom executors that would unlock much more flexible task scheduling.

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 big shortcoming of older multi-threaded APIs was that, once an asynchronous piece of code started executing, it was very difficult to meaningfully cancel that work.

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. The current code will suspend execution, the code you await will execute or let other higher-priority work run before it and finally, if your code throws, that error will bubble up the call hierarchy to the nearest catch statement.

Each await funnels the execution through a system, which prioritizes jobs, propagates cancellation, bubbles up errors, and more.

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 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, understand 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.

Insert 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. Insert 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)
}

As your learned in the previous chapter, you’ll usually use Task to code in an asynchronous context. This feels like the perfect time to dive deeper into this type!

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(for:): Makes the task suspend for at least the given duration and 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.

Note: It’s worth reiterating the fact that Task creates only top-level tasks. If you nest syntactically two or more tasks (in other words they are nested visually in your code) that doesn’t create a task hierarchy at runtime, they will all be top-level tasks.

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 likely notice UI glitches, such as the progress bar only filling up halfway sometimes. 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 could be running on any thread.

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

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 annotate specific methods, closures, or even functions — with @MainActor. This will route the annotated scope’s work to the main actor transparently.

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

Routing 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.