Swift 6 Strict Concurrency
Isolation domains, Sendable, global actors, region isolation, sending, Swift 5 to 6 migration
- 01
Your app module still builds in Swift 5 mode — which concurrency flags do you turn on first, and how do you get to Swift 6 without a dead week?
MediumYou do not flip the language mode first: you enable the upcoming features one at a time in Swift 5 mode, where every violation is a warning and the target keeps building, and switch to Swift 6 only once the warning count is zero.
// Package.swift — migrate one target at a time, leaves first. let package = Package( name: "App", targets: [ // Done: this leaf is clean, so it opts into the language mode outright. .target( … - 02
A reviewer finds
nonisolated(unsafe)in your pull request — what exactly are you promising, and when is it the honest answer rather than a silencer?MediumYou are promising that you serialise every access to that declaration yourself, because the annotation only switches off the compiler's checking — it adds no lock, no queue and no runtime protection of any kind.
import Foundation import Synchronization // HONEST 1 — the lock is declared next to the storage and nothing bypasses it. enum Metrics { private static let lock = NSLock() … - 03
Your class has nothing but
letproperties and the compiler still refuses to let it cross an actor boundary — what is it objecting to?MediumSendableis never inferred for a class: you have to write the conformance, and then the class must befinal, hold only immutable storage, and hold only Sendable types.import Foundation import Synchronization // Rejected, one reason each: // class NotFinal: Sendable { let a = 1 } // -> non-final class 'NotFinal' cannot conform to the 'Sendable' protocol … - 04
static var sharedcompiled fine for years and is now an error in Swift 6 — what are the legal shapes for a piece of shared mutable state?MediumThe diagnostic — "static property 'x' is not concurrency-safe because it is nonisolated global shared mutable state" — says any thread can reach that storage with nothing serialising it, so every fix makes it immutable, isolates it, or guards it.
import Synchronization // error: static property 'current' is not concurrency-safe because it is // nonisolated global shared mutable state // enum Session { static var current: User? } … - 05
You hand a plain non-Sendable model object to an actor method and it compiles — what did the compiler prove, and what makes the same line start failing?
MediumIt proved the value's region is disconnected: nothing outside the current scope can reach it and you never touch it again after the call, so handing it over cannot create a race even though the type is not Sendable.
final class Report { // not Sendable: mutable stored properties var title = "" var rows: [String] = [] } actor Archive { … - 06
What can a
sendingparameter express that@Sendablecannot, and which compiler error does onlysendingfix?Medium@Sendableconstrains a closure's captures — everything it closes over must already be Sendable — whilesendingsays nothing about the type and instead transfers ownership of one value across the boundary.final class Draft { var body = "" } // not Sendable actor Outbox { private var queued: [Draft] = [] func queue(_ d: Draft) { queued.append(d) } } … - 07
Your
@MainActorview model has to conform toEquatableand the compiler rejects the conformance — what are your options?HardEquatable.==is nonisolated, so anyone may call it from any domain, and a main-actor witness cannot honour that without a hop it is not allowed to insert — hence "conformance of 'Model' to protocol 'Equatable' crosses into main actor-isolated code and can cause data races".@MainActor final class Model { let id: Int var rows: [String] = [] init(id: Int) { self.id = id } } … - 08
The build goes green after you add
@preconcurrencyto an import and to a conformance — what did each one silence, and what risk survives the silence?HardThey are two unrelated tools that share a spelling: on an import it makes the compiler assume the module's types are Sendable, and on a conformance it replaces a compile-time isolation error with a runtime trap.
// The SDK predates concurrency: no Sendable annotations, and its delegate // protocol is nonisolated. @preconcurrency import LegacyPlayer import Foundation @MainActor … - 09
You write a
@globalActorfor the storage layer, and a type that needs both it and@MainActorwill not compile — what does a global actor buy you, and where does the serial-queue intuition break?HardA global actor is one process-wide actor instance that many declarations share, and unlike two serial queues, two of them cannot nest: a declaration has exactly one isolation, so every crossing is an
awaitand a real suspension.// A global actor is a type that names ONE shared actor instance. @globalActor actor StorageActor { static let shared = StorageActor() } … - 10
A thread-hostile C library has to be called from one context and nowhere else — how do you make an actor run its work there instead of on the cooperative pool?
HardYou give the actor its own executor: implement
SerialExecutorand return it fromnonisolated var unownedExecutor: UnownedSerialExecutor(SE-0392), and every isolated call on that actor is enqueued there instead of on the shared pool.import Dispatch // 1. The cheap case: the library only needs serialisation. actor ImagePipeline { private let queue = DispatchSerialQueue(label: "image.pipeline") // DispatchSerialQueue already conforms to SerialExecutor. … - 11
The vendor documents that its callback arrives on the main thread but the method is
nonisolated— isMainActor.assumeIsolatedthe right tool, and what makes it crash?HardIt is a runtime assertion, not a cast:
assumeIsolatedchecks that you are already running on that actor and then lends you its isolation synchronously — it never switches, and if the check fails it traps.import UIKit @MainActor final class Player { private var progress: Double = 0 private let bar = UIProgressView() … - 12
nonisolatedon a stored property, on a synchronous method and on anasyncmethod — what does each promise, and which of them puts work in the background?HardNone of them moves work anywhere:
nonisolatedremoves a declaration from its actor's domain, so the only thing it changes is who may call it withoutawait.@MainActor final class Profile: Equatable, Hashable { let id: UUID // Sendable `let`: already readable cross-domain var displayName: String // main-actor isolated init(id: UUID, name: String) { self.id = id; displayName = name } … - 13
What do Approachable Concurrency and
Default Actor Isolation: MainActorchange in an app target, and why would you not ship a shared framework with them on?HarddefaultIsolation(SE-0466) flips the default for unannotated declarations in a module fromnonisolatedto@MainActor, which deletes most of a UI target's Sendable errors — and because that default lands on your public API, a framework imposes it on everyone who imports it.// Package.swift — swift-tools-version: 6.2 // // .target( // name: "AppUI", // swiftSettings: [ // .defaultIsolation(MainActor.self), // the target that owns the UI … - 14
A vendor's Objective-C SDK calls your delegate back on whatever thread it likes — how do you get that into a Swift 6 module without
@unchecked Sendableon everything?HardPut one small nonisolated object between the SDK and your code and make it the only thing the vendor's threads ever touch: its delegate methods own no mutable state, and they hand every event to an actor through a Sendable channel.
import Foundation // ---- The boundary: nonisolated, owns nothing mutable, therefore Sendable. ---- final class ScannerBridge: NSObject, VendorScannerDelegate { enum Event: Sendable { case found(String), failed(String) } … - 15
Your module compiles clean in Swift 6 language mode and still corrupts data under load — which races is the isolation checker structurally unable to see?
HardThe checker reasons about Swift values crossing isolation boundaries; sharing that happens through an address, through another language, or through the kernel is outside the theorem it proves.
import Foundation import Synchronization // 1. Pointers are Sendable, so this compiles in Swift 6 mode — and races. func corrupt(_ buffer: UnsafeMutablePointer<Int>, count: Int) { Task.detached { for i in 0..<count { buffer[i] += 1 } } … - 16
Thread Sanitizer reports a race in a target that compiles clean under Swift 6 — is the compiler wrong, and how do you track the bug down?
HardNeither is wrong: the compiler proves that no unchecked value crosses an isolation boundary in the code it was allowed to check, while TSan watches real memory accesses at runtime, including everything the compiler was told to skip.
// Compiles clean in Swift 6 mode. TSan reports a race on `pending`. final class UploadQueue: @unchecked Sendable { // the check was waived here private var pending: [URL] = [] private let lock = NSLock() func add(_ url: URL) { …