Swift Actors, Data races and region-based isolation
A deep dive into Swift's concurrency model, focusing on actors and region-based isolation.
Understanding Actors and Isolation in Swift 6
🛠️ How We Protected State Before Actors
Before actors and structured concurrency, protecting shared mutable state in Swift required manual thread-safety using low-level primitives. Each came with footguns.
🔐 1. NSLock (or pthread mutex)
swiftclass VideoLoaderManager { private var videoTasks: [String: VideoTask] = [:] private let lock = NSLock() func startLoading(id: String, url: URL) { lock.lock() let task = VideoTask(url: url) videoTasks[id] = task lock.unlock() task.start() } func cancelLoading(id: String) { lock.lock() videoTasks[id]?.cancel() videoTasks.removeValue(forKey: id) lock.unlock() } }
Pros: Fine-grained control
Cons: Easy to forget unlock(), deadlock risk, no compile-time safety
🌀 2. Serial DispatchQueue
swiftclass NotificationManager { private var observers: [() -> Void] = [] private let queue = DispatchQueue(label: "notification.queue") func addObserver(_ observer: @escaping () -> Void) { queue.async { self.observers.append(observer) } } func notifyAll() { queue.async { for observer in self.observers { observer() } } } }
Pros: Deadlock-safe if used correctly
Cons: Requires async completion patterns, not await-friendly
🚦 3. DispatchSemaphore
swiftclass DownloadQueue { private var downloads: [String: DownloadTask] = [:] private let semaphore = DispatchSemaphore(value: 1) func addDownload(id: String, task: DownloadTask) { semaphore.wait() downloads[id] = task semaphore.signal() } func cancelDownload(id: String) { semaphore.wait() downloads[id]?.cancel() downloads.removeValue(forKey: id) semaphore.signal() } }
Pros: Useful when threading needs are low-level
Cons: Very error-prone, risks of starvation, and a single mistake can hang the app
💥 The Problem with Shared Mutable State
Let’s look at how easy it is to write unsafe code without realizing it:
swiftclass VideoLoaderManager { private var videoTasks: [String: VideoTask] = [:] func startLoading(id: String, url: URL) { let task = VideoTask(url: url) videoTasks[id] = task task.start() } } let manager = VideoLoaderManager() DispatchQueue.global().async { manager.startLoading(id: "vid1", url: URL(string: "https://example.com/1.mp4")!) } DispatchQueue.global().async { manager.startLoading(id: "vid2", url: URL(string: "https://example.com/2.mp4")!) }
This looks innocent — but it's a race condition waiting to happen. Multiple threads mutate the videoTasks dictionary simultaneously.
You won't get a compiler warning. You might not even crash right away. But someday, under just the right load...
plaintext💣 EXC_BAD_ACCESS
Safety is not guaranteed by the language here, but by the developer's awareness.
🛡️ The Actor Solution in Swift
🎯 Why Actors Matter
Actors in Swift come with built-in data race protections and a modern, structured model of concurrency. Rather than relying on locks or queues, actors enforce isolation by design.
Let’s see how actors solve these:
swiftactor VideoLoaderManager { private var videoTasks: [String: VideoTask] = [:] func startLoading(id: String, url: URL) { let task = VideoTask(url: url) videoTasks[id] = task task.start() } func cancelLoading(id: String) { videoTasks[id]?.cancel() videoTasks.removeValue(forKey: id) } } actor NotificationManager { private var observers: [() -> Void] = [] func addObserver(_ observer: @escaping () -> Void) { observers.append(observer) } func notifyAll() { for observer in observers { observer() } } }
Usage:
swiftlet manager = VideoLoaderManager() let notificationManager = NotificationManager() Task { await manager.startLoading(id: "vid1", url: URL(string: "https://example.com/1.mp4")!) await notificationManager.addObserver { print("A") } await manager.cancelLoading(id: "vid1") await notificationManager.notifyAll() }
With actors, you get safety by default—no more manual locking or worrying about races!
‼️ But: You can only call actor methods from within an actor context or using await from outside. This is where actor mailboxes come into play.
✉️ Introducing the Actor Mailbox
When you make calls to an actor from outside, Swift queues those operations into a special queue known as the actor's mailbox.
Each actor has its own mailbox that serializes incoming tasks:
- You call a method on an actor from the outside.
- The method call is added to the actor’s mailbox.
- The actor processes the mailbox serially, ensuring exclusive access to its internal state.
- Actor keeps working on other operations while asynchronously waiting for the mailbox to be processed.
‼️ Actors introducing concurrency to our code, even if we didn't ask for it!
Isolation
🌍 Global Actors in SwiftUI
Suppose we have a video list view model in SwiftUI:
swift@Observable class VideoListViewModel { var videos: [String] = [] func loadVideos() async throws { videos = ["Intro.mp4", "Demo.mov", "Outro.mp4"] } }
And the view using it:
swiftstruct VideoListView: View { @State var viewModel: VideoListViewModel var body: some View { List(viewModel.videos, id: .self) { video in Text(video) } .task { try? await viewModel.loadVideos() } } }
But this leads to a race and compiler error ❌.
🛠 Fix
Isolate the view model to the same actor as the view:
swift@Observable @MainActor class VideoListViewModel { var videos: [String] = [] func loadVideos() async throws { videos = ["Intro.mp4", "Demo.mov", "Outro.mp4"] } }
This ensures both view and model are on the same actor — avoiding data races.
🧩 Global Actors Reduce Concurrency Overhead
Global actors allow you to limit isolation contexts and reduce concurrency. MainActor is a global actor that ensures UI updates happen on the main thread.
It is declared like this:
swift@globalActor actor MainActor: GlobalActor { static let shared: MainActor }
Generally, you won't create global actors yourself, but you can use them to ensure that certain code runs on the main thread.
🧪 Region-Based Isolation and Unsafe State
Sometimes… unsafe state can safely cross isolation boundaries. For example, consider this:
swiftactor VideoTokenManager { var currentUser: User init(currentUser: User) { self.currentUser = currentUser } } class VideoNetworking { var tokenManager: VideoTokenManager? static func createManager() -> VideoTokenManager { let user = User() let manager = VideoTokenManager(currentUser: user) return manager } }
At first, this may appear dangerous — passing a User instance from a non-isolated context (VideoNetworking) to an actor (VideoTokenManager).
But Swift allows it safely under one condition: the User instance must not be accessed again from the original context.
So this:
swiftlet user = User() let manager = VideoTokenManager(currentUser: user)
...is safe because it follows region-based isolation. The User only exists temporarily, fully owned by the actor after that point.
Conclusion
Actors and region-based isolation in Swift 6 provide a powerful way to manage concurrency and state safely. By understanding how actors work, how they isolate state, and how to use global actors effectively, you can write safer, more maintainable concurrent code.
Next article will cover the changes in Task, @Sendable, and region-based isolation in Swift 6, including practical examples and migration tips.