Swift 6 actors and concurrency: how they work, reentrancy pitfalls, and what's new in 6.2

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 @MainActor the default. New Xcode 26 app projects do this, so most UI code no longer needs annotations.
  • nonisolated(nonsending) by default (SE-0461, upcoming feature): a nonisolated async function 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 @MainActor for UI.
  • Every await inside an actor is a reentrancy point. Re-validate state after it.
  • Avoid @unchecked Sendable unless 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.