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.

actor FileSystemAccess {
    func readFromFile(urlString: String) {
        FileManager.default.contents(atPath: urlString)
    }
}

Simplest Solution: Make the containing method async

The simplest solution is to make the calling method async also.

class SomeNonIsolatedClass {
    let fileSystem = FileSystemAccess()

    func readFromFile(urlString: String) async {
        await fileSystem.readFromFile(urlString: urlString)
    }
}

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.

@FileSystemActor
class SomeNonIsolatedClass {
    let fileSystem = FileSystemAccess()

    func readFromFile(urlString: String) async {
        await fileSystem.readFromFile(urlString: urlString)
    }
}

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

   func nowIHaveToRunOnTheFileSystemActor() {
      // something that doesn't interact with the file system and shouldn't be run by the FileSystemActor	
   }

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.

    func readFromFile(urlString: String) {
        Task {
            await fileSystem.readFromFile(urlString: urlString)
        }
    }
 

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.

class SomeNonIsolatedClass: @unchecked Sendable {

Or do something more proper to make the type actually sendable.

struct SomeNonIsolatedStruct {
    @FileSystemActor
    let fileSystem = FileSystemAccess()

    func readFromFile(urlString: String) {
        Task {
            await fileSystem.readFromFile(urlString: urlString)
        }
    }
}

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

    func handleSomeOperation(urlString: String) {
        readFromFile(urlString: urlString)

        // We've introduced a race condition :(
        codeThatExpectsTheLastMethodToHaveBeenCompleted()
    }

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

    func performTheThing() {
        Task {
            await doThisFirst()
        }

        // other synchronous functionality

        Task {
            await doThisNext()
        }
    }

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.

    func beginProcessFile() throws {
        Task {
            // errors thrown here will not be caught
            try await processFile()
        }
    }

    func processFile() async throws {
        throw TypedError.badthings
    }

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.

class ViewModel {
    func userPressedSearchButton() {
        Task {
            // ...
        }
    }
}

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.


  1. Annotating the class like this would require FileSystemActor to be a Global Actor, which is not shown.