How to shoot yourself in the foot with Swift Concurrency - Too Many Tasks
Adopting async/await and Swift Concurrency in a large codebase can cause subtle bugs to creep in your code. Here are some things to watch out for.
Overview
Apple is strongly encouraging us to use more async methods in our code. But most of us are working with codebases built long before this paradigm was introduced. This means most of us will be refactoring our existing codebases as part of async migration.
Note: I’m expecting most of this work to occur using Swift 5 language mode. This is fine for now. But we should still be making sure we are preparing ourselves for Swift 6.
Async Problems
Changes in apis are causing us to rethink how we structure our apps. As async functionality is introduced into our code, we need to consider how the rest of our codebase needs to adapt.
Our Task? Call an async method from a synchronous context
You want to call an async method but it’s called from a synchronous context. In this example we’ve extracted file system access and isolated it. This is a valid use of actor isolation, but it will require any usage of it from outside that actor to access it asynchronously.
Simplest Solution: Make the containing method async
The simplest solution is to make the calling method async also.
But that isn’t always the right call. For a couple reasons
Can induce downstream changes
Changing the method signature may require a bunch of downstream changes. You may not have the time to handle them. Async migration can be an invasive process. It’s wise to keep the blast radius of our changes small. This helps any bugs introduced to be detected quicker.
You may not own the code that uses it
You may not be able to change the method signature because you don’t own the code that relies on it.
Another Solution: Isolate the containing class
Another option is to annotate the containing class with the same actor keyword. This allows methods from FileSystemAccess to accessed synchronously.
Also a simple fix 1. But that can introduce constraints to the rest of the class you may not want.
Forces other code the be isolated to the same actor
What to do?
Asking your favorite LLM will give you advice like this:
To call an asynchronous method from synchronous code, you need to use Task to create a new asynchronous context.
Put a Task on it
This is simple enough, and will work in many cases.
Done?
Well, not quite. This will compile in Swift 5 mode, but won’t compile in Swift 6. You’d have to declare the original class as @unchecked: Sendable.
Or do something more proper to make the type actually sendable.
Task Problems
We were able to create an asynchronous context in a synchronous method.
For better for worse, creating a Task to run asynchronous code will be a tempting choice as we transition our code.
Be careful.
While this can work for many cases, there are problems lurking.
Early exit
The code inside the Task block won’t be executed until after the method completes. This means the method didn’t complete what it was asked to do. While this is common in legacy codebases, it’s not clear to whoever calls the method.
Even if code like this continues to work, it’s easy to introduce a breaking change without it being detected.
Unknown order of execution
Despite appearing like the doThisFirst() will occur before doThisNext(), this isn’t guaranteed. Each Task will be run after the containing method exits. But the order is not guaranteed. Similar to the previous example, even if the code works now. It’s easy to introduce a breaking change without it being detected.
Multiple tasks can introduce performance problems
There is a performance cost when introducing async operations. This is often overlooked. Devs often jump through a lot of async hoops to improve performance and end up making things worse.
Swallows exceptions
An exception thrown in a new Task context will not report its exceptions to the calling function. The method will have returned before the exception is thrown. This can cause problems to be undetected.
Better approaches when initiating a Task
Less Tasks
Less is more. Do your best to get the most out of each Task created. This reduces the blast area of your code prone to future bugs.
Response to user input
This is a good time to create a async context. We already expect whatever we initiate to occur in the near future.
In short, use less tasks and keep them close to the view layer.
Nothing new under the sun
We had the same type of issues using GDC. But async is forcing us to think about threading in a lot more areas.
Make sure you and your team understand what to look out for when refactoring large projects for async migration.
-
Annotating the class like this would require FileSystemActor to be a Global Actor, which is not shown. ↩