When we use await in our code, we introduce potential suspension points. I’m purposely using potential here, since it’s not guaranteed that our code would actually suspend and wait. We’ve learned this in the threading module.
Writing thread-safe code using Swift Concurrency is the first step towards a stable code base. The next step is to create a performant codebase where code suspends only when it’s really needed. We’ve just optimized the gradient generator app’s performance by introducing parallelism. This lesson will guide you in reducing suspension points by efficiently using the techniques we’ve learned in earlier modules.
Note
I recommend reading the Understanding Task Suspension Points before this lesson to refresh your knowledge on suspension points.
Understanding the suspension surface area
Before we dive into techniques, it’s important to build intuition around what we’re trying to optimize. Every await introduces a potential suspension, but the bigger problem is often how much code lives between two suspension points. The larger that region becomes, the harder it is to reason about:
- Actor invariants
- Performance
- Thread hops
- Potential reentrancy
- Consistency of state
Think of each await as a border crossing between isolation domains. Our goal in this lesson is to:
- Do as much work as possible before a border crossing
- Cross it once
- Finish the job
- Only cross again when necessary
This is the mindset behind every technique you’ll learn.
Will code always suspend when using await?
Let’s make this clear right away: your code is not guaranteed to suspend when using await. If the isolation domain matches, your code might be optimized to run without suspension.
Suspension can only occur at an await, but an await does not guarantee suspension.
How to control suspension points
The most important part of this lesson is knowing how we can control suspension points. It would be redundant to rewrite lessons you’ve already touched, so I’m referencing them here instead.
Use synchronous methods if possible
A very effective way to control suspension is to isolate your essential logic into synchronous helper methods. A synchronous method (not async) is guaranteed never to suspend, which means:
- No actor reentrancy
- No unexpected hops between executors
- Fully consistent actor state during the method
- Zero hidden suspension points
This makes total sense, but sometimes we’re so focused on concurrency that we mark each method with async, even if it’s not needed.
Here’s an example in which we do image processing. Before, you might add async to all the image processing methods:
private func scale(_ image: CGImage) async { ... }
func process(_ image: CGImage) async {
let image = await scale(image)
// ... more processing
}
However, since the scale(_) method is private, we can only call it from inside the current instance. process already runs asynchronous, so we might as well mark the scale method as synchronous:
private func scale(_ image: CGImage) { ... }
func process(_ image: CGImage) async {
let image = scale(image)
// ... more processing
}
Even though the outcome could be similar due to optimizations, we’re now controlling the suspension points ourselves by removing unnecessary asynchronous attributions.
Preventing Actor Reentrancy
A common mistake while writing concurrency code is introducing actor reentrancy. You’re basically re-entering the same actor. We’ve discussed this in detail in the lesson Understanding Actor Reentrancy.
Instead, you want to restructure your code so you’re using your actor’s isolation efficiently. In other words, only step out of the actor isolation when all the work is done. This isn’t always possible, but with the learnings from the dedicated lesson, you should come a long way.
Using nonisolated(nonsending) and @concurrent
Not all your code needs to jump to a different isolation domain. In fact, many of the applications we write can be single-threaded for a long time. This is why the Swift team introduced approachable concurrency (we’ll discuss this in detail in the Migration module of this course).
A related feature of approachable concurrency is nonisolated(nonsending), which we discussed in detail in the lesson Dispatching to different threads using nonisolated(nonsending) and @concurrent (Updated for Swift 6.2).
In short, methods that are nonisolated(nonsending) will inherit the isolation domain of the caller, preventing a suspension point.
Inheritance of actor isolation using the #isolation macro
This is very much a reminder of the lesson Inheritance of actor isolation using the #isolation macro. In other words: if you can inherit the existing isolation domain, you’ll remove another suspension point.
Prefer non-suspending APIs when you only need a quick check
Some APIs have both suspending and non-suspending versions. For example, the cancellation APIs. If you’re inside a critical section and just want to know if you should stop execution:
if Task.isCancelled {
return
}
Instead of using the asynchronous variant:
try await Task.checkCancellation() // Potentially introduces a suspension point
It depends on the code you want to use whether this is an option. For the above example, you might not be able to use Task.isCancelled in case you want an error to be thrown on cancellation.
Embrace parallelism
Features like Task Groups and async let allow you to run multiple tasks at the same time. This won’t necessarily result in fewer suspension points, but the total time spent waiting for work to complete will be reduced by running tasks in parallel.
Suspension point reduction checklist
Whenever you write await, ask yourself:
- Can this be synchronous?
- Can I move this await to a higher-level function? (image processing example)
- Am I accidentally hopping between isolation domains?
- Would
nonisolated(nonsending)remove the suspension? - Is there a non-suspending API available?
- Should this be merged with another await using
async letor task groups?
These questions help you reason about suspension consistently and write more predictable, performant async code.
Summary
In this lesson, we’ve learned how to control suspension points. You can not always prevent a suspension point, but Swift Concurrency offers features that ensure a suspension won’t occur. With these techniques at hand, it’s time to dive into how you can use Xcode Instruments to indicate suspension points.