kostia.dev
Swift
Concurrency
iOS

Swift Actors, Data races and region-based isolation

Jun 10, 2025
9 min read

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)

swift
class 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

swift
class 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

swift
class 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:

swift
class 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:

swift
actor 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:

swift
let 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:

  1. You call a method on an actor from the outside.
  2. The method call is added to the actor’s mailbox.
  3. The actor processes the mailbox serially, ensuring exclusive access to its internal state.
  4. 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:

swift
struct 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:

swift
actor 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:

swift
let 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.

Related Posts

Swift
Concurrency
iOS
A guide to region-based isolation, @Sendable, sending and unchecked sendable, and closure safety in Swift 6 with examples.
6/12/2025
7 min read
3 shared tags
iOS
Swift
Performance
Swift implements four types of method dispatch: inlining, static, table, and message. Understanding which one applies and when explains some of Swift's most confusing behaviour — and helps you write faster code.
2/21/2026
10 min read
2 shared tags
Swift
Concurrency
Swift 6 Migration
Refactoring
Sendable
Region-Based Isolation
Practical refactoring tips for migrating to Swift 6 concurrency: @Sendable, sending, region-based isolation, and common error fixes.
6/13/2025
5 min read
2 shared tags