Using Swift Actors to Protect Shared State in Asynchronous Code
Learn how Swift actors eliminate data races by isolating mutable state, see a concrete counter example, and understand the reentrancy trade‑off.
10 Sept 2026, 20:16 UTC

When you start writing asynchronous Swift code with async/await, mutable state that is accessed from multiple tasks can easily lead to data races. A data race occurs when two threads read and write the same memory location without proper synchronization, causing undefined behavior that is hard to reproduce and debug. The Swift concurrency model introduces actors as a language‑level solution to this problem.
Why Actors Help
An actor is a reference type that encapsulates its own mutable state and guarantees that only one task can execute its isolated code at a time. The compiler enforces this by requiring any external access to an actor’s properties or methods to be prefixed with await. This await marks a potential suspension point, allowing the runtime to switch to another task while guaranteeing that no other task is inside the actor.
In addition to the general actor type, Swift provides the global MainActor. Any code marked with @MainActor runs exclusively on the main thread, which is essential for UI updates.
How Actor Isolation Works
When you define an actor, all of its stored properties are implicitly isolated. Consider the following definition:
actor Counter {
private var value = 0
func increment() {
value += 1
}
func currentValue() -> Int {
return value
}
}
Because Counter is an actor, the value property can only be accessed from within the actor’s own context. External code must call its methods with await:
let counter = Counter()
Task {
await counter.increment()
print(await counter.currentValue())
}
The await tells the compiler that the call may suspend, allowing the runtime to ensure exclusive access.
Worked Example: Concurrent Increments
To see the safety guarantee in action, we can launch several tasks that increment the same counter many times.
let counter = Counter()
let incrementCount = 10_000
let taskCount = 5
for _ in 0..
If Counter were a plain class or struct without actor isolation, the concurrent increments could produce a final value less than the expected total due to lost updates. With the actor, each increment is serialized, so the final value matches the expected total.
Trade‑off: Reentrancy
Actors allow reentrancy: while a task is suspended at an await inside the actor, the actor can process another incoming message. This means that code after an await may observe a state that has changed in the meantime. For example:
actor Bank {
private var balance = 0
func deposit(_ amount: Int) async {
// Simulate some async work
try await Task.sleep(nanoseconds: 1_000_000)
balance += amount
}
func withdraw(_ amount: Int) async -> Bool {
if balance >= amount {
balance -= amount
return true
}
return false
}
}
If a deposit is awaited, another task could call withdraw and see the old balance, potentially withdrawing more than should be allowed after the deposit completes. Developers must design actor methods to be safe across suspension points, often by avoiding reliance on intermediate state or by using private helper methods that are not exposed as isolated entry points.
Reentrancy can also impact performance because the actor may interleave work from multiple tasks, increasing context‑switch overhead. For highly contended state where reentrancy is undesirable, consider using a serial dispatch queue or a lock instead of an actor.
Practical Verification
To confirm that your actor‑based code is free of data races, enable the Thread Sanitizer (TSan) in Xcode:
- Select your test scheme.
- Edit the scheme → Test → Arguments.
- Add
-TSanto the “Arguments Passed On Launch” section. - Run your unit or UI tests.
TSan will report any data races it detects during execution. If no warnings appear, you have strong evidence that the actor isolation is preventing concurrent mutable access.
When to Choose Actors
Use actors when you need to protect mutable state that is accessed from multiple concurrent asynchronous tasks, especially when the state is tightly coupled to the behavior (methods) that manipulate it. For simple counters, caches, or service managers, actors provide a clear, compile‑time guarantee of safety. If you find yourself fighting reentrancy or needing low‑latency, lock‑free alternatives, evaluate whether a traditional synchronization primitive or a dedicated serial queue better fits the performance profile.
By adopting actors, you move many thread‑safety concerns from runtime crashes to compile‑time checks, making your Swift concurrent code easier to reason about and maintain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.