I wanted async/await but I got a concurrency model

ig0r01 pts0 comments

I wanted async/await but I got a concurrency model - Igor Kulman

I remember the moment Swift announced async/await. I was genuinely relieved.

Finally, there was going to be a way out of completion handler pyramids, delegate chains, and the special kind of cognitive overhead that comes from reading code that executes in a completely different order than it is written.

I had worked with C# async/await years earlier, and my mental model came from there. In a UI app, if a SynchronizationContext is present, awaiting a Task captures it and posts the continuation back to it. In practice, that meant I could await something from the UI layer and continue on the UI thread afterwards. When I explicitly did not want that, usually in library code, I used .ConfigureAwait(false) and opted out.

That was not the whole of .NET async programming, of course. Console apps, ASP.NET Core, and custom schedulers all have their own details. But for the kind of UI code I was writing, the default felt right: stay where I started, unless I said otherwise.

That experience set my expectations for Swift async/await. I expected a nicer way to express waiting. What I got was something much larger.

What Swift actually gave us

Swift concurrency is not just async/await. It is a full concurrency model built around actors, isolation domains, Sendable, and compiler rules about which code can access which data from which context.

The goal is data-race safety. Swift 6 made that goal much more visible by turning potential data races into compiler errors in the Swift 6 language mode. Code that had previously compiled with warnings under complete strict concurrency checking now had to deal with them.

Some of those errors point to real bugs. Passing non-Sendable state between concurrent contexts can absolutely be a problem. Accessing actor-isolated state from the wrong place can absolutely be a problem. The compiler catching those cases is valuable.

But a lot of iOS code is not written like a server or a highly concurrent system. Most of it is UI-bound. Most app state lives on the main actor, even if the code does not always say that explicitly. Background work exists, but it is usually narrow and well-contained: fetch this, decode that, write to disk, come back to the UI.

For that kind of app, the problem I had was not concurrent mutation of shared state. The problem I had was unreadable control flow.

Swift solved the data-race problem with the thoroughness of a systems programming language, then made every app developer pay the conceptual cost.

The defaults matter

This is where the contrast with C# still feels important to me. In the UI code I used to write, the safe and obvious thing was the default. Await something, then continue in the UI context. If I wanted to avoid that context capture, I had to say so with .ConfigureAwait(false).

Swift started from a different place. Under the semantics clarified by SE-0338, non-actor-isolated async functions formally run on a generic executor. If you call one from actor-isolated code, such as code on the main actor, the function can hop off that actor. When the call returns, the actor-isolated caller resumes on its actor.

That rule is coherent once you understand Swift’s model. It is also exactly the kind of rule I did not expect to have to care about when all I wanted was async/await.

Swift 6.2 improves this. SE-0461, shipped as the NonisolatedNonsendingByDefault upcoming feature, changes nonisolated async functions so they run in the caller’s isolation by default. Swift 6.2 also introduced @concurrent as the explicit way to say that an async function should leave the caller’s actor and run concurrently.

That moves Swift closer to the model I expected in the first place: stay where you are unless you ask to leave. It is a welcome change, but the fact that it was needed says something about the original defaults.

There is another Swift 6.2 feature, SE-0466, that lets a module infer @MainActor isolation by default. That is useful for UI apps and scripts. It is also a separate setting from “Approachable Concurrency”, which matters because the behavior of the same code can now depend on compiler settings, language mode, upcoming feature flags, target type, and module boundaries.

That is a lot to know just to understand where an async function runs.

The complexity leaks everywhere

The difficult part is not one specific keyword. I can learn @MainActor, nonisolated, Sendable, @unchecked Sendable, nonisolated(unsafe), nonisolated(nonsending), and @concurrent. The difficult part is that these concepts interact.

Adding Sendable to one type often creates a chain of requirements through the rest of the model layer. Making a view model @MainActor can affect protocol conformances. A protocol that looks harmless can become difficult to satisfy once isolation enters the picture. A type that is fine in one module can behave differently when imported from another module...

swift async code await from actor

Related Articles