PureXML/third_party/rules-swift/concurrency.md

20 KiB

Swift 6 Strict Concurrency

Concurrency posture for TileKit and Tiledown: immutability first, sparse actors, escape hatches always justified, grounded in the Swift Evolution proposals.

Triggers: async, await, actor, Sendable, @MainActor, Task, nonisolated, sending, AsyncSequence, AsyncStream, withTaskGroup, @unchecked, isolation, data race, cross-actor.

Canonical source: Swift Evolution proposals. Each rule below points at the proposal(s) that introduced and justify the feature. When in doubt about a rule, re-read the proposal's Motivation section. That is the load-bearing context, not the rule's summary here.

Overall posture

Strict concurrency is achievable without region-based isolation, complex task hierarchies, or pervasive locking. The discipline is:

  1. Immutability first. Default to value-typed structs that get implicit Sendable. Reach for actor only when global mutable state genuinely exists.
  2. Sparse actor use. Reserve actor for cross-task shared state (transports, file watchers, registries). Don't actor-ify every stateful type.
  3. @MainActor is rare in CLI/library code. Wrap UI-touching types only. Don't smear @MainActor across non-UI code to "make it compile."
  4. @unchecked Sendable and nonisolated(unsafe) are escape hatches. Every use carries a one-line comment justifying why it is safe.

Sendable

Rule. Implicit > explicit > @unchecked.

  • Value types whose stored properties are all Sendable are implicitly Sendable. Don't restate.
  • Add explicit : Sendable on public value types so the API surface declares intent:
    public struct Progress: Sendable {
        public let current: Int
        public let total: Int
    }
    
  • Error enums: always explicit.
  • @unchecked Sendable only for wrappers around known-safe non-value types, with a one-line justification:
    // URLSession is thread-safe; this wrapper is stateless.
    struct ContentFetcher: @unchecked Sendable { ... }
    
  • Never combine @unchecked Sendable with hand-rolled NSLock / DispatchQueue in new code. If you need locks, use actor. Where a synchronous API must guard shared state (a protocol requirement, a synchronous engine that cannot be async), use Synchronization.Mutex (SE-0433): Mutex<State> is Sendable without @unchecked, the state is reachable only inside withLock, and the critical section stays visible. Keep the work inside withLock short and never call back into the same lock from it.

Why (SE-0302, Swift 5.5). Sendable was introduced because passing arbitrary references across isolation boundaries makes data-race safety impossible to verify locally. The protocol turns "is this safe to send across a concurrency domain?" into a property the compiler can check by induction on stored properties. Implicit conformance for value types is the design intent: most structs should be Sendable without ceremony, because most value types compose Sendable parts. Explicit annotation on public types is for API clarity, not for the compiler.

Why (SE-0418, Swift 6.0): inferred Sendable for methods and key path literals. Function values and key paths needed parallel inference. Before SE-0418, you had to mark every closure parameter @Sendable by hand even when the closure captured only Sendable state. Rule of thumb after this proposal: if every captured / referenced type is Sendable, the function is Sendable. Bookkeeping cost goes to zero.

Why (SE-0463, Swift 6.2): Obj-C completion handlers as @Sendable. Objective-C completion handlers were imported without @Sendable, so calling Swift code from an ObjC framework had a hole in its concurrency proof. Swift 6.2 closes the hole. Concrete impact: bridging code that was previously @unchecked Sendable to silence the warning can now drop the @unchecked.

Why (SE-0331, Swift 5.6): unsafe pointers are NOT Sendable. Unsafe(Mutable)(Buffer)Pointer originally conformed to Sendable unconditionally. SE-0331 removed that conformance because the inference rules made arbitrary types accidentally Sendable when they contained an unsafe pointer to non-Sendable data. Lesson: Sendable's safety model only holds if every conformance is honest. Don't add @unchecked Sendable to wave away a real race.

Actors

Rule. actor is for cross-task shared mutable state: transports, file watchers, registries with concurrent readers/writers. Not for everyday data.

  • Don't actor-ify data pipelines. A struct that gets copied is a better default than an actor that gets awaited.
  • Inside an actor, methods are isolated by default. Mark synchronous reads of immutable storage or global resources as nonisolated:
    actor Screen {
        let columns: Int                        // immutable, captured at init
        nonisolated var size: (Int, Int) {      // POSIX ioctl, no actor state
            var ws = winsize()
            _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws)
            return (Int(ws.ws_col), Int(ws.ws_row))
        }
    }
    

Why (SE-0306, Swift 5.5). Actors exist because mutable state shared across concurrent contexts is the canonical source of data races. Before actors, the Swift solution was "don't share mutable state" (value semantics) or "use a lock" (manual). Neither composes with async/await. An actor provides a structured, compiler-enforced way to express "this state is mutable but accessed serially through a single isolation domain." The serialisation is the safety guarantee; reentrancy (multiple suspended tasks on one actor) is the cost.

The reentrancy point matters for design: an actor's state can change between await points inside the actor. Don't write actor methods that assume invariants survive a suspension. Re-read after every await.

Why (SE-0327, Swift 5.10): actor initialisation. Originally, actor init rules were strict: every stored property had to be initialised before any method call. SE-0327 relaxed this so an actor's init can be synchronous and call its own (nonisolated) methods. Practical impact: actor init is not isolated until the first hop, so you can do synchronous setup work in init without await.

@MainActor

Rule. Type-level on UI-owning types, method-level in tests, rarely on protocols.

  • Type-level @MainActor for types that own UI/AppKit/UIKit objects:
    @MainActor public final class WindowCoordinator {
        private let window: NSWindow
        ...
    }
    
  • Method-level @MainActor in test code where you want the test on main:
    @MainActor func testTileRendering() async throws { ... }
    
  • Protocol-level @MainActor only when every conforming implementation truly needs main-thread isolation. Prefer leaving protocols isolation-agnostic.
  • CLI / library code rarely needs @MainActor. If you reach for it in a non-UI target, ask whether you actually want actor.

Why (SE-0316, Swift 5.5): global actors. Sometimes the right isolation is not per-instance (an actor) but per-application (the main thread). @MainActor is a global actor that says "any type or method bearing this annotation runs on the main thread, full stop." The motivating use case is UI frameworks: AppKit / UIKit are inherently main-thread, and @MainActor lifts that constraint into the type system so the compiler enforces it instead of runtime queue assertions.

Why (SE-0434, Swift 6.0): usability of global-actor-isolated types. Before SE-0434, @MainActor types had awkward interactions: stored properties could not be read from non-isolated contexts even when they were let and Sendable, you could not conform @MainActor types to non-isolated protocols, and so on. SE-0434 closes those gaps. Practical impact: @MainActor is now significantly more ergonomic in Swift 6.0+.

Why (SE-0470, Swift 6.2): global-actor isolated conformances. A type can now have a conformance that is itself isolated to a global actor. Lets you say "this type conforms to Codable, but its Codable requirements run on @MainActor." Niche, but unblocks cases where the underlying type stays UI-bound while still participating in serialization or other protocol contracts.

nonisolated and nonisolated(unsafe)

Rule. nonisolated for methods that don't touch isolated state. nonisolated(unsafe) is the escape hatch for fire-and-forget globals or test escape hatches, always commented.

  • nonisolated (safe) on methods/properties of an isolated type that don't read isolated state:

    // POSIX file descriptors are global; no actor state read.
    nonisolated func writeRaw(_ bytes: [UInt8]) {
        _ = write(STDOUT_FILENO, bytes, bytes.count)
    }
    
  • nonisolated(unsafe) reserved for:

    • Fire-and-forget logging state (read-mostly, last-write-wins is acceptable)
    • Test-only escape hatches (stdin overrides, DI test seams)

    One-line comment justifying:

    // Logging is fire-and-forget; last-write-wins is acceptable for these flags.
    nonisolated(unsafe) static var minimumLevel: Logging.Level = .info
    
  • Never nonisolated(unsafe) to silence a warning on shared mutable state that needs synchronisation. Fix the design.

Why (SE-0412, Swift 6.0): strict concurrency for global variables. Before SE-0412, global vars were a backdoor: they bypassed the actor model entirely and were a frequent source of data races in Swift 5 code. SE-0412 closes the backdoor by requiring global mutable state to be either (a) isolated to a global actor, (b) immutable (let), or (c) explicitly marked nonisolated(unsafe) with the developer accepting the responsibility.

The (unsafe) marker is intentionally ugly. It is there so reviewers can grep for it and ask "is this race actually acceptable?" The proposal's stance: most globals should be let, some should be @MainActor-isolated, and nonisolated(unsafe) should be rare and justified.

Region-based isolation (sending)

Rule. Almost never needed if you stay immutable-by-default. If you reach for sending, first ask whether the value can be made Sendable by construction (immutable struct of Sendable parts).

Why (SE-0414, Swift 6.0): region-based isolation. Strict Sendable checking forbids passing non-Sendable values across isolation boundaries. But many real programs create a non-Sendable value in one isolation domain, then hand it off to another and stop using it themselves. That is a race-free pattern, but pre-0414, the compiler could not see it.

Region-based isolation lets the compiler track "this value's region of access" and prove the hand-off is safe even though the type is not Sendable. The proposal's example: an actor builds up a non-Sendable mutable buffer, then transfers it to another actor. After the transfer, the original actor cannot reach the buffer; the receiving actor has exclusive access.

Why (SE-0430, Swift 6.0): sending parameter and result. The sending annotation is how you express the transfer in an API signature: "This parameter is sent to me, the caller cannot use it after this call. This result is sent to you, I cannot use it after returning." The proposal preserves the semantics from SE-0414 but moves them from inferred regions to explicit annotations at API boundaries.

Practical takeaway: most codebases do not need sending. It is for libraries that build non-Sendable graphs (parser ASTs, large mutable buffers) and need to hand them across actor boundaries. If your data is immutable, you do not need it.

Task patterns

Rule. Task { ... } for cleanup and isolation hops. Task.detached { @Sendable ... } for bridging synchronous APIs. withTaskGroup for bounded parallelism.

  • Task { ... } inherits the caller's actor isolation:
    defer { Task { await transport.disconnect() } }     // cleanup
    Task { @MainActor in coordinator.onClose() }         // hop to MainActor
    
  • Task.detached { @Sendable ... } starts a new, unisolated task. Use only for bridging synchronous APIs (FileManager enumerators, blocking IO, legacy callbacks):
    Task.detached(priority: .userInitiated) { @Sendable () -> [URL] in
        // sync-only FileManager work
    }
    
  • withTaskGroup { ... } for bounded parallelism. Always bound the group; never spawn unbounded.
  • Cancellation: rely on await points to check cancellation implicitly. Explicit try Task.checkCancellation() at the top of long synchronous loops; Task.isCancelled for graceful bail-out without throwing.

Why, structured concurrency. Task { ... } is the structured form: the task inherits priority and isolation from the spawning context, and its lifetime is bound to the enclosing scope. Task.detached is the unstructured form: a new, independent task with no inheritance. The default should be structured; reach for Task.detached only when you specifically need to break the inheritance chain (for example detaching from @MainActor to do sync work, or detaching from a parent that might be cancelled when the child should not be).

Why cancellation is cooperative. Swift's cancellation model is intentionally cooperative: setting Task.isCancelled does not halt execution. The task must check the flag and exit. This is by design; abrupt cancellation makes resource cleanup impossible. Practical pattern: every await is a cancellation point (the framework checks for you); for long synchronous loops, add explicit try Task.checkCancellation() at iteration boundaries.

AsyncSequence and AsyncStream

Rule. AsyncStream is the canonical channel for "events arrive over time." For single-consumer progress callbacks, prefer a @Sendable (Progress) -> Void closure.

Typical shape:

actor Channel {
    let messages: AsyncStream<Message>
    private var continuation: AsyncStream<Message>.Continuation!

    init() {
        (messages, continuation) = AsyncStream<Message>.makeStream()
    }

    func send(_ m: Message) { continuation.yield(m) }
}

Back-pressure: pick a buffering policy deliberately (.unbounded, .bufferingNewest(n), .bufferingOldest(n)). Default is unbounded, fine for low-rate event streams, dangerous for high-rate producers.

Why. AsyncSequence generalises the iterator pattern over time. AsyncStream is the concrete bridge from "push" producers (callback APIs, delegate methods, system event sources) to "pull" consumers (for await). The split between protocol and concrete type lets stdlib types (network responses, file reads) and your own channels share the same for await syntax.

When to not use AsyncStream: single-callback progress reporting. A @Sendable (Progress) -> Void closure passed at the call site is simpler, has no buffering question, and is easier to test.

Cross-actor calls

Rule. Pass Sendable values, await across the boundary. Explicit await MainActor.run { ... } for event boundaries only.

  • Default: the compiler handles the isolation hop when you await an isolated method.
  • Don't pass non-Sendable references across isolation domains. If you find yourself wanting to, either (a) make the type Sendable, (b) marshal data into a Sendable value, or (c) use sending (rare).
  • await MainActor.run { ... } is for event boundaries (UI callback, terminal redraw), not as a workaround for "this code happens to need to be on main."

Why. The actor model only delivers data-race safety if values crossing boundaries are Sendable. The compiler enforces this; you cannot "just pass it", you have to either prove safety (via Sendable) or accept responsibility (via @unchecked/sending).

Test concurrency

Rule. Native async throws test methods. Actor-isolated state read via await actor.value. No XCTestExpectation in new code.

  • Async tests:
    @Test func renderer_returns_expected_output() async throws {
        let index = await TileIndex.build(...)
        let results = await index.search("foo")
        #expect(results.count == 3)
    }
    
  • Actor under test: await actorInstance.value to read across the boundary. Use a dedicated actor SendableCounter test double when accumulating state from outside.
  • XCTestExpectation / fulfillment(of:) only when interoperating with legacy callback APIs. Native async/await is simpler.

Anti-patterns

Avoid in new Swift 6 code:

  • DispatchQueue.main.async { ... } → use Task { @MainActor in ... } or await MainActor.run { ... }.
  • NSLock / pthread_mutex + @unchecked Sendable → use actor; where the API must stay synchronous, use Synchronization.Mutex.
  • withCheckedContinuation / withUnsafeContinuation → only when wrapping legacy callback APIs.
  • @MainActor smeared across non-UI types to silence warnings → fix the design.
  • nonisolated(unsafe) to bypass a warning without thinking about the race → fix the design.
  • Task.detached as the default → use Task { ... } unless you specifically need unisolated execution.

Acceptance check

Conforms when each binding clause above is shown to hold:

  • Every escape hatch is justified. Every @unchecked Sendable and every nonisolated(unsafe) carries a one-line comment beside or above it explaining why the race is acceptable. List them and read the neighbouring line:
grep -rnE '@unchecked Sendable|nonisolated\(unsafe\)' Sources/ Tests/

Every hit MUST have an adjacent justifying comment; an unjustified one is a violation.

  • The anti-patterns are absent from new code. The following finds no lines added by the change under review:
grep -rnE 'DispatchQueue\.main\.async|NSLock|pthread_mutex|XCTestExpectation' Sources/ Tests/

Each surviving hit must be a deliberate legacy-bridging exception named in a comment (the withCheckedContinuation / XCTestExpectation "only when wrapping legacy callback APIs" carve-out), not a default reach.

  • Observable checklist for the clauses no grep settles, each phrased so two reviewers agree: public value types on the API surface declare : Sendable explicitly and error enums always do; actor appears only for cross-task shared mutable state, not for data that could be a copied struct; @MainActor does not appear in a non-UI CLI/library target to "make it compile"; withTaskGroup calls are bounded, never unbounded; and no new type pairs a hand-rolled lock with @unchecked Sendable (the "use actor" clause).

Vision

The current direction of Swift concurrency is captured in "Improving the approachability of data-race safety" (vision document, referenced by SE-0463, SE-0466, SE-0470). The vision: data-race safety should be the default, but the cost of opting in (annotations, refactors) should keep dropping. Each Swift 6.x release closes another gap that previously forced an @unchecked or a @MainActor smear.

When designing a new type, ask: "will this need annotations to be Sendable/isolated, or can I make the design itself simple enough that the compiler infers everything?" The vision answer is the second.

Cross-references

  • dependency-injection.md rule 4: no closure typealiases at cross-module seams. Closures as method parameters / property values are still fine; only the named cross-module typealiases are banned.
  • Point-Free Dependencies (within-module, closure-based with @Sendable closures) is an optional dependency-wiring choice, not required by these rules; plain constructor injection is the alternative.

Reading the proposals

The rules above are summaries. The proposals' Motivation sections carry the full reasoning. When designing something non-trivial, read the relevant proposal end-to-end:

  • SE-0306: actors
  • SE-0302: Sendable
  • SE-0414: region-based isolation
  • SE-0412: strict concurrency for globals

The Swift Programming Language book's Concurrency chapter is the second canonical source. Apple also publishes a data-race-safety migration guide.

When a rule above and a proposal disagree, the proposal wins. Update this file.