Hi everyone 👋
After my mTLS post, I wanted to write up what I've learned about actors and the concurrency model in Swift 6: how they work, the mistakes I made along the way, and what changed in Swift 6.2. Corrections are very welcome!
1. What an actor gives you
An actor is a reference type that protects its own 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 the 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 is free to run 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 the same time, both miss the cache and the image is downloaded twice. The fix is to store the in-flight task so later callers can await it instead of starting a new one:
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 after it.
3. Global actors and @MainActor
@MainActor is a global actor tied to the main thread. You can apply it to a type, a function or a property, 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 only if it's Sendable (value types made of Sendable members, actors, final classes with immutable Sendable state, and so on).
When you really do need shared mutable state outside an actor, a Mutex (Synchronization module, iOS 18+) is much cleaner than @unchecked Sendable plus 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) brings the same guarantee into API signatures, for both 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 a lot of surprising Sendable errors. -
@concurrent: use it when you explicitly 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
return data
}
}
-
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 point.
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 the state is really protected (Mutex, or an immutable value). - Migrate one module at a time. Turn on strict concurrency warnings in Swift 5 mode first, then switch to the Swift 6 language mode.
-
On 6.2, choose
@concurrentdeliberately. Otherwise, work stays on the caller's actor.
I'd love to hear how others structure actors in larger apps, especially around reentrancy and migrating existing code to Swift 6. Drop your experience in the comments! 🙌
Top comments (0)