← Back to list

Swift 6 Concurrency & Thread Safety

Can you architect data layers that the compiler guarantees are race‑free?

Eric Williams · 2026-02-10 00:44 · 0 claps · 4.1 min read
#swift-6 #swift-concurrency #swift-programming #swift
Open on Medium ↗
Wiki topics: SAF · Safety & Alignment 💻 · Programming 📱 · Mobile Development 🏛️ · Architecture

Swift 6 Concurrency & Thread Safety

Can you architect data layers that the compiler guarantees are race‑free?

The Compiler is Here to Save You from yourself. With Swift 6, the compiler stops being polite and starts being helpful. Strict concurrency checking is now the default, and code that was “well it worked for me” becomes code that won’t even compile.

In Swift 5, the responsibility for thread safety was entirely on you. The compiler would gently warn you while letting race conditions slip through. Swift 6 takes that burden off of your hands. Unsafe boundary‑crossing types now triggers firm, unapologetic errors.

Isolation Domains — The Core Concept An isolation domain is a zone where mutable state is protected by one concurrency mechanism. Only code inside that domain can mutate its state directly.

Swift 6 gives us three kinds of isolation domains: • Actor isolation - All mutable state lives in an actor instance • Global actor isolation - Code tagged with a global actor, like @MainActor • Nonisolated code - Functions or task bodies that aren’t tied to any actor; they run on whatever thread was active at call time

Crossing from one isolation domain to another? You must use await . That is the compiler making sure you don’t sneak in a data race.

Actors - Local Isolation, Serialized Execution An actor is a reference type that serializes access to its mutable state. Every actor has a serial executor, only one task runs inside it at a time. Here’s a simple TripEntity actor:

actor TripEntity {
 private var trips: [TripID: Trip] = [:]
func trip(for id: TripID) -> Trip? {
 trips[id]
 }
func store(_ trip: Trip) {
 trips[trip.id] = trip
 }
}

If this were a plain class, two threads writing to that dictionary could corrupt memory or crash outright. The actor makes that impossible, only one async call can run inside the actor at once. Cross‑domain example:

let tripEntity = TripEntity()
Task {
 await tripEntity.store(Trip(id: "123"))
}

Calling store requires await because you’re entering another isolation domain. The compiler forces this serialization: you can’t “forget later” to protect shared data.

Global Actors Shared Context, not Threads A global actor is a singleton actor that provides a shared serialized context for specific code. @MainActor, come for free, and it ties UI code to the main thread.

@MainActor
class TripDetailViewModel: ObservableObject {
 @Published var state: ViewState<Trip> = .idle
 let tripRepository: TripRepository
init(repo: TripRepository) {
 self.tripRepository = repo
 }
func loadTrip(id: TripID) async {
 state = .loading // Guaranteed on main thread
 let trip = await tripRepository.fetch(id)
 state = .loaded(trip) // Also guaranteed on main thread
 }
}

With the view model marked as @MainActor, every mutation is guaranteed to run on the main thread. No more manual ‘DispatchQueue.main.async’ scattered throughout your code. Yay.

Custom global actors: You are now able to create your own with ‘@globalActor’ when multiple types need to share a single serialized context, such as a database layer or a networking subsystem that will not block the UI.

@globalActor
actor DatabaseActor {
 static let shared = DatabaseActor()
}
@DatabaseActor
class TripRepository {
 private var cache: [TripID: Trip] = [:]
func save(_ trip: Trip) {
 cache[trip.id] = trip
 }
}
@DatabaseActor
class SyncEngine {
 func reconcile(_ remote: [Trip], with repository: TripRepository) {
 // Same isolation domain - no await needed
 for trip in remote {
 repository.save(trip)
 }
 }
}

A quick nerd note: global actors serialize access on a shared executor, but they don’t own their own thread. Swift schedules tasks on an available thread; serialization is the guarantee, not thread pinning. Used sparingly, custom global actors like @DatabaseActor or @NetworkActor can make app‑wide concurrency safe by design. But, when used everywhere, they become a mess of hidden dependencies, and difficult to reason. Go slow.

The Sendable Protocol, Safe Passage Between Domains The compiler does not just check when you run code, it also checks the data you pass across isolated domains. And this is what the ‘Sendable’ protocol enforces.

If a value might be accessed from multiple domains at once, it must be ‘Sendable’. The compiler verfies that it is safe, or it won’t compile.

There are three kinds of Sendable types:

  1. Implicit - Value types whose stored properties are all Sendable (e.g. Int , String , Array<String> )
  2. Explicit - Classes marked as both final and Sendable , with only immutable ( let ) properties
  3. @unchecked Sendable - You promise the compiler it’s safe — the concurrency equivalent of force‑unwrap
actor SyncEngine {
 func sync(_ payload: SyncPayload) { … }
}
// This won't compile in Swift 6 if SyncPayload has var properties
final class SyncPayload {
 var id: String
 var date: Date
 var content: String
}

The compiler will stop you here: ‘SyncPayload’ is not ‘Sendable’. You could mutate it from outside the actor while it is being read inside, and voila ‘data race’.

The fix:

final class SyncPayload: Sendable {
 let id: String
 let date: Date
 let content: String
}

Or by using a value type:

struct SyncPayload: Sendable {
 var id: String
 var date: Date
 var content: String
}

When ‘@unchecked Sendable’ is barely okay: Use this for carefully managed cases like bridging immutable C objects, low-level caches behind explicit locks, or legacy systems you are not ready yet refactor as yet.

Putting It All Together — A Concurrency‑Safe Data Layer Swift 6 divides concurrency safety into three coordinated layers. Together, they move data‑race prevention from “developer discipline” to “compiler guarantee.”

| Layer        | Role                                              | Enforced By                                          |
|--------------|---------------------------------------------------|------------------------------------------------------|
| Actor        | Serializes access to mutable state                | Runtime (serial executor) + Compiler (`await`)       |
| Global Actor | Guarantees execution in shared serialized context | Compiler (isolation checking)                        |
| Sendable     | Ensures data crossing domains is safe to share    | Compiler (conformance checking)                      |

Think of it this way: Actors protect data inside an isolation domain; Global actors align multiple types within one domain; Sendable ensures values traveling between domains can’t start races.

It is Less Luck and More Logic Swift6 turns concurrency safety from a “guideline” into a “law”. There is no “I think the data layer is race-free”, the compiler makes it so.

Stay tuned for my next trick:

SWIFT_STRICT_CONCURRENCY = complete

메타데이터
post_id
d155209bca88
slug
swift-6-concurrency-thread-safety-d155209bca88
url
https://medium.com/@ewilliams.vii/swift-6-concurrency-thread-safety-d155209bca88
canonical_url
https://medium.com/@ewilliams.vii/swift-6-concurrency-thread-safety-d155209bca88
author_url
https://medium.com/@ewilliams.vii
status
ok
fetched_at
2026-08-02 13:24:08