March 5, 2024·6 min read·engineeringswiftconcurrencyactors

Swift 5.10 closes gaps in data isolation checking

Complete concurrency checking now diagnoses data-race risks across the language. Global state and actor-isolated initialization explain why the last gaps matter.

A singleton can violate actor isolation before any application code explicitly mutates it. Its first access may run an initializer on the wrong executor. Swift 5.10 uses this case to explain a gap in earlier concurrency checking, and why completing the checking model requires more than recognizing async functions and actor methods.

The March 5 release announcement describes full data isolation enforcement when complete concurrency checking is enabled. The compiler diagnoses potential data races throughout the language, subject to explicit unsafe opt-outs. This is an opt-in checking configuration for Swift 5.10. The announcement describes enforcement by default in a future Swift 6 language mode as the next step, so the release should not be read as silently switching every existing project into that mode.

I value Swift's effort to make mutation understandable from the program's types. Value semantics help, but references inside values can still share mutable objects. Concurrency checking has to account for those relationships as well as the storage visible at the immediate call site.

Initialization is an isolation boundary

The announcement presents a main-actor-isolated class containing a static singleton. Its private initializer calls MainActor.assertIsolated(). An asynchronous function outside the actor reads that singleton synchronously. In Swift 5.9, complete checking failed to diagnose the access, even though the assertion could fail at runtime.

The issue is the initializer's execution context. The static property computes its initial value on first access. A synchronous access from outside the main actor can therefore evaluate main-actor-isolated code outside that actor. Thread-safe initialization of a static value does not itself establish the actor context required by the initializer.

Swift 5.10 warns that the expression is asynchronous and needs await. One correction keeps the class isolated and makes the access asynchronous, as the announcement recommends. This reduced example shows that relationship without the diagnostic-producing call.

@MainActor
final class Model {
    private init() {
        MainActor.assertIsolated()
    }

    static let shared = Model()
}

func obtainModel() async {
    let model = await Model.shared
    _ = model
}

Another correction is to isolate the calling function to the main actor. The release also describes moving the initializer and static property outside actor isolation, with actor-dependent work moved into a separate isolated method. These alternatives express different ownership of the work. Choosing between them depends on whether initialization itself needs the actor, or whether only later operations do.

This example explains why merely labeling an entry point async is insufficient. An asynchronous function outside a particular actor does not acquire that actor's isolation for all expressions in its body. The access must respect the isolation attached to the declaration it uses. The new warning identifies that mismatch before the runtime assertion does.

Global storage defeats local reasoning

SE-0412, strict concurrency for global variables, explains another problem addressed by the release. Uncaptured local variables have a limited context of access. Actor isolation can restrict access to an object's stored state. Global and static storage remains accessible through independent paths, so those local restrictions do not by themselves establish safety.

Under strict concurrency checking, global storage must have global actor isolation or be immutable with a Sendable type. Both parts of the latter condition matter. A constant binding prevents reassignment of the binding. It does not automatically prevent mutation of an object reachable through it. A Sendable requirement addresses whether the value can safely cross concurrent contexts.

A static dictionary used as a cache is therefore relevant even when its enclosing declaration is a structure. The dictionary's storage lasts beyond an individual instance and can be reached by independent tasks. Being declared inside a value type does not turn that shared storage into a separate copy for each caller.

The proposal explicitly includes static member variables in its definition of globals. It also explains that lazy initialization already has a thread-safety guarantee. The checking rule concerns the ways programs can subsequently access the value, and any isolation required by its type or declaration. Initialization safety and ongoing access safety answer different questions.

The compiler does not insert a transaction

SE-0412 considers automatically locking global accesses and rejects that as the general solution. Locking an individual read and an individual write would still permit interference between them. An expression that reads a global, computes a result and writes it back needs protection over the entire logical operation if callers require atomic behavior.

For example, doubling a shared value involves an observation followed by an update. If another caller changes the value between those steps, separate locks around each access do not make the overall operation indivisible. This is why replacing a diagnostic with invisible locking would give a misleading account of the program's synchronization.

The proposal also considers placing globals on the main actor automatically. Its preferred approach makes the developer choose the isolation or change the declaration, perhaps by making it an immutable value. That choice is useful because the main actor is a semantic commitment about access to the state. It is not a generic storage repair that every global should acquire implicitly.

I prefer an explicit requirement here. A cache that belongs to a particular actor and a process-wide immutable configuration value have different access rules. Making those rules visible in declarations gives a reviewer something more concrete than a convention about which functions are allowed to touch a variable.

Unsafe annotations narrow the unchecked area

The release introduces nonisolated(unsafe) for storage that uses synchronization the compiler cannot prove. It can annotate stored properties, local variables and global or static variables. The announcement contrasts this with @unchecked Sendable, which exempts an entire type from the ordinary proof of sendability.

This narrower annotation matters for an implementation that protects one shared variable with a dispatch queue. The compiler may have no representation of the rule that every access occurs on that queue. Marking the storage unsafe records where the static checking obligation ends. It does not create the queue, redirect accesses to it or verify that the convention holds.

An unchecked type conformance has a wider effect. Callers may treat values of that type as sendable based on the programmer's assertion. If only one field needs special treatment, exempting the whole type removes more checking than the implementation requires. The storage-level annotation lets the rest of the type continue participating in ordinary analysis.

The same issue arises with synchronization implemented in operating-system facilities or in C, C++ and Objective-C code. A language-level checker cannot infer every external synchronization discipline. The release preserves a way to express such designs while making their unchecked portion visible in source.

Runtime tools still have a role around these declarations. The announcement specifically mentions exclusivity enforcement and Thread Sanitizer as ways failures may become visible when the manually supplied synchronization is incorrect. Suppressing a warning supplies no new protection to the actual memory access.

Complete checking and precise checking differ

The release announcement acknowledges that Swift 5.10 diagnoses some code that additional analysis could prove safe. Completing coverage of the concurrency model does not mean the compiler accepts every safe program. A checker can conservatively reject a transfer because it lacks enough information to rule out concurrent access.

One area identified for further language work is passing non-Sendable values across isolation boundaries when the compiler can establish that concurrent access is impossible. Another is improving sendability inference for functions and key paths. As of this release, these are described as ongoing work toward Swift 6. They should not be assumed available merely because Swift 5.10 completes the isolation model.

The distinction affects interpretation of migration warnings. Some diagnostics identify a real shared-state problem, such as the singleton initializer running outside its actor. Others reflect conservative limitations of the analysis. An unsafe annotation can suppress either kind, but it cannot establish which kind the application encountered.

Checking can precede a language-mode change

The compiler option shown in the release is -strict-concurrency=complete. Enabling it makes the stronger analysis available while projects remain on Swift 5.10. The announcement describes warnings for the examples, giving maintainers a way to examine concurrency obligations before a later language mode makes them default requirements.

This separates obtaining a compiler from deciding when to adopt stricter language semantics. The official binaries cover macOS, Windows and Linux, while the checking model concerns Swift declarations and their access rules. A project can therefore investigate its shared state using this release without waiting for the next major language version.

The useful result is a more complete description of what the program requires. Actor-isolated initialization must execute in the correct isolation context. Shared globals need an explicit safe access model. Where external synchronization supplies that model, the annotation identifies the storage whose correctness still depends on the implementation's own discipline.