DEV Community

Dheerendra Chaturvedi
Dheerendra Chaturvedi

Posted on

Swift 6 Concurrency in Practice: Actors, Reentrancy, Sendable and What's New in 6.2

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
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 } }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 @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 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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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 @MainActor for UI.
  • Every await inside an actor is a reentrancy point. Re-validate state after it.
  • Avoid @unchecked Sendable unless 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 @concurrent deliberately. 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)

⌂ 换网址