Skip to content

How to use Actors and non-isolated in Swift | Modern Swift Concurrency #9

Before actors, writing a thread-safe shared data store meant manually managing DispatchQueue barriers or locks — and a single forgotten queue dispatch would silently introduce a data race that only crashed under concurrent load. This lesson shows how actors eliminate that manual synchronization entirely, and how nonisolated lets you opt specific members out of the isolation guarantee when they truly don't need it.

What You'll Learn

  • How an actor serializes access to its mutable state and why the compiler forces await on every cross-actor property and method access
  • What nonisolated means: how to mark methods and properties that don't touch actor-isolated state so they can be called synchronously without await
  • How MyDataManager (a class with a manual DispatchQueue lock) and MyActorDataManager (an actor) solve the same thread-safety problem, and why the actor approach is superior

Mental Model

Think of an actor as a bank vault with a single-door entry and a queue outside. The vault (the actor) holds the valuables (mutable state). Only one person can be inside the vault at a time. If you want to deposit or withdraw (call a method), you join the queue outside and wait for the vault to be free (await). The queue ensures no two people reach for the same cash simultaneously — the data race is architecturally impossible.

nonisolated is like the bank's information counter outside the vault. It can answer questions about the bank's opening hours or address without anyone entering the vault — those are read-only facts stored on a public board (a let constant or a pure computation). Because no one needs to enter the vault for these questions, there is no queue and no await required.

Detailed Explanation

The sample in this lesson is a direct before/after comparison. MyDataManager is a traditional class that uses a serial DispatchQueue as a lock to protect concurrent access to its data array. MyActorDataManager is the actor replacement that provides the same guarantee with less code and compiler-enforced correctness.

The DispatchQueue-based approach in MyDataManager works by routing all mutations through lock.async { ... }. Because lock is a serial queue, only one closure runs at a time, serializing mutations. The completion handler pattern (@escaping) is required because lock.async is asynchronous — the caller can't get the result directly. The caller is responsible for dispatching back to the main queue for UI updates. Forgetting either the lock.async wrapper or the DispatchQueue.main.async hop is a silent correctness failure the compiler cannot detect.

MyActorDataManager removes all of this. The data array is actor-isolated — it is a compiler error to access it from outside the actor without await. The getRandomData() method runs on the actor's executor, which serializes all calls automatically. Callers use await manager.getRandomData() instead of a completion handler, and the compiler enforces that the result is used correctly in the right actor context.

nonisolated is a crucial refinement to this model. By default, every property and function in an actor is isolated — you need await to access anything. But some things genuinely don't require isolation: let constants (immutable, so concurrent reads are always safe) and methods that only compute from their arguments without touching the actor's state. For these, nonisolated removes the isolation requirement, allowing synchronous, non-awaited access.

nonisolated let myRandomText = "asdfasdfadfsfdsdfs" in MyActorDataManager can be read directly without await: let newString = manager.myRandomText. This is safe because let constants are immutable — there is nothing to race over. Without nonisolated, even reading this string would require await, which is unnecessary overhead and makes the code more verbose.

nonisolated func getSavedData() -> String is an isolated-free method that returns a constant. It can be called synchronously from any context. The key constraint: nonisolated functions cannot access actor-isolated properties or functions. If you tried to read self.data inside getSavedData(), the compiler would error with "actor-isolated property 'data' can not be referenced from a non-isolated context."

The two views (HomeView and BrowseView) share the same MyActorDataManager.instance singleton. Both fire timers at different rates (100ms and 10ms) and simultaneously call getRandomData(). With MyDataManager (the class), this would be a data race. With the actor, the calls are automatically queued — each await manager.getRandomData() waits for the actor to be free, serializing all mutations regardless of how many concurrent callers there are. The commented-out GCD versions in both views show what the equivalent callback-based code looks like.

Code Structure

ActorsBootcamp.swift defines two parallel implementations of the same data manager: MyDataManager (class with DispatchQueue lock) and MyActorDataManager (actor with nonisolated members). HomeView fires a slow timer (0.1s interval) and BrowseView fires a fast timer (0.01s interval), both calling the shared actor instance concurrently. ActorsBootcamp presents both views in a TabView so you can observe the concurrent updates visually.

Complete Code

ActorsBootcamp.swift

swift
import SwiftUI

// Class-based approach: manual synchronization via a serial DispatchQueue
class MyDataManager {
    
    static let instance = MyDataManager() // singleton: shared across the entire app
    private init() { } // private init prevents external instantiation, enforcing singleton usage
    
    var data: [String] = [] // NOT thread-safe: concurrent access without the lock is a data race
    private let lock = DispatchQueue(label: "com.MyApp.MyDataManager") // serial queue used as a mutex
    
    func getRandomData(completionHandler: @escaping (_ title: String?) -> ()) {
        lock.async { // all mutations route through the serial queue; only one closure runs at a time
            self.data.append(UUID().uuidString)
            print(Thread.current) // observe which thread is active — varies based on GCD scheduling
            completionHandler(self.data.randomElement()) // caller is responsible for hopping back to main for UI updates
        }
    }
    
}

// Actor-based approach: compiler-enforced serialization, no manual locking required
actor MyActorDataManager {
    
    static let instance = MyActorDataManager() // singleton: the actor guarantees thread-safe access to this instance
    private init() { }
    
    var data: [String] = [] // actor-isolated: compiler prevents access without await from outside the actor
    
    nonisolated let myRandomText = "asdfasdfadfsfdsdfs" // let constant: immutable, so concurrent reads are safe; no await needed
    
    func getRandomData() -> String? { // actor-isolated: only one caller runs this at a time; no queue or lock needed
        self.data.append(UUID().uuidString)
        print(Thread.current) // observe the thread — actors can run on any cooperative pool thread
        return self.data.randomElement()
    }
    
    nonisolated func getSavedData() -> String { // nonisolated: does not touch actor-isolated state; can be called synchronously
        return "NEW DATA"
    }
    
}


struct HomeView: View {
    
    let manager = MyActorDataManager.instance
    @State private var text: String = ""
    let timer = Timer.publish(every: 0.1, tolerance: nil, on: .main, in: .common, options: nil).autoconnect() // fires every 100ms on the main thread
    
    var body: some View {
        ZStack {
            Color.gray.opacity(0.8).ignoresSafeArea()
            
            Text(text)
                .font(.headline)
        }
        .onAppear(perform: {
            let newString = manager.myRandomText // nonisolated let: no await needed — synchronous access is safe
            
            Task {
                let newString = await manager.getSavedData() // await still required because getSavedData is on an actor type, even though nonisolated
            }
        })
        .onReceive(timer) { _ in
            Task { // bridge from synchronous timer callback into async context for the actor call
                if let data = await manager.getRandomData() { // await: suspends until the actor is free to serve this request
                    await MainActor.run(body: {
                        self.text = data // hop to main actor before updating @State — required for UI correctness
                    })
                }
            }
//            DispatchQueue.global(qos: .background).async {  // old approach: manually dispatches to background
//                manager.getRandomData { title in
//                    if let data = title {
//                        DispatchQueue.main.async {  // manually hop back to main for UI update
//                            self.text = data
//                        }
//                    }
//                }
//            }
        }
    }
}

struct BrowseView: View {
    
    let manager = MyActorDataManager.instance // same singleton: both HomeView and BrowseView share this actor
    @State private var text: String = ""
    let timer = Timer.publish(every: 0.01, tolerance: nil, on: .main, in: .common, options: nil).autoconnect() // 10x faster than HomeView's timer — stress tests concurrency

    var body: some View {
        ZStack {
            Color.yellow.opacity(0.8).ignoresSafeArea()
            
            Text(text)
                .font(.headline)
        }
        .onReceive(timer) { _ in
            Task {
                if let data = await manager.getRandomData() { // concurrent with HomeView's calls; actor serializes them automatically
                    await MainActor.run(body: {
                        self.text = data
                    })
                }
            }
//            DispatchQueue.global(qos: .default).async {  // old approach: manual GCD with explicit main-thread hop
//                manager.getRandomData { title in
//                    if let data = title {
//                        DispatchQueue.main.async {
//                            self.text = data
//                        }
//                    }
//                }
//            }
        }
    }
}

struct ActorsBootcamp: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }
            
            BrowseView()
                .tabItem {
                    Label("Browse", systemImage: "magnifyingglass")
                }
        }
    }
}

struct ActorsBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        ActorsBootcamp()
    }
}

Code Walkthrough

  1. MyDataManager — the lock-based approach — The DispatchQueue(label:) is a serial queue used as a mutex. All operations on data are wrapped in lock.async { }, ensuring only one closure executes at a time. The completionHandler pattern is required because lock.async is fire-and-forget — the result must be delivered asynchronously. The caller (in BrowseView's commented-out block) must then DispatchQueue.main.async back to the main thread. Two failure modes: forgetting lock.async (data race) or forgetting DispatchQueue.main.async (UI threading violation). The compiler catches neither.

  2. MyActorDataManager — the actor approach — The actor declaration replaces the DispatchQueue lock entirely. The Swift runtime's actor executor is the serialization mechanism. getRandomData() is no longer a completion-handler function — it's a regular synchronous function that returns a value, made async only by virtue of being actor-isolated. The compiler generates all the synchronization infrastructure. The caller simply awaits the call.

  3. nonisolated let myRandomText — In HomeView.onAppear, manager.myRandomText is read without await. This works because myRandomText is declared nonisolated. If you removed the nonisolated keyword and tried to compile, you'd get: "expression is 'async' but is not marked with 'await'". The nonisolated let tells the compiler: "this property is safe to read from any context without actor serialization."

  4. await manager.getSavedData() — nonisolated func still needs await in some contexts — Notice that getSavedData() is nonisolated, but HomeView.onAppear still calls it with await inside a Task. This is because getSavedData is on an actor type — Swift requires await for all async contexts even for nonisolated functions when calling across an actor boundary. In contexts where you're not crossing an actor boundary (e.g., calling a nonisolated func from another nonisolated context), await is not needed.

  5. Concurrent timer calls between HomeView and BrowseViewHomeView fires every 100ms and BrowseView fires every 10ms. Both call await manager.getRandomData() on the same actor instance. If MyDataManager (the class) were used instead, you'd need to ensure both views' GCD calls route through the same serial queue — easy to miss. With the actor, sharing the same instance is sufficient: all calls are automatically queued behind the actor's executor.

  6. MainActor.run after the actor call — Even with MyActorDataManager, you still need to hop to the main thread for UI updates. The actor serializes access to its own state, but @State private var text is SwiftUI UI state that must be mutated on the main thread. await MainActor.run { self.text = data } performs that hop explicitly. Annotating the whole view with @MainActor would eliminate the need for this, but SwiftUI view bodies implicitly run on @MainActor already.

Common Mistakes

Mistake: Using nonisolated on a method that accesses actor-isolated propertiesnonisolated func myFunc() { self.data.append("x") } is a compile error: "actor-isolated property 'data' can not be referenced from a non-isolated context." nonisolated removes the actor's protection for that method, so the compiler prevents you from using isolated state inside it. If you need to access data, the function must be actor-isolated (no nonisolated keyword).

Mistake: Mixing UI updates into the actor and bypassing @MainActor If you call UIApplication.shared.open(url) or modify a SwiftUI @Binding from inside an actor method, you're updating UI from whatever thread the actor happens to be running on — which is not necessarily the main thread. Actors do not run on the main thread by default. Always route UI updates through MainActor.run { } or @MainActor-annotated functions.

Mistake: Calling await actor.method() hundreds of times per second in a timer without throttlingHomeView fires every 100ms (10 times/second) and BrowseView fires every 10ms (100 times/second). This generates over 100 actor hops per second. For lightweight work like appending a UUID, this is fine. But if each actor call triggered a database write or network request, this would cause queue buildup — new tasks arrive faster than old ones complete. Implement debouncing or coalescing before the actor call to prevent this.

Key Takeaways

  • Actors replace manual DispatchQueue locks with compiler-enforced serialization — you cannot accidentally forget to use the lock because the compiler requires await for all actor-isolated access, turning a potential runtime bug into a compile error
  • nonisolated opts a specific member out of actor isolation, allowing synchronous access from any context — use it for let constants and pure computations that provably do not touch the actor's mutable state
  • Actors serialize their own state but do not automatically handle UI threading — always use await MainActor.run { } or @MainActor to ensure UI updates happen on the main thread after an actor call completes

Last updated: June 27, 2026

Released under the MIT License.