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
insidefetchServerStatus()
. 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 invokecompletion
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.
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.
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.
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.
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 beforethrows
or the return type. Call the function by prependingawait
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 prependingawait
, 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 DownloadFile
s 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.
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 DownloadFile
s.
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:
- Check if you already fetched the file list; if not, you call
availableFiles()
to do that. - 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.
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:
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!