Recently, I have been working on migrating some Swift ansynchronous code to Swift Structured Concurrency. I’m working in a large codebase that dates back to when Swift was just a twinkle in Chris Lattner’s eye.

With years of developers rolling on and off the project and much code written in Objective-C and pre-Result type, I have encountered a few gotchas. Here I will go through some of the special considerations a developer needs to consider when bringing Structured Concurrency into an older codebase.

Callback Closure Contract Violations

With completion handlers, there is a contract that is expected with how they are called: The callback closure will be called exactly once and will have either a value on success or an error value on failure. Swift Structured Concurrency enforces this contract, while legacy code may violate them. With violations, it is essential to study the code to understand the intended semantics, if any, of the callback closure issues you encounter.

Invalid Callback Values

In legacy code, you might find closures that predate the Result type and are instead of the form (Success?, Error?). The issue here is that nothing enforces one and only one value being populated. The following is a table showing the possible values for this tuple and their validity with the standard callback contract:

Success Failure Valid
nil nil No
nil Error Yes
Value nil Yes
Value Error No

I have seen (nil, nil) passed in some scenarios, while I have not encountered both values being populated.

In either case, updating the closures to a Result type is a good intermediate step before updating the function to be async with structured concurrency. When doing so, take care to examine the semantics of the invalid cases. You may need to introduce additional Error cases that are ignored up the call chain or make accommodations for partial Success.

One and Only One Callback Call

When calling a callback closure function from an async method, it is necessary to wrap the call in one of the several withContinuation variants. All variants expect a resume() variant to be called on the Continuation exactly once. withCheckedContinuation variants will enforce this at compile time while withUnsafeContinuation will still expect this contract to be fulfilled, but the compiler will not enforce it. The following table shows the validity of the number of calls:

Number of Calls Valid
0 No
1 Yes
2 or more No

With the legacy callback closure code, there is no enforcement of this callback contract. Legacy methods may call the callback more than once or not at all. In the first case, a workaround may be to use mechanisms to ensure that the Continuation is only called once.

In both cases, the broken function should ultimately be updated to conform to the callback contract. Again, pay attention to the semantics of the callback abuse and make additional adjustments in client code as needed.

MainActor Issues

Add @MainActor Annotations

Some code may access views, view controllers, or other resources required to be on the main thread. Make sure to annotate any functions or Tasks that do such work with @MainActor. For example:

// A function that runs on the `MainActor`
@MainActor
func asyncFunction() async {
//...
}
// A `Task` that runs on the `MainActor`

Task { @MainActor in
//...
}

There is also a bug in Swift where withContination wrappers don’t always inherit the current async context. Supposedly this was fixed in a recent version of Xcode, but I have still seen issues. A workaround is to create a MainActor Task within the withContination wrapper like so:

await withContinuation { continuation in
    Task { @MainActor
        // Call callback closure function here
    }
}

Objective-C Considerations

async Swift functions can be exposed to Objective-C as a method that takes a completion closure:

@objc
func structuredConcurrencyMethod() async {}
- (void)objectiveCMethod {
    [object structuredConcurrencyMethodWithCompletionHandler:^() {
        // Handle callback
    }];

Objective-C doesn’t have the concept of a MainActor. If you have work that needs to be done on the main thread, use GCD to dispatch to the main queue:

- (void)objectiveCMethod {
    [object structuredConcurrencyMethodWithCompletionHandler:^() {
        dispatch_async(dispatch_get_main_queue(),^{
            // Do main thread work
        });
    }];

Refactor

If possible, try to refactor the view and resource access out of async code and into client code by communicating results up the call chain.

When to Refactor to Structured Concurrency?

Moving to structured concurrency can cause complicated and subtle issues if the callback issues above – and broader architectural issues – are not first addressed. Therefore, it is highly recommended that refactoring to structured concurrency be a later refactor after the more prominent issues have been addressed.