Today we are going to explore Swift Actors vs GlobalActors vs MainActor, as per normally I will explain to you deeply how the things became things. Swift 5.5 introduced. Actors to enhance concurrency by preventing data races and ensuring safe access to shared mutable state. Here in this article you will be learning about Actors, GlobalActors, MainActor, and their entire ecosystem deeply. We’ll also observe how the Swift compiler, LLVM, and Swift concurrency model internally handle them.
Before I even got into writing this article, a bunch of questions were still up in the air, and they really piqued my interest.
- What Actors are and how they work internally?
- How Actors ensures isolation and prevents data races?
- What is the role of GlobalActors and MainActor?
- How the Swift compiler manages actor-based concurrency?
- Best practices, pitfalls, and performance considerations
1. What Actors are and how they work internally?
An Actor in Swift is a reference type (like a class) that ensures thread-safe access to its mutable state by serializing tasks through an internal executor (queue). It prevents race conditions by allowing only one task to modify its state at a time. Unlike locks, Through structured concurrency, they manage execution safely
without manual synchronization., requiring await
for safe access. When an async function suspends, the actor becomes reentrant, allowing other tasks to execute in the meantime. Internally, Swift’s runtime schedules tasks efficiently, ensuring safe and optimized concurrency without manual synchronization.
1.1 Actor Syntax & Basics
actor BankManager {
var balance: Double = 0.0
func deposit(amount: Double) {
balance += amount
}
func getBalance() -> Double {
return balance
}
}
Key Properties of Actors
✔️ Isolation: Only one task can access the mutable state at a time.
✔️ Concurrency-safe: Prevents data races without requiring locks.
✔️ Reference Type: Like a class, but it enforces isolation.
1.2 Calling Actor Methods
Because actors are isolated, calling methods from outside requires awaiting an asynchronous execution:
let manager = BankManager()
Task {
await manager.deposit(amount: 100.0)
print(await manager.getBalance()) // 100.0
}
💡 Key takeaway:
- Direct actor method calls require
await
because they execute asynchronously. - Immutable properties can be accessed synchronously:
actor Counter {
let name: String = "MyCounter"
}
let counter = Counter()
print(counter.name) // ✅ Allowed, because `name` is immutable.
2. How Actors ensures isolation and prevents data races?
Actors guarantee isolation and prevent data races by enforcing exclusive access to their mutable state. Internally, each actor has a dedicated executor (queue) that serializes tasks, allowing only one operation to modify its properties at a time. Direct access to an actor’s state from outside is prohibited, requiring await for controlled interaction. This prevents multiple threads from reading and writing simultaneously, eliminating race conditions. Furthermore, Swift’s actor reentrancy allows other tasks to execute while an actor is waiting on an await operation, ensuring efficiency without compromising safety.
2.1 Unsafe code:
class UnsafeCounter {
var value = 0
}
let counter = UnsafeCounter()
DispatchQueue.concurrentPerform(iterations: 10) {
counter.value += 1 // 🚨 Data race
}
💥 The above code is unsafe because multiple threads can access value simultaneously
2.2 Actor ensuring safety
actor SafeCounter {
var value = 0
func increment() {
value += 1
}
}
let counter = SafeCounter()
Task {
await counter.increment() // ✅ Safe
}
🔹 Why is this safe?
Because Swift guarantees that only one task at a time can access value.
3. What is the role of GlobalActors and MainActor?
In Swift, GlobalActor
and @MainActor
are used to manage concurrency by ensuring that specific pieces of code execute on a designated actor, helping to prevent data races.
3.1 What Are GlobalActor?
Swift’s GlobalActor is a special type of actor that synchronizes access across multiple instances. define a singleton actor that provides execution context for specific functions, properties, or types. You can use it to group related code that should always execute on the same actor.
@globalActor
struct MyGlobalActor {
static let shared = MyActor()
}
actor MyActor {
func doWork() {
print("Executing on MyActor")
}
}
@MyGlobalActor
func someFunction() {
print("This function runs on MyGlobalActor")
}
MyGlobalActor
is a custom global actor. Any function, property, or type annotated with @MyGlobalActor
will execute within MyActor
but there is a limitation by design but not necessarily a flaw. global actor must have exactly one shared actor instance. You cannot have multiple shared
instances inside a @globalActor
. The compiler will reject this because it won’t know which actor to use.
3.2 What Are MainActor?
is a built-in global actor that ensures the annotated code runs on the main thread. It is crucial for updating UI in SwiftUI and UIKit applications.
You can use it when you want to handling view-related logic or ensuring that critical code executes on the main thread.
@MainActor
class ViewModel {
var text: String = "Loading..."
func updateText() {
text = "Updated!"
}
}
Equivalent to:
class ViewModel {
@MainActor var text: String = "Loading..."
@MainActor func updateText() {
text = "Updated!"
}
}
Key differences between GlobalActor
and @MainActor
Feature | GlobalActor | @MainActor |
---|---|---|
Purpose | Defines a custom global execution context. | Ensures execution on the main thread. |
Customization | Yes, you can create multiple global actors. | No, it is predefined. |
Use Case | Enforcing execution order for shared resources. | UI updates and main-thread-only operations. |
4. How the Swift compiler manages actor-based concurrency?
4.1 The Role of swiftc
(Swift Compiler) in Actor Isolation
The Swift compiler swiftc
enforces actor-based concurrency by ensuring that all access to actor-isolated state is properly synchronized. It does this through actor isolation rules, which prevent data races and enforce structured concurrency. When you mark a type with actor
, the compiler restricts direct access to its mutable state from outside. The compiler also detects nonisolated functions, allowing them to run outside the actor’s execution context when needed. Through static analysis, swiftc
guarantees that only one execution context mutates its state at a time, making concurrency safer and more predictable.
When you write:
actor MyActor {
var count = 0
func increment() {
count += 1
}
}
The Swift compiler swiftc
transform it into something like that:
class MyActor : public swift::Actor {
private:
int count;
public:
void increment() {
// Implicitly async-safe
count++;
}
}
Here’s what happens step by step:
- Actor Isolation Analysis: The compiler ensures that all accesses to mutable actor properties go through async barriers.
- Sendable Checking: The compiler enforces that actor states comply with
Sendable
for safe cross-thread access. - Message Passing Model: Under the hood, actors use a run queue model similar to an event loop.
4.2 Actor’s Execution Model in LLVM
The basis for such is Swift actors use a highly optimized execution model built on LLVM to manage concurrency efficiently. Instead of using the traditionally lock / unlock mechanisms, Swift actors use Message-passing model, similar to Erlang or channels in Go, which ensure that only one task runs at any given time within any given actor.
Every actor has a Job Queue, we calling that a FIFO (First-In, First-Out) queue of tasks that are executed singly in order, just because of that we saying actors prevents data races.
When an async function within an actor calls await, Swift use continuations, a bloc of logic based on LLVM coroutine mechanism to suspend a task without blocking the thread. When the awaited operation completes, the task resumes exactly where it paused, and this is the secret of the power and the efficient of swift actors.
- Job Queues: A FIFO queue that executes tasks one by one.
- Continuations: Swift use LLVM-based coroutines (
async
/await
) to suspend and resume tasks. - Actor Locks, No! Unlike traditional locks, Swift actors use message passing
5. Best Practices & Performance Considerations
Swift actors help to manage concurrency safety, but they are not always the best choice. Understanding when to use them and how they impact performance is very important if you want to writing efficient code.
5.1 When to Use Actors?
Actors are useful when multiple parts of your app need to access and modify the same data safely. as I explained they prevent race conditions by ensuring that only one task modifies the data at a time. This is useful for shared mutable state, caches, and replacing complex locking mechanisms.
Scenario | Why? |
---|---|
Protecting shared mutable state | Prevents race conditions |
Managing concurrent data (caches) | Ensures consistency and avoids corruption |
Avoiding locks (DispatchQueue ) | Safer and easier than manual synchronization |
5.2 When are Actors unnecessary?
Actors add overhead, so they are unnecessary when performance is critical or when data is already immutable. If a struct can do the job without needing synchronization, it is the better choice.
Scenario | Better Alternative |
---|---|
Performance-critical operations | Direct access (no actor) |
Data never changes (immutable) | Use a struct |
No need for global coordination | Use local variables |
5.3 Performance Considerations
Also actors work by message-passing, meaning every task must wait its turn in a queue. Frequent cross-actor calls slow things down. A better approach is to store frequently accessed data inside the actor to reduce unnecessary communication.
5.3.1 Bad Approach (Inefficient Cross-Actor Calls)
actor InvoiceGenerator {
func generateInvoice(for userId: String) async -> Invoice {
// ❌ Cross-actor call 1
let userDetails = await fetchUserDetails(userId: userId)
// ❌ Cross-actor call 2
let purchaseHistory = await fetchPurchaseHistory(userId: userId)
return Invoice(user: userDetails, purchases: purchaseHistory)
}
}
func processInvoices(userIds: [String]) async {
let generator = InvoiceGenerator()
for userId in userIds {
// ❌ Multiple calls to actor
let invoice = await generator.generateInvoice(for: userId)
print("Generated invoice for \(invoice.user.name)")
}
}
5.3.2 Better Approach (Batching Requests Inside the Actor)
actor InvoiceGenerator {
func generateInvoices(for userIds: [String]) async -> [Invoice] {
// ✅ Batch fetch
let userDetails = await getMultipleUserDetails(userIds: userIds)
// ✅ Batch fetch
let purchasesList = await getMultiplePurchases(userIds: userIds)
return userIds.compactMap { id in
guard let user = userDetails[id],
let purchases = purchasesList[id]
else { return nil }
return Invoice(user: user, purchases: purchases)
}
}
}
func processInvoices(userIds: [String]) async {
let generator = InvoiceGenerator()
// ✅ One call instead of multiple
let invoices = await generator.generateInvoices(for: userIds)
for invoice in invoices {
print("Generated invoice for \(invoice.user.name)")
}
}
Why Is This Better?
- Only one cross-actor call is made, reducing overhead.
- Cached profiles are accessed locally, avoiding unnecessary work.
- Missing profiles are fetched in one batch, improving performance.
Conclusion
Actors preventing race conditions by controlling access to shared state but should be used wisely to avoid performance obstruct. We should keep frequently accessed data inside the actor and minimize cross-actor calls. Using global actors when a task must always run in the same execution context. With these kind of practices, we can write safe and efficient Swift concurrency code.
Use Case | Recommended Actor |
---|---|
UI updates in SwiftUI | @MainActor |
Background processing | Regular actor |
Logging, global state | Custom @GlobalActor |
Sources
For further reading and to dive deeper into Swift actors and concurrency, you can explore the following resources:
- The official Swift documentation on concurrency provides a comprehensive overview of actors,
async/await
, and structured concurrency. - The Swift Evolution proposal for actors (SE-0306) explains the design and implementation details behind Swift’s actor model.
- Learn more about the underlying mechanisms in the LLVM Coroutines documentation, which powers Swift’s
async/await
and task suspension. - For a visual and detailed explanation, check out the WWDC 2021 video on Swift concurrency, where Apple engineers discuss how actors and
async/await
work together.