Understanding Swift’s Synchronization Models
One problem. Four different concurrency models.
Over the years Swift has accumulated many different ways of synchronizing data access — some are so old that newer developers have never used them, others so new that many people aren’t even sure why they were added. I’d like to try to address both situations by looking at how four of these systems can help us prevent data races in a simple bank account example. We’ll see that each has its own mental model, strengths, and trade-offs. If you’re unfamiliar with data races or what causes them, I cover the topic in more detail here.
Let’s look at our single-threaded bank account implementation and see what goes wrong when it is instead accessed concurrently. To keep our focus on the concurrency models rather than the banking domain, we’ll keep it super simple and skip things like depositing and handling currency values. I’m also omitting Swift 6 strict concurrency compliance for the same reasons.
Basic Bank Account Implementation
final class BankAccount {
private var balance: Int
init(balance: Int) {
self.balance = balance
}
func withdraw(_ amount: Int) -> Bool {
guard currentBalance() >= amount else {
return false
}
balance -= amount
return true
}
func currentBalance() -> Int {
balance
}
}Let’s begin by defining the invariants our implementation should maintain.
A withdrawal must succeed only when sufficient money is available
The balance must never become negative.
And now let’s see if these hold when our bank account is accessed concurrently. Say there are two account owners and they decide to withdraw some money from an ATM at the same time.
For a refresher on async let and TaskGroups check out this article.
let account = BankAccount(balance: 100)
async let taskA = account.withdraw(80)
async let taskB = account.withdraw(50)
let results = await [taskA, taskB]
Starting balance: 100
Task A reads starting balance = 100
Task B reads starting balance = 100
Task A writes new balance = 20
Task B writes new balance = 50
Total withdrawn: 130
Ending balance: 50Because the operations may interleave, one possible execution is shown above, but many different interleavings can land us in a pretty bad state. Here we have violated our invariants and allowed a total of $130 to be withdrawn from an account containing only $100, even though the final balance still appears to be positive.
The issue is that the operations are not actually atomic (i.e. indivisible). Notice that even though balance -= amount looks like one statement in Swift, it is not one indivisible machine operation. It is actually three separate steps: load, decrement, and store. When different threads try to withdraw their execution of the three steps can get interleaved and the state gets corrupted. We need to prevent different threads from modifying the state simultaneously.
To prevent this, we need a synchronization mechanism that makes the critical section effectively atomic again. Swift gives us several different ways to accomplish this: NSLock, Mutex, Serial Dispatch Queues, and Actors. We’ll go through them one by one and dig into the details.
NSLock
We’ll start each section by previewing the full implementation.
import Foundation
final class BankAccount {
private let lock = NSLock()
private var balance: Int
init(balance: Int) {
self.balance = balance
}
func withdraw(_ amount: Int) -> Bool {
lock.withLock {
guard balance >= amount else {
return false
}
balance -= amount
return true
}
}
func currentBalance() -> Int {
lock.withLock {
balance
}
}
}Note that the closure-based withLock{} syntax is equivalent to the older style of manually calling lock() and unlock(), often in conjunction with a defer{} block to avoid missing early exits. The closure approach is generally clearer and also prevents us from accidentally forgetting to release the lock.
func currentBalance() -> Int {
lock.lock()
defer { lock.unlock() }
return balance
}The purpose of a lock is to create a region of code that only one thread may execute at a time. In our demo above, we noted that “A withdrawal must succeed only when sufficient money is available.” We can use that to define a critical section of our code that should not overlap and surround it with a lock. We have to be careful that the critical section covers the entire invariant, but no more than necessary. The process a thread will follow is:
Acquire the lock
Execute the critical section
Release the lock
While one thread owns the lock, other threads are blocked and must wait to acquire the lock before entering the protected region. This is called mutual exclusion and is what the term mutex stands for. NSLock is Foundation’s implementation of a traditional mutual exclusion lock.
So, we have successfully protected the balance with a lock and avoided data races. But, there are some weaknesses and nuances to using NSLock that we will examine next.
First, notice that inside our withdraw method I am directly reading the balance property, not using the currentBalance() method. If we ever need to add additional logic to the method, we would need to duplicate it inside withdraw(). Why not just use the method?
guard currentBalance() >= amount else {
return false
}Well, that would deadlock at runtime with no warning to the programmer writing it.
When currentBalance() attempts to acquire the lock, it discovers that the current thread already owns it (from inside withdraw()). So it blocks and waits for the lock to be released. However, withdraw() cannot release the lock until currentBalance() finishes executing. Neither can make progress.
A second class of issues relates to the fact that the relationship between the state and the lock exists only in the programmer’s head and the compiler has no visibility into it. This means that correctness must depend on the programmer always correctly understanding when and where locking is needed. Let’s say we later add a method to add interest to the balance.
func applyInterest(_ rate: Double) {
balance += Int(Double(balance) * rate)
}We’re back to having data races. The compiler cannot verify that every access is properly synchronized, so correctness depends entirely on programmer discipline.
Overall, NSLock is a manual, low-overhead, widely understood option for making a type thread-safe by acting as a gate allowing only one thread through at a time. Its main weakness is that the gating is not directly tied to the underlying data needing protection. We’ll look at Mutex next and see how it addresses this.
Mutex
import Synchronization
final class BankAccount {
private let balance: Mutex<Int>
init(balance: Int) {
self.balance = Mutex(balance)
}
func withdraw(_ amount: Int) -> Bool {
balance.withLock { balance in
guard balance >= amount else {
return false
}
balance -= amount
return true
}
}
func currentBalance() -> Int {
balance.withLock { $0 }
}
}Instead of importing Foundation to access NSLock, we are importing Synchronization to access Mutex. The Synchronization framework was introduced in the 2024 OS releases (iOS 18/macOS 15). As the name suggests, Mutex still provides mutual exclusion and operates very similarly to NSLock.
The improvement is that Mutex puts the state inside the lock. We no longer need to track balance and the lock separately. In fact, there is no independently accessible Int at all and thus no way to modify it and accidentally forget to apply locking.
func currentBalance() -> Int {
balance
}
Compiler Error: Cannot convert value of type ‘Mutex<Int>’ to expected argument type ‘Int’The implementation uses Swift’s newer ownership and isolation features (borrowing, sending, etc.) which allows the issues to be surfaced as errors at compile time. The only way to access the value is to temporarily borrow it through withLock(). Because the compiler understands the synchronization guarantees provided by Mutex, BankAccount can also safely conform to Sendable.
Two small notes regarding the syntax. Balance is declared as a let constant to prevent the mutex itself from being replaced, but the balance state it manages is still mutable. Second, withLock takes a closure with a parameter for the value stored inside the mutex which is modified via inout, so we reference it via $0 or a named parameter.
With Mutex we’re able to improve how state is represented and accessed. The data is directly tied to the fact that it must be accessed via a lock rather than being an undocumented convention. But we still have many of the same potential risks — deadlock is still possible and we still have to carefully establish the granularity of the lock to cover only the critical section.
Serial Queue
final class BankAccount {
private let queue = DispatchQueue(label: “BankAccount”)
private var balance: Int
init(balance: Int) {
self.balance = balance
}
func withdraw(_ amount: Int) -> Bool {
queue.sync {
guard balance >= amount else {
return false
}
balance -= amount
return true
}
}
func currentBalance() -> Int {
queue.sync {
balance
}
}
}Here we have switched to using a serial queue (unless we add the .concurrent attribute, the queue is serial). This is a completely different model for serializing access compared to the locks we’ve looked at above. Instead of structuring the code to allow a single caller into a critical section we are saying “Every operation touching this state must be submitted to the same ordered executor.” The queue itself becomes the synchronization primitive. Unlike locks, callers never directly coordinate with one another—they simply enqueue work.
The dispatch queue tracks work submitted to it and arranges it so that it always completes in order.
submitted: A → B → C
executed: A → B → CBy dispatching via sync we ensure that work does not continue until the closure completes. As with locks, the queue must enclose the full invariant — forgetting to wrap currentBalance() in a sync dispatch would again cause data races. And as with NSLock, nothing prevents a future developer from breaking enforcement like this:
func reset() {
balance = 0
}Beyond the mutual exclusion that NSLock and Mutex provide, queues also provide work scheduling. So they are a good fit when work operations need to be received and processed in order.
Actor
actor BankAccount {
private var balance: Int
init(balance: Int) {
self.balance = balance
}
func withdraw(_ amount: Int) -> Bool {
guard currentBalance() >= amount else {
return false
}
balance -= amount
return true
}
func currentBalance() -> Int {
balance
}
}The actor version is the smallest implementation so far. There is no explicit lock or queue logic because the actor declaration establishes an isolation boundary. This isolation means that the actor’s state can only be accessed directly by code running on that actor. So, within BankAccount, we do not need to do any manual synchronization. Code outside the actor interacts with it asynchronously by awaiting actor-isolated methods. It’s worth noting that having the async API cascade outward is not always a desired effect — callers must all either already be in an asynchronous context or create one.
Calls outside the actor:
Task {
let balance = await account.currentBalance()
let succeeded = await account.withdraw(80)
}I’ve covered suspension points and continuations in more detail in this article.
But the key concept here is that await marks a potential suspension point. The actor may already be processing an operation and so the caller may need to suspend until the actor can execute it. We are relying on the compiler to fully enforce isolation rather than relying on convention.
Note that we are able to safely call the currentBalance() method directly without issue. Because these are both synchronous and actor-isolated, this is totally safe. That reasoning changes when an actor method contains await, though. Suppose we need to add a remote authorization before withdrawing:
func withdraw(_ amount: Int) async -> Bool {
guard currentBalance() >= amount else {
return false
}
await authorize(amount)
balance -= amount
return true
}BankAccount is an actor so this seems safe at first glance. Every await divides the method into independent execution segments.
Segment 1: check balance
await authorization
Segment 2: subtract amountWhile the actor is awaiting authorization it is allowed to process another operation. In some ways, this is a welcome ability — the actor can remain productive and complete other work while slow operations wait. But here it means that the balance could be changed by a second operation started while the first withdraw call is suspended waiting on the authorization check to complete. This situation is called reentrancy.
The balance must be checked again after any async operations complete.
func withdraw(_ amount: Int) async -> Bool {
guard currentBalance() >= amount else {
return false
}
guard await authorize(amount) else {
return false
}
guard currentBalance() >= amount else {
return false
}
balance -= amount
return true
}The first balance check is an optimization to avoid an unnecessary authorization; the second is the correctness check. Or, if we wanted to avoid directly checking the balance twice, we could restructure it like this.
func withdraw(_ amount: Int) async -> Bool {
guard await authorize(amount) else {
return false
}
guard currentBalance() >= amount else {
return false
}
balance -= amount
return true
}Actor isolation guarantees exclusive access only while an actor-isolated segment is executing. Isolation does not extend across suspension points. In other words, every await inside an actor is a signal to check any assumptions established before the await and recheck them after resuming.
Actors move synchronization into the language itself so that the compiler can prevent unsynchronized access and eliminate the manual auditing required by locks and queues. But they replace reasoning about lock ownership with reasoning about suspension points. We are freed from data races, but still need to make sure we avoid higher-level logical race conditions due to reentrancy.
Conclusion
So when should we use each?
All four approaches solve similar underlying problems—making a critical section effectively atomic—but they fit best in slightly different situations:
Actors — Default choice for new asynchronous Swift code. The compiler enforces actor isolation, eliminating unsynchronized access to actor state. The main thing to watch for is reentrancy: every await inside an actor is a point where assumptions may need to be revalidated.
Mutex — An excellent choice for protecting synchronous shared state on modern OS releases. By putting the protected value inside the mutex, the compiler helps enforce correct access rather than relying entirely on programmer discipline. Like any lock, though, you still need to define the correct critical section and avoid deadlocks.
NSLock — A solid option when supporting older operating systems or working in existing Foundation-based code. It’s lightweight and widely understood, but the relationship between the lock and the data it protects exists only by convention, so correctness depends on consistently applying the locking discipline.
Serial Dispatch Queue — Best when the problem is naturally expressed as a sequence of ordered work rather than simply protecting shared state. Queues provide both mutual exclusion and scheduling, making them a great fit when the order of operations matters.
From a performance perspective, these primitives all have different costs, but synchronization overhead is rarely the bottleneck in real applications. In most cases, you’ll get better results by choosing the primitive whose mental model best matches your problem than by optimizing for small differences in synchronization performance.


