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.

1 Like

Thanks, great summary! It's always good to refresh and validate concurrency concepts.

Something I keep coming back to is: where exactly the reentrancy risk lies.

When you call private helper function that happens to be asynchronous, only the real suspension points count (network calls, etc) so if you check the invariante there, there's no need to check it again in the entry point.

Or at least that's my understanding. Here's an example:

actor MyActor {
    private var state = 0

    func entryPoint() async {
        state = 1
        // Execution jumps straight into the helper here. 
        // No other task can change 'state' between these two lines.
        await localHelper() 
    }

    private func localHelper() async {
        // ✅ STATE IS SAFE HERE: state is still 1. 
        // No other task has had a chance to interleave yet.
        print(state) 
        
        // 🚨 THE REAL RISK POINT: This is a true suspension point.
        // The thread is yielded NOW. Another task can now mutate 'state'.
        try? await Task.sleep(for: .seconds(1)) 
        
        // ⚠️ STATE IS UNRELIABLE HERE: state could be anything now.
        print(state) 
    }
}

Still it's probably better to organize your code in a way that you don't have to rely on that assumption.

Best way to mitigate reentrancy is capturing state as local variables and perform mutations before await when possible. And maybe not having superfluous helper methods with a single caller.

You are correct. The entry into and return from an asynchronous function within the same isolation does not suspend. This can be really important to provide certain kinds of guarantees.

But, in general, I would be extremely careful here, because it can lull you into a false sense of security.

func entryPoint() async {
    state = 1
    // there is no suspension on entry and exit, however...
    await localHelper()
    // ... state may have been mutated at this point, because `localHelper` suspends internally
}
1 Like

I wonder if there are precedents for non-reentrant actors (or equivalents) in other languages to achieve a simpler concurrency model. For example, something explicitly marked at the declaration level:

// Eliminates suspension-point interleaving and state mutation concerns
nonreentrant actor ImageCache {
    // ...
}