Chapter 5: Intermediate async/await & CheckedContinuation¶
In the previous chapter, you worked through creating custom asynchronous sequences. At this point, you should already feel right at home when it comes to using AsyncSequence
and AsyncStream
.
You saw that wrapping existing APIs, like Timer
and NotificationCenter
, is very powerful, letting you reuse your tried-and-tested code in your modern async
/await
codebase.
In this chapter, you’ll continue working in the same direction. You’ll look into more ways to reuse existing code to the fullest by leveraging Swift’s superpowered concurrency features.
Introducing continuations¶
Two patterns form the cornerstone of asynchronous programming on Apple platforms: callbacks and the delegate pattern. With completion callbacks, you pass in a closure that executes when the work completes. With the delegate pattern, you create a delegate object, then call certain methods on it when work progresses or completes:
To encourage the new concurrency model’s adoption, Apple designed a minimal but powerful API that comes in handy when bridging existing code. It centers around the concept of a continuation.
A continuation is an object that tracks a program’s state at a given point. The Swift concurrency model assigns each asynchronous unit of work a continuation instead of creating an entire thread for it. This allows the concurrency model to scale your work more effectively based on the capabilities of the hardware. It creates only as many threads as there are available CPU cores, and it switches between continuations instead of between threads, making it more efficient.
You’re familiar with how an await
call works: Your current code suspends execution and hands the thread and system resources over to the central handler, which decides what to do next.
When the awaited function completes, your original code resumes, as long as no higher priority tasks are pending. But how?
When the original code suspends, it creates a continuation that represents the entire captured state at the point of suspension. When it’s time to resume execution or throw, the concurrency system recreates the state from the continuation and the work… well, continues.
This all happens behind the scenes when you use async
functions. You can also create continuations yourself, which you can use to extend existing code that uses callbacks or delegates. These APIs can benefit from using await
as well.
Manually creating continuations allows you to migrate your existing code gradually to the new concurrency model.
Creating continuations manually¶
There are two continuation API variants:
- CheckedContinuation: A mechanism to resume a suspended execution or throw an error. It provides runtime checks for correct usage and logs any misuse.
- UnsafeContinuation: An alternative to
CheckedContinuation
, but without the safety checks. Use this when performance is essential and you don’t need the extra safety.
Note: The APIs are essentially identical, so you’ll only work with CheckedContinuation
in this chapter. For any function mentioned in this chapter that has “checked” in its name, you can assume there’s an “unsafe” equivalent as well.
You don’t normally initialize a continuation yourself. Instead, you use one of two handy generic functions that take a closure. The closure provides a ready-to-use continuation as an input parameter:
- withCheckedContinuation(_:): Wraps the closure and gives you a checked continuation back.
- withCheckedThrowingContinuation(_:): Wraps a throwing closure. Use this when you need error handling.
You must resume the continuation once — and exactly once. Enforcing this rule is the difference between checked and unsafe continuations. You resume a continuation by using one of the following ways:
- resume(): Resumes the suspended task without a value.
- resume(returning:): Resumes the suspended task and returns the given value.
- resume(throwing:): Resumes the suspended task, throwing the provided error.
- resume(with:): Resumes with a
Result
containing a value or an error.
The methods above are the only ones you can call on a continuation, which is yet another easy-to-use, minimal type.
Next, you’ll wrap CLLocationManagerDelegate
to learn how to quickly use continuations to reuse your existing code.
Wrapping the delegate pattern¶
In this chapter, you’ll continue working on the Blabber project, starting where you left off at the end of the last chapter. If you’ve worked through the challenges, just keep up the great work. Otherwise, you can start with this chapter’s starter project, which includes the solved challenge.
Start the book server now, if you haven’t already. Navigate to the server folder in the book materials-repository, 00-book-server, and enter swift run
. The detailed steps are covered in Chapter 1, “Why Modern Swift Concurrency?”.
Some APIs use the delegate pattern to continuously “talk” to their delegate — for example, to send progress updates or notifications about app state changes. When you need to handle multiple values, you should use an AsyncStream
to bridge the delegate pattern to newer code.
In other cases, like in this chapter, you’ll need to handle a single delegation callback or a completion — and that’s the perfect opportunity to use a continuation!
In the next few sections, you’ll focus on letting the users share their location in chat:
When you work with location data, you need to reach out to one of the oldest frameworks in iOS: CoreLocation
.
Note: Offering apps that were capable of providing location-based services was one of the iPhone 2’s killer features — and one of the reasons why it became a huge success. CoreLocation
is one of the frameworks that iOS 2 initially made available to third-party developers.
As a classic API, CoreLocation
heavily relies on delegates, making it a perfect candidate for you to learn how to interoperate between async
/await
code and those older patterns.
The main type you usually deal with in the CoreLocation
framework is CLLocationManager
. When you ask this type to start location updates, it repeatedly calls its delegate with the current device location:
In Blabber, you don’t want to share the user location continuously, but only once — when the user taps the location button. Still, the location manager doesn’t provide a callback API that lets you get just a single location. You’ll need to create your own delegate type and code the logic to stop updates after the first location comes through.
Open BlabberModel.swift and scroll to shareLocation()
. This method is already wired to the location button in the chat screen:
Managing the authorizations¶
You’ll get started by creating a location manager and verifying that the user has authorized the app to use the device location data. At this point, users who are running the app for the first time will see the standard system dialogue that asks them to grant authorization:
Before dealing with any CoreLocation
-specific work, add the following code that creates a continuation:
let location: CLLocation = try await
withCheckedThrowingContinuation { [weak self] continuation in
}
withCheckedThrowingContinuation(_:)
takes a throwing closure, suspends the current task, then executes the closure. You should call your asynchronous code from within the closure, then resume the continuation
argument when you’re done. In this case, you’ll resume with an error or a location.
You can pass continuation
around like any other variable, storing it in your model or passing it over to other functions. Wherever it ends up, calling one of its resume(...)
methods will always resume the execution at the original call site.
Also, you might have noticed that the function contains “checked” in its name. That indicates the runtime checks if you use the continuation safely.
Build and run the project. Tap the location button, and then tap Allow While Using App in the privacy dialogue:
Log in and tap the location button. You won’t see an error onscreen. However, look at the Xcode console output, and you’ll see the following, between other logs:
SWIFT TASK CONTINUATION MISUSE: shareLocation() leaked its continuation!
The runtime detected that you never used continuation
and that the variable was released at the end of the closure. Long story short, your code at try await withCheckedThrowingContinuation(...)
will never successfully resume from its suspension point.
As mentioned earlier, you must call a resume(...)
method exactly once from each code path.
Next, you’ll fix this by integrating your continuation
with a newly minted delegate.
Handling the location errors¶
Open Utility/ChatLocationDelegate.swift, where you’ll find the placeholder type ChatLocationDelegate
. Notice that all the CLLocationManagerDelegate
requirements are optional, so the file compiles without any of CLLocationManagerDelegate
’s methods.
You’ll add two methods to handle location updates and location errors.
First of all, inside the class definition, add a new type alias for a throwing continuation that returns a location:
typealias LocationContinuation = CheckedContinuation<CLLocation, Error>
That alias name will make your code a little less verbose.
Since your delegate holds on to the continuation until it receives a location, you need to store it in a property. You’ll also add a CLLocationManager
to feed your proxy delegate with any updates. Add both of these properties, like so:
private var continuation: LocationContinuation?
private let manager = CLLocationManager()
You also need a new initializer so you can inject the continuation and also ask the location manager for the needed permissions. Add that next:
init(continuation: LocationContinuation) {
self.continuation = continuation
super.init()
manager.delegate = self
manager.requestWhenInUseAuthorization()
}
First of all, note the use of super.init()
; this lets you set self
as the location manager’s delegate before requesting authorization.
Then, you call requestWhenInUseAuthorization()
to show the system privacy dialogue, which sets the authorization status on the manager object. If the user has already granted permissions, the method does nothing. You’ll deal with the various authorization values later in the chapter.
Note: You’ll need to grant location permissions to continue with this chapter. If you denied location usage by mistake, or you wanted to test what happens if you rejected the permissions, don’t worry — just delete the app from the iOS Simulator. The next time you run the project, you’ll get the authorization dialogue again.
The first delegate method you need is the one that gets called when the location permissions update. This happens when the permissions have been granted and immediately after the location manager is created. Add the following delegate method to ChatLocationDelegate
:
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .notDetermined:
break
case .authorizedAlways, .authorizedWhenInUse:
manager.startUpdatingLocation()
default:
continuation?.resume(
throwing: "The app isn't authorized to use location data"
)
continuation = nil
}
}
If the user hasn’t responded to the permissions request, which would happen the first time they run the app, you do nothing. If they’ve granted the permissions, you tell the location manager to start getting location data. Otherwise, you’ll resume the continuation with an error.
After resuming, you destroy the continuation because doing anything else with it is illegal.
Next, add the delegate method that’s called when the user’s location updates:
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.first else { return }
continuation?.resume(returning: location)
continuation = nil
}
The locations
argument contains a list of CLLocation
values. Here, it’s safe to take the first one and pass it on to your own code.
Additionally, you call continuation?.resume(returning:)
to resume the original code execution and return the first location from the suspension point:
Finally, just like before, you set the continuation
property to nil
.
Next, add error handling via locationManager(_:didFailWithError:)
:
func locationManager(
_ manager: CLLocationManager,
didFailWithError error: Error
) {
continuation?.resume(throwing: error)
continuation = nil
}
If the manager fails to fetch the device location, it calls this method on its delegate so you can update your app accordingly.
You use continuation?.resume(throwing:)
to resume your original code at the suspension point and throw the given error:
At the end, as you did before, you set continuation
to nil
to release the continuation you just used.
You now have the complete workflow in place: Once you set up the location manager with the delegate, it will try to fetch the current location and will call one of the methods you’ve wired to use the injected continuation
:
Additionally, when the continuation resumes, you reset continuation
so you can’t use it more than once.
This concludes the setup. Now, it’s time to start the updates and set the whole machinery in motion.
Using your delegate¶
Inside the closure of withCheckedThrowingContinuation(_:)
, insert the following:
self?.delegate = ChatLocationDelegate(continuation: continuation)
You just created a ChatLocationDelegate
and injected the continuation
you got from withCheckedThrowingContinuation(_:)
to it. You store the resulting delegate to a predefined delegate
property to make sure it isn’t immediately released from memory.
After that, what happens is:
- The manager calls the change authorization delegate method when it initializes.
- After the user grants permissions, the manager fetches the device location.
- The manager calls the delegate with an array of
CLLocation
s. - The delegate calls
continuation
and resumes by returning the first availableCLLocation
. - The original call site
let location: CLLocation = try await withCheckedThrowingContinuation ...
resumes execution, letting you use the returned location value.
To test the result, append the following code at the very bottom of the function, after withCheckedThrowingContinuation
:
print(location.description)
When the process completes, you’ll see the location object printed in Xcode’s console.
Build and run. Tap the location button to give the new feature a try.
There could be two outcomes of this. You will either see a location printed in the output console if you’ve used Xcode to simulate location data in the past. Alternatively, you will see an error like so:
By the way — how cool is that?
You didn’t write any special code to handle the error — you pipe in the error from your delegate, then your continuation
re-throws it. Finally, you catch the error seamlessly in the button action. For the last mile, the starter SwiftUI code updates lastErrorMessage
on the chat view, which pops the alert box onscreen.
If you haven’t tested location-aware apps in Xcode before, you need to enable location data in the iOS Simulator.
At the bottom of the code editor in Xcode, click the location button and pick one of the default locations in the list:
Once you’re feeding location data successfully into the iOS Simulator, you’ll see the location icon fill with color:
Log in again and tap the location button. This time, you’ll see the coordinates of your selected location in the console:
<+19.01761470,+72.85616440> +/- 5.00m (speed -1.00 mps / course -1.00) ...
Great work so far! You’ve gone through setting up a continuation and creating a proxy delegate. Now, you can apply this approach in basically any scenario.
To exercise this routine one more time, you’ll look into wrapping up a callback-based API next.
Wrapping callback APIs with continuation¶
In the system frameworks that Apple introduced after iOS 4, most asynchronous APIs are callback-based.
That means that when you call a given method, you provide a parameter with a closure that executes asynchronously when the method finishes its work.
For example, if your app wants to request authorization to provide parental controls, you need to call AuthorizationCenter.requestAuthorization(completionHandler:)
from Apple’s FamilyControls framework, like so:
AuthorizationCenter.shared
.requestAuthorization { result in
}
Calling this API displays the system UI that asks for authorization, if necessary. After an arbitrary amount of time, depending on the user’s actions, it calls back to your closure. It returns the authorization status via the result
closure argument.
Having a single closure is arguably a little easier to wrap with a continuation than creating a separate delegate type, as you did earlier in the chapter.
In this section, you’ll continue working on BlabberModel.shareLocation()
by wrapping a custom callback-based API that turns a location into a human-readable address.
The Blabber starter project includes a custom type called AddressEncoder
. It converts a location to a human-readable address via a classic callback API: AddressEncoder.addressFor(location:completion:)
.
In this section, you’ll work through calling that API and using a continuation to make it fit seamlessly with the rest of your asynchronous code.
Creating the closure¶
Open BlabberModel.swift and scroll back to the method called shareLocation()
, where you added your delegate wrapping code.
To make the new call to AddressEncoder
, add this code at the bottom of shareLocation()
:
let address: String = try await
withCheckedThrowingContinuation { continuation in
}
You start this section the same way that you approached wrapping CLLocationManager
’s delegate — by calling withCheckedThrowingContinuation(_:)
to create a closure with a continuation to control asynchronous execution.
This time, you’ll return a String
when you resume. That string will be the human-friendly address for the location coordinates you already have.
Now, insert this code inside the closure:
AddressEncoder.addressFor(location: location) { address, error in
}
Here, you call addressFor(location:completion:)
. In the completion callback, you receive an optional address and an optional error.
This is, unfortunately, a common pattern in Swift APIs, especially before the official introduction of the Result
type.
This pattern opens the code for undesired scenarios — for example, when the closure receives both a nil
result and a nil
error…
You’ll have to make the best of the situation and try resuming with the correct behavior for each callback outcome. Add this switch
inside the callback closure from above:
switch (address, error) {
case (nil, let error?):
continuation.resume(throwing: error)
case (let address?, nil):
continuation.resume(returning: address)
}
You switch over address
and error
:
- When you get an error, you pipe it through to the continuation via
continuation.resume(throwing:)
. - On the other hand, if you get an address back, you return it via
continuation.resume(returning:)
.
So far, so good — but the compiler now complains that you need to handle all the possible combinations.
Add two more cases inside the switch
statement to handle any unexpected callback input:
case (nil, nil):
continuation.resume(throwing: "Address encoding failed")
case let (address?, error?):
continuation.resume(returning: address)
print(error)
}
- If you get
nil
for both the address and the error, that’s clearly some kind of unknown error, so you throw a generic error: Address encoding failed. - If you get both an address and an error, you return the address — but also print the error so that the message remains in the app’s log.
That clears the compiler error, and you cover all the bases when it comes to unexpected callbacks from AddressEncoder
.
Note: If you already peeked into the source code of AddressEncoder
, you know that it will never call the completion closure with incorrect parameters. However, you can’t do that for APIs where you don’t have access to the source code. That’s why it’s important to handle invalid API usage defensively.
It’s time for the final line in shareLocation()
. After your new withCheckedThrowingContinuation
, append:
try await say("📍 \(address)")
Once you have the address as a string, you call BlabberModel.say(_:)
to share it in chat.
Build and run one more time. Enable location simulation in Xcode and tap the location button in the app:
With that last addition to the Blabber app, you’ve covered most of the continuation APIs, and you’ve used continuations to bridge delegates and callbacks. Doing this will allow your async
/await
code to work alongside your existing codebase, not against it.
You’ll continue working with Blabber in the next chapter, where you’ll learn more about debugging and testing your asynchronous code.
If you’d like to work through one more exercise, stay around for this chapter’s optional challenge.
Challenges¶
Challenge: Build a command-line version of Blabber¶
This is an optional challenge that you can try on your own to exercise some of the concepts of the last few chapters.
In this challenge, you’ll build the Clipper app: a CLI (Command Line Interface) version of Blabber. It lets you grab a user name and chat with friends from a Terminal window.
In the introduction section of Chapter 1, “Why Modern Swift Concurrency?”, you covered platform restrictions for Swift concurrency features. As a reminder, you’re building a macOS app here. That means that if you’re using Xcode 13.2 or newer, you can run this challenge on macOS 10.15 or later. If you’re using an earlier version of Xcode 13, you have to be running macOS 12.
This project’s twist is that, as a command-line app, it’s an exercise in creating a complete chat app in about 30 lines of code.
When you’ve successfully completed the challenge, you’ll be able to open multiple Terminal windows and chat between your alter egos.
Open the starter challenge project for this chapter by double-clicking Package.swift. The Clipper app consists of a single source file called main.swift.
Inside, you’ll find:
- A long-living
URLSession
for live updates calledliveURLSession
. - One
Task
that accesses the/cli/chat
server endpoint. Its job is to print the chat messages. - A second
Task
that iterates over the standard user input and sends any messages the user enters to the server.
Believe it or not — that’s the complete chat app!
You can try completing the code on your own; if you prefer the guided tour, however, follow these steps:
- Inside the
do
block in the firstTask
, get abytes
stream of theurl
address defined in the task. Then, iterate over the lines, like you did in previous chapters, and print each line. UseliveURLSession
so the request doesn’t time out.
That’s all! To test the chat, follow these steps:
- Start the book server.
- Open multiple Terminal windows.
- In each window, change the current directory to the Clipper folder — the folder containing Package.swift.
- In each window, type
swift run Clipper [username]
and replace [username] with the chat name you’d like to use. - You’ll see a message from the server confirming you’re connected:
[username connected]
. - Now, you can chat by entering messages in each Terminal.
Key points¶
- You bridge older asynchronous design patterns to
async
/await
by usingCheckedContinuation
or its unsafe counterpart,UnsafeCheckedContinuation
. - For each of your code paths, you need to call one of the continuation’s
resume(...)
methods exactly once to either return a value or throw an error. - You get a continuation by calling either
withCheckedContinuation(_:)
orwithCheckedThrowingContinuation(_:)
.