Hi everyone
After my mTLS post, I wanted to share notes on how actors and the concurrency model work in Swift 6, the mistakes I made, and what changed in Swift 6.2. Corrections are welcome!
1. What an actor gives you
An actor is a reference type that protects its mutable state. Only one task runs on an actor at a time, so the compiler can prove there are no data races. Code outside the actor has to await to reach that state.
enum BankError: Error { case insufficientFunds }
actor BankAccount {
private(set) var balance: Decimal = 0
func deposit(_ amount: Decimal) { balance += amount }
func withdraw(_ amount: Decimal) throws {
guard balance >= amount else { throw BankError.insufficientFunds }
balance -= amount
}
}
let account = BankAccount()
await account.deposit(100)
let current = await account.balance
Inside the actor, calls are synchronous: there's no await between guard and balance -= amount, so that check-then-update can't be interrupted.
2. The big gotcha: reentrancy
Actors are reentrant. At every await, the actor can start running other work, so your state may have changed by the time you resume.
actor ImageCache {
private var cache: [URL: Data] = [:]
func image(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
let data = try await download(url) // other callers can run here
cache[url] = data
return data
}
}
If two callers ask for the same URL at once, both miss the cache and the image downloads twice. To fix this, store the in-flight task so later callers can await it:
actor ImageCache {
private var cache: [URL: Data] = [:]
private var inFlight: [URL: Task<Data, Error>] = [:]
func image(for url: URL) async throws -> Data {
if let cached = cache[url] { return cached }
if let task = inFlight[url] { return try await task.value }
let task = Task { try await download(url) }
inFlight[url] = task
defer { inFlight[url] = nil }
let data = try await task.value
cache[url] = data
return data
}
}
Rule of thumb: treat every await inside an actor as a point where your assumptions can break, and re-check state afterwards.
3. Global actors and @MainActor
@MainActor is a global actor tied to the main thread. You can mark a type, a function or a property with it, and the compiler makes callers hop to the main actor.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var name = ""
func load() async throws {
let profile = try await api.fetchProfile()
name = profile.name // safe: we're back on the main actor
}
}
4. Sendable and the Swift 6 language mode
In the Swift 6 language mode, data-race checks are errors, not warnings. A value can cross an isolation boundary if it's Sendable (value types of Sendable members, actors, final classes with immutable Sendable state, and so on).
When you really need shared mutable state outside an actor, a Mutex (Synchronization module, iOS 18+) is cleaner than @unchecked Sendable with a hand-rolled lock:
import Synchronization
final class Counter: Sendable {
private let value = Mutex(0)
func increment() { value.withLock { $0 += 1 } }
}
5. Region-based isolation and sending
Swift 6 also lets you pass non-Sendable values across actors when the compiler can prove nobody else still uses them (SE-0414):
final class Draft { var text = "" } // not Sendable
actor Editor {
func publish(_ draft: Draft) { /* ... */ }
}
func submit(to editor: Editor) async {
let draft = Draft()
draft.text = "Hello"
await editor.publish(draft) // OK: draft is handed over
// draft.text = "Oops" // error: draft was already sent to the actor
}
The sending keyword (SE-0430) puts the same guarantee into API signatures, for parameters and return values.
6. What's new in Swift 6.2 ("approachable concurrency")
- Default actor isolation (SE-0466): a module can make
@MainActorthe default. New Xcode 26 app projects do this, so most UI code no longer needs annotations. nonisolated(nonsending)by default (SE-0461, upcoming feature): anonisolated asyncfunction now runs on the caller's actor instead of hopping to the global executor. This removes many surprising Sendable errors.@concurrent: use it when you want work to leave the actor and run on the concurrent thread pool:
nonisolated struct ImageProcessor {
@concurrent
func resize(_ data: Data) async -> Data {
// heavy work runs off the main actor
}
}
- Isolated conformances (SE-0470): a main-actor type can conform to a protocol only within that isolation, e.g.
extension Person: @MainActor Equatable. Task.immediate(SE-0472): starts running synchronously on the current actor until its first suspension.
Lessons learned
- Don't make everything an actor. Use value types for data, an actor for shared mutable state, and
@MainActorfor UI. - Every
awaitinside an actor is a reentrancy point. Re-validate state after it. - Avoid
@unchecked Sendableunless you really protect the state (Mutex, or an immutable value). - Move to the Swift 6 language mode one module at a time, using the strict concurrency warnings in Swift 5 mode first.
- On 6.2, decide deliberately what should be
@concurrent. Otherwise work stays on the caller's actor.
I'd love to hear how others structure actors in larger apps, especially around reentrancy and moving existing code to Swift 6.