5.
Intermediate async/await & CheckedContinuation
Written by Marin Todorov
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 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 of asynchronous programming have dominated Apple platforms for a long time: 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 the execution 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
CheckedContinuationin 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
Resultcontaining 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. Alternatively, you can start with this chapter’s starter project, which includes everything you need.
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.
CoreLocationis 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. You’ll create your own location delegate type and code the logic to fetch the user’s coordinates.
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.
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. 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.
Building up the Location Manager
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. Add the property, like shown below:
private var continuation: LocationContinuation?
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(manager: CLLocationManager, 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. After having made use of async/await in the last few chapters, do you already feel like delegates are a drag? That’s not unreasonable — delegates can make solving some simpler tasks a lot more verbose than they need to be.
Now, it’s time to start the updates and set the whole machinery in motion.
Using Your Delegate
Open BlabberModel.swift again and add these two properties to the class:
private let manager = CLLocationManager()
private var delegate: ChatLocationDelegate?
The first one is the location manager, which you will reuse whenever the user taps the location button. The latter is the location delegate which you will recreate each time you need to fetch the user’s location.
Then, in shareLocation() inside the closure of withCheckedThrowingContinuation(_:), insert the following:
self?.delegate = ChatLocationDelegate(manager: manager, continuation: continuation)
if manager.authorizationStatus == .authorizedWhenInUse {
manager.startUpdatingLocation()
}
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
CLLocations. - The delegate calls
continuationand 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)
manager.stopUpdatingLocation()
delegate = nil
When the process completes, you’ll see the location object printed in Xcode’s console. Finally, you stop the location updates and reset delegate because you don’t need it anymore, once you get a location.
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.
There’s a small wrinkle here though, which exposes a useful learning opportunity. If you don’t enable location simulation, the location manager only throws an error the first time you ask it for a location. This means that if you ask for a location again, the manager does not call its delegate methods, which means your continuation will never resume.
You can try this out by tapping the location button again in the chat window, making sure you haven’t turned on location simulation from Xcode. If you are seeing a location in the console, switch to another simulator or reset the one you are working on. You will see the console error about leaking the continuation that you saw earlier.
What’s happening? When you tap the location button, a new delegate is created, and the old one is destroyed. This takes the continuation with it, and the continuation has not been resumed, which is against the rules. You could possibly see something like this in real-world use if you hit the button multiple times before a location fix was obtained.
To make absolutely sure the continuation always resumes, open ChatLocationDelegate.swift and add the following code to ChatLocationDelegate:
deinit {
continuation?.resume(throwing: CancellationError())
}
Throwing a cancellation error is the standard way to say that your async code can stop doing its work. You probably don’t want to display this in an alert, though, so open ChatView.swift and find the code that calls model.shareLocation(). Above the catch block, add the following code to catch and ignore cancellation errors:
} catch is CancellationError {
// Ignore cancellation errors.
This will prevent cancellation errors being displayed to the user. After that brief diversion, you can get back to location handling. 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
nilfor 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
URLSessionfor live updates calledliveURLSession. - One
Taskthat accesses the/cli/chatserver endpoint. Its job is to print the chat messages. - A second
Taskthat 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
doblock in the firstTask, get abytesstream of theurladdress defined in the task. Then, iterate over the lines, like you did in previous chapters, and print each line. UseliveURLSessionso the request doesn’t time out. -
In the second
Task, iterate over the standard inputFileHandle.standardInputbytes sequence. That’s the input the user enters into the command line. For each line, do the following:- Create a
URLRequestwith the URL defined in the task. Set the method toPOSTand the body to the data representation of “[username] line”. - Send the request by calling
URLSession.data(for:delegate:). - Catch request errors and handle them the same way as you dealt with the first
catchin the starter code.
- Create a
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/awaitby usingCheckedContinuationor 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(_:).