Introduction
In this article, we will dive deep into actors, nonisolated methods, @MainActor and @GlobalActors, and the concept of actor reentrancy. We will also explore what happens behind the scenes in the Swift Concurrency runtime - including jobs, executors, workers, and schedulers - so you can understand not just how to use these tools, but why they work the way they do.
Whether you’re already using Swift’s async/await features or just starting to explore concurrency, this guide will give you a solid understanding of the mechanisms that keep your concurrent code safe and efficient.
Table of contents
Open Table of contents
- Actors and Isolation in Swift Concurrency
- Working with
@MainActorand Other@GlobalActors - Actor Reentrancy Explained
- The Hidden Risks of Suspending Inside Actor Methods
- Inside the Swift Concurrency Runtime
- Conclusion
Actors and Isolation in Swift Concurrency
If you’ve spent years working with GCD, you already know the core problem: shared mutable state. When multiple threads can read and write the same data at the same time, you risk data races: inconsistent reads, lost updates, or crashes that only appear under heavy load.
With GCD, we relied on discipline using serial queues or locks. But discipline fails. One forgotten .sync call and your correctness vanishes. Swift Concurrency introduces Actors to make data-race freedom a language-level guarantee.
Class vs Struct vs Actor
| Type | Semantics | Thread Safety | Mutation Model |
|---|---|---|---|
| Struct | Value | By-copy safe | Explicit mutating |
| Class | Reference | Unsafe by default | Shared mutable state |
| Actor | Reference | Data-race safe | Serialized access |
Actors sit exactly where classes used to be but with correctness guarantees.
Actor Basics
An actor is a reference type that protects its mutable state through isolation. Unlike a class, you cannot accidentally touch an actor’s internal state from multiple threads.
actor BankStore {
private var balance: Int = 0
func deposit(_ amount: Int) {
balance += amount
}
func withdraw(_ amount: Int) -> Bool {
guard balance >= amount else { return false }
balance -= amount
return true
}
}
Key properties of actors:
- Reference semantics
- Only one task at a time can access actor-isolated state
- External access requires
await
nonisolated: Opting Out of Isolation
Sometimes you need functionality that doesn’t touch the actor’s state or needs to be callable synchronously. Use the nonisolated keyword for these “pure” utilities.
actor ImageCache {
nonisolated static let maxItems = 100
nonisolated func cacheKey(for url: URL) -> String {
url.absoluteString
}
}
Rule of thumb: if it reads or writes actor state - it should not be nonisolated.
The Actor Model: The Mailbox Mental Model
Think of an actor as having a mailbox:
- Each actor has a queue of pending work.
- Messages (calls) are enqueued as tasks.
- The actor processes these one at a time.
When you write await store.deposit(50), you aren’t calling a function in the traditional sense. You are sending a message to the actor and suspending your current thread until the actor finishes processing that message. This is why await is mandatory: the actor might be busy with someone else’s request.
Working with @MainActor and Other @GlobalActors
When building scalable iOS applications, managing shared state across isolated domains like UI components, network layers, and local caches becomes a complex puzzle. Swift simplifies this with @GlobalActor.
A global actor is essentially a singleton actor. It allows you to isolate state and operations globally without needing to pass an actor reference around your entire dependency graph. The most famous of these is, of course, the @MainActor.
The @MainActor is uniquely tied to the main thread. Anything marked with this attribute is guaranteed to execute on the main thread, making it the bedrock for all UI updates.
@MainActor
final class FlashcardViewModel: ObservableObject {
@Published var currentCard: Card?
func loadNextCard() async {
// Safe to update UI state directly; we are isolated to the MainActor.
self.currentCard = await fetchCard()
}
}
However, the power of global actors isn’t limited to the main thread. You can define your own global actors to serialize access to highly contested shared resources, such as a centralized local database or an aggressive retry policy manager.
@globalActor
public actor SyncActor {
public static let shared = SyncActor()
}
@SyncActor
final class OfflineSyncManager {
var pendingMutations: [Mutation] = []
func queue(mutation: Mutation) {
pendingMutations.append(mutation)
}
}
By annotating OfflineSyncManager with @SyncActor, you guarantee that all accesses to pendingMutations are serialized on that specific actor’s executor, completely eliminating data races from different parts of your app trying to queue offline changes simultaneously.
Actor Reentrancy Explained
If you’re coming from the world of Grand Central Dispatch (GCD) and DispatchQueue, actors require a fundamental mental shift. A serial dispatch queue executes tasks strictly one after another. If a task is running, nothing else can run on that queue until it finishes.
Swift actors are different: they are reentrant.
Reentrancy means that while an actor guarantees mutual exclusion for synchronous code execution (only one thread can be inside the actor at a time), it explicitly allows other tasks to interleave at suspension points.
When an actor encounters an await, it suspends the current task. Crucially, it also gives up its lock on the executor. During this suspension, the actor is completely free to pick up and execute other pending tasks. Once the awaited operation finishes, the original task is scheduled to resume on the actor when it’s free again.
This design prevents deadlocks. If actors weren’t reentrant, two actors awaiting each other would instantly freeze your application. However, reentrancy introduces its own subtle class of concurrency bugs.
The Hidden Risks of Suspending Inside Actor Methods
Because the actor unblocks during an await, the state of your actor before the await might not match the state after the await. This is the single biggest trap engineers fall into when adopting Swift Concurrency.
Imagine implementing a session manager that fetches a fresh authentication token. If multiple requests fail and trigger a token refresh simultaneously, you might accidentally fire off multiple network requests if you don’t account for reentrancy.
actor SessionManager {
private var cachedToken: String?
func getValidToken() async throws -> String {
// 1. Check local state
if let token = cachedToken {
return token
}
// 2. Suspend! The actor is now free to process other calls to `getValidToken()`
let freshToken = try await performNetworkRefresh()
// 3. State mutation.
// DANGER: If another task interleaved during step 2, we might overwrite a valid token,
// or we just unnecessarily performed multiple network requests.
self.cachedToken = freshToken
return freshToken
}
}
To protect against this, you must rethink how you handle in-flight asynchronous operations. Instead of caching just the result, you often need to cache the Task itself.
actor SessionManager {
private var cachedToken: String?
private var refreshTask: Task<String, Error>?
func getValidToken() async throws -> String {
if let token = cachedToken { return token }
// Return the in-flight task if one exists
if let existingTask = refreshTask {
return try await existingTask.value
}
// Otherwise, create a new task and cache IT immediately
let task = Task {
let freshToken = try await performNetworkRefresh()
self.cachedToken = freshToken
self.refreshTask = nil // Clean up
return freshToken
}
self.refreshTask = task
return try await task.value
}
}
Always remember: across an await, your actor’s state is completely unguarded.
Inside the Swift Concurrency Runtime
To truly master structured concurrency, we need to step out of the syntax and into the engine room. Swift’s concurrency model isn’t just syntactic sugar over GCD; it is a completely bespoke, highly optimized runtime built around a cooperative thread pool.
Understanding Jobs
In the Swift runtime, a Job is the fundamental unit of schedulable work. When you write an async function, the compiler breaks your function down into partial tasks or “continuations” split at every await keyword.
Each of these segments is wrapped into a Job. When a task suspends, the current Job finishes. When the awaited result is ready, a new Job is enqueued to resume the remainder of the function. Jobs are lightweight, heavily optimized, and managed entirely by the Swift runtime.
How Executors Work
If Jobs are the work, Executors are the environments where the work is allowed to happen. An executor defines the execution semantics for a set of Jobs.
Every actor has a serial executor. This executor acts as a funnel, ensuring that only one Job associated with that actor runs at any given microsecond. When you call an actor method, you are submitting a Job to that actor’s executor.
Custom Serial Executors (Actor Level)
In the first example, we create a MainQueueExecutor conforming to SerialExecutor. This is particularly useful when you have a legacy codebase heavily dependent on a specific DispatchQueue and you want to wrap that logic into a modern Actor.
final class MainQueueExecutor: SerialExecutor {
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let unownedExecutor = asUnownedSerialExecutor()
DispatchQueue.main.async {
unownedJob.runSynchronously(on: unownedExecutor)
}
}
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
UnownedSerialExecutor(ordinary: self)
}
}
@globalActor
actor CustomGlobalActor: GlobalActor {
static let sharedUnownedExecutor = MainQueueExecutor()
static let shared = CustomGlobalActor()
nonisolated var unownedExecutor: UnownedSerialExecutor {
Self.sharedUnownedExecutor.asUnownedSerialExecutor()
}
}
Task Executors (Task Level)
While a SerialExecutor protects an actor’s state, a TaskExecutor influences the “ambient” environment where a task and its children run. It doesn’t provide serial isolation; it provides a preferred execution location.
final class MainQueueExecutor: TaskExecutor {
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
self.enqueue(unownedJob)
}
func enqueue(_ job: UnownedJob) {
let unownedExecutor = asUnownedTaskExecutor()
DispatchQueue.main.async {
job.runSynchronously(on: unownedExecutor)
}
}
func asUnownedTaskExecutor() -> UnownedTaskExecutor {
UnownedTaskExecutor(ordinary: self)
}
}
let executor = MainQueueExecutor()
Task.detached(executorPreference: executor) {
// TODO: Perform an async operation
}
What Workers Do
Executors don’t magically run code; they need CPU threads. This is where Workers come in.
In Swift Concurrency, there is a global, cooperative thread pool. The threads inside this pool are the “workers.” Unlike GCD, which could spawn hundreds of threads leading to thread explosion and massive memory overhead, the Swift thread pool is strictly limited generally to the number of active CPU cores. owever, this isn’t a hard-and-fast rule; there are specific cases where the pool may spawn more threads. We took a deep dive into this behavior in the article Swift Concurrency: Part 1
Workers ask executors for Jobs. When a worker thread picks up a Job from an executor, it executes it until completion or suspension. Because the number of workers is limited, Swift enforces a strict rule: you must never use blocking APIs (like semaphores or synchronous network calls) inside an async context. If you block a worker thread, you are permanently stealing a core from the concurrency runtime.
The Role of Schedulers
The Scheduler is the invisible conductor orchestrating this entire process. It decides which Jobs sit on which Executors, and which Workers get assigned to process them.
The scheduler is highly priority-aware. When you spawn a Task(priority: .userInitiated), the scheduler ensures the resulting Jobs jump ahead of .background Jobs in the queue. It handles the complex logic of priority inversion avoidance, waking up worker threads, and balancing the load across the CPU.
Types of Executors and How They’re Chosen
Swift utilizes different types of executors depending on the context of your code:
-
The Global Concurrent Executor: If your code is not isolated to any actor (e.g., a detached task or a standalone async function), it runs on the default global concurrent executor. This executor distributes Jobs freely across all available workers in the cooperative thread pool.
-
The Main Actor Executor: This is a specialized serial executor permanently bound to the application’s main thread. The scheduler ensures that any Job submitted here is handed off to the main runloop.
-
Default Serial Executors: Every standard
actoryou create gets its own default serial executor. The runtime dynamically maps this executor to any available worker thread in the pool as needed. -
Custom Executors (Swift 5.9+): Advanced use cases might require overriding how an actor executes its jobs. By implementing the SerialExecutor protocol, you can create custom executors for instance, to force an actor to run its jobs on a specific, legacy DispatchQueue to interoperate with older C++ or Objective-C codebases seamlessly.
How the Runtime Chooses an Executor
Understanding that executors exist is one thing; predicting exactly where your code will run is another. When a Job is ready to execute, the Swift runtime evaluates a precise decision tree to route that workload.
Here is the exact algorithm the runtime uses to select an executor:

Is the method isolated? (i.e., is it bound to a specific actor?)
-
No (Non-isolated):
-
Is there a preferred Task executor?
-
Yes: The task executes on the Preferred Task Executor.
-
No: The task executes on the standard Global Concurrent Executor.
-
-
-
Yes (Actor-isolated):
-
Does the actor provide its own custom executor?
-
Yes: The task executes strictly on the Actor’s Custom Executor.
-
No: Does the current Task have a preferred executor?
-
Yes: The task executes on the Preferred Task Executor (while still strictly upholding the actor’s serial isolation).
-
No: The task executes on the Default Actor Executor.
-
-
-
This cascading logic ensures that actors maintain their state safety while allowing developers to influence the underlying execution environment when necessary.
Inspecting Your Context: The #isolation Macro
When dealing with deep call stacks and complex async boundaries, you might lose track of your current execution context. Swift 5.10 introduced a brilliant diagnostic tool to solve this: the #isolation macro.
This macro evaluates at compile time to capture the actor isolation of the current context. It returns an any Actor? representing the actor you are currently isolated to, or nil if you are executing concurrently.
func debugCurrentContext() {
// Prints the instance of the actor (like MainActor), or "no isolation"
print(#isolation ?? "no isolation")
}
Sprinkling this into your logging infrastructure is invaluable when debugging data races or verifying that a heavy computation isn’t accidentally blocking the @MainActor.
Task Executors vs. Actor Executors
With recent advancements in Swift Evolution (specifically SE-0417 and SE-0392), developers now have the unprecedented ability to provide custom executors. However, to wield this power safely, you must deeply understand the difference between the two primary executor protocols: TaskExecutor and ActorExecutor (via SerialExecutor).
What is a Task Executor?
A Task Executor governs the execution environment for a specific Task hierarchy. Crucially, a Task Executor is inherently concurrent. It represents a thread pool or a concurrent queue where multiple jobs can be processed simultaneously. When you assign a preferred Task Executor, you are telling the runtime, “Unless an actor says otherwise, run the asynchronous work for this task pool over here.”
What is an Actor Executor?
An Actor Executor (which conforms to the SerialExecutor protocol) governs the execution environment for a specific actor instance. Unlike a Task Executor, an Actor Executor is strictly serial. It processes one job at a time, enforcing the mutual exclusion that makes actors safe from data races.
The Danger of Custom Implementations
Understanding the concurrent nature of Task Executors and the serial nature of Actor Executors is not just trivia it is a strict runtime invariant.
If you decide to write a custom executor (for example, wrapping an old C++ thread pool or a specific Grand Central Dispatch queue), you carry the burden of upholding these invariants:
-
If you implement a
SerialExecutorfor an actor, but your underlying implementation accidentally allows concurrent execution, you will break the actor’s state isolation and introduce impossible-to-reproduce data races. -
Conversely, if you implement a
TaskExecutorbut back it with a serial queue, you risk starving the cooperative thread pool and introducing unexpected deadlocks across your async task hierarchies.
The compiler trusts you to maintain these semantic guarantees. If you break them, the concurrency model shatters.
Conclusion
Swift Concurrency is more than syntactic sugar for asynchronous code. It is a carefully designed execution model that formalizes how work is scheduled, isolated, and resumed. Actors provide safety guarantees, but understanding reentrancy and executor behavior is what allows engineers to reason about concurrency with confidence.
By understanding these low-level mechanics when an actor temporarily releases isolation and how the runtime schedules jobs across worker threads you can build iOS applications that are not only performant, but also resilient to the subtle concurrency bugs that once plagued asynchronous systems.
Thanks for reading
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.