Greetings, traveler!
Concurrency in modern iOS applications shows up almost everywhere. Networking, database access, image processing, and even parts of UI logic often run in parallel. The system schedules work across multiple threads, and for the most part this happens transparently. The code compiles, the app runs, and everything looks fine until shared state enters the picture.
The moment multiple execution contexts start interacting with the same mutable data, the model becomes harder to reason about. A simple example is a cache or an in-memory store that is accessed from different parts of the app. One task writes a value while another reads or updates it at the same time. There is no guarantee about the order of these operations, and the result depends on timing rather than logic.
Thread safety
This is where thread safety becomes relevant. A piece of code is considered thread-safe when it behaves correctly under concurrent access. That usually means one of two things: either the state is isolated so that only one context can access it at a time, or access to the state is coordinated in a way that prevents conflicting operations from interleaving.
In practice, most issues come from the same pattern: shared mutable state without clear ownership. The language and the runtime do not prevent this by default. Swift gives strong guarantees around memory safety, but it does not automatically make arbitrary code safe to use from multiple threads.
The rest of the discussion in this article focuses on how to control access to that shared state. Swift offers several approaches, each operating at a different level of abstraction. Some of them integrate with the language and guide how APIs are designed. Others give direct control over execution and leave correctness entirely in the hands of the developer. Choosing between them is less about syntax and more about understanding what kind of guarantees are needed in a given part of the system.
Synchronization tools in Swift
Swift gives you several ways to control access to shared state, but they solve this problem at different levels.
Actors work at the language level. They isolate mutable state behind an asynchronous boundary, and the compiler participates in enforcing that boundary. You do not manually coordinate access to the state. You define ownership, and the runtime serializes access for you.
DispatchQueue works at the execution level. It lets you decide where and in what order work runs. A serial queue can protect shared state by forcing all access through one execution context. A concurrent queue can combine parallel reads with barrier writes. The model is flexible, but the guarantees are not built into the type system. They depend on how consistently the queue is used.
Locks and mutexes work at the critical-section level. They do not define ownership and they do not change the shape of the API. They simply ensure that only one thread can execute a protected region at a time. This gives direct control and low overhead, but it also leaves correctness almost entirely in the developer’s hands.
Atomics solve a narrower problem. They make a single operation on a value safe under concurrent access, but they do not protect larger pieces of state or preserve invariants across multiple values. They are useful for counters, flags, and a small set of low-level patterns. They are rarely the right starting point for application code.
There are also adjacent tools such as semaphores, dispatch groups, and operation queues. They help coordinate work, express dependencies, or limit concurrency. That matters, but it solves a different problem. These APIs control when tasks run or how they relate to each other. They do not define ownership or protect shared state by default. Used carefully, they can be part of a synchronization strategy, but they do not provide safety on their own.
Seen together, these tools form a spectrum. At one end, Swift gives you language-level isolation. At the other, it gives you low-level primitives with very little built-in protection. The difference between them is where the guarantee comes from, how visible that guarantee is in the API, and how much discipline the code relies on.
Actors: isolation as a language feature
Actors introduce a different way of thinking about synchronization. Instead of protecting access to shared state after the fact, they define ownership upfront. The state lives inside the actor, and all interaction with that state goes through its interface. This changes how code is structured. The question shifts from “how do I make this thread-safe” to “who owns this state”.
Under the hood, an actor processes one piece of work at a time. Calls into an actor are scheduled as tasks, and the runtime ensures that its mutable state is never accessed concurrently. The compiler is aware of actor isolation and enforces it at the call site. Access requires await, and synchronous entry points are not allowed to bypass that boundary.
A simple example is a cache that may be accessed from multiple parts of the application. Without synchronization, concurrent reads and writes would lead to undefined behavior. With an actor, the ownership is explicit and access is serialized by design.
actor ImageCache {
private var storage: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
storage[url]
}
func insert(_ image: UIImage, for url: URL) {
storage[url] = image
}
}Using this cache from concurrent code requires await, which makes the boundary visible at the call site:
let cache = ImageCache()
func loadImage(from url: URL) async -> UIImage? {
if let cached = await cache.image(for: url) {
return cached
}
let image = await downloadImage(from: url)
if let image {
await cache.insert(image, for: url)
}
return image
}This example is intentionally simple, but it shows the core idea. The cache state is never exposed directly, and all mutations go through the actor. There is no need for explicit locks or queues. The isolation is enforced by the language, and incorrect usage is caught at compile time.
This has a direct impact on API design. Once a type becomes an actor, its interface becomes asynchronous. Even simple operations that would otherwise be synchronous now require suspension points. This is often a reasonable trade-off in parts of the system that are already asynchronous, such as networking layers or long-lived services. In other areas, especially those that are heavily synchronous or performance-sensitive, this can introduce friction.
Another aspect that tends to surface in real code is reentrancy. While an actor executes one task at a time, it may suspend during an await and allow another task to run before resuming. This means that state observed before the suspension may change by the time execution continues. The model remains safe in terms of data races, but reasoning about ordering requires attention.
In practice, actors work well as boundaries around stateful components. Caches, managers, and services that coordinate multiple operations benefit from a clear ownership model. The code becomes easier to follow because access patterns are explicit, and many classes of concurrency bugs are eliminated by construction.
At the same time, actors are not a universal replacement for lower-level primitives. The scheduling model, the need for await, and the cost of task suspension all play a role in performance-sensitive paths. When operations are small, frequent, and tightly coupled, the overhead becomes more visible.
Seen in context, actors represent the highest level of abstraction available in Swift today. They provide strong guarantees and shape how code is written, which makes them a natural starting point when designing concurrent systems.
DispatchQueue: explicit coordination
Before actors became part of the language, DispatchQueue was the default way to coordinate concurrent work in Swift code. It is still widely used today, both in existing codebases and in places where explicit control over execution is more important than language-level isolation.
The core idea is straightforward. A queue controls when a block of work runs. A serial queue executes one block at a time. A concurrent queue may run multiple blocks in parallel. This makes queues useful both for scheduling work and for protecting shared state.
A serial queue is the simplest form of synchronization with GCD. If every read and write goes through the same queue, access becomes serialized.
final class TokenStore {
private let queue = DispatchQueue(label: "com.example.token-store")
private var token: String?
func read() -> String? {
queue.sync {
token
}
}
func write(_ newValue: String?) {
queue.sync {
token = newValue
}
}
}This works because the queue becomes the synchronization boundary. Even if multiple threads call read() and write() at the same time, the queue processes those closures one by one.
That simplicity is also where the first trade-off appears. The correctness of this approach depends on discipline. The state must never be touched outside the queue. The compiler does not help enforce that rule. The safety comes from convention and code review.
For read-heavy state, a common pattern is to use a concurrent queue with barrier writes. Regular reads can execute in parallel, while mutations are isolated behind a barrier.
final class SettingsStore {
private let queue = DispatchQueue(
label: "com.example.settings-store",
attributes: .concurrent
)
private var storage: [String: String] = [:]
func value(for key: String) -> String? {
queue.sync {
storage[key]
}
}
func set(_ value: String, for key: String) {
queue.async(flags: .barrier) {
self.storage[key] = value
}
}
}Here, reads are allowed to run concurrently as long as no barrier block is active. A barrier write waits until earlier reads complete, performs the mutation alone, and then allows later reads to continue. This can work well for caches, dictionaries, and other structures where reads are much more frequent than writes.
It also introduces more surface area for mistakes. The distinction between sync, async, and barrier has to remain consistent across the type. One write performed without the barrier breaks the model. One direct access to storage from outside the queue breaks it as well.
Another issue with queues is that they are easy to misuse in a way that looks harmless. Calling sync on the same serial queue from work already running on that queue will deadlock.
final class Counter {
private let queue = DispatchQueue(label: "com.example.counter")
private var value = 0
func increment() {
queue.sync {
value += 1
logCurrentValue()
}
}
private func logCurrentValue() {
queue.sync {
print(value)
}
}
}The problem here is subtle. increment() already runs on queue, and logCurrentValue() tries to synchronously dispatch onto that same queue again. The second sync waits for the queue to become available, but the queue is already occupied by the current block. That is a deadlock.
The corrected version keeps all related work inside the same critical section.
final class Counter {
private let queue = DispatchQueue(label: "com.example.counter")
private var value = 0
func increment() {
queue.sync {
value += 1
print(value)
}
}
}This style of synchronization gives direct control over how work is executed. That control can be valuable. Queues fit naturally into legacy GCD-based systems, callback-driven code, and components where the execution model matters as much as the protected state.
At the same time, queues do not express ownership as clearly as actors. They coordinate access, but they do not redefine the shape of the API around isolation. A queue-backed type may still look fully synchronous from the outside, which can be convenient, but it also hides the concurrency boundary from the call site.
With actors, the isolation is part of the type system. With queues, the isolation is a property of the implementation. Both approaches can work well. The queue-based version asks the developer to maintain the contract manually.
Locks and mutexes: direct control over access
Locks and mutexes sit closer to the hardware model than queues or actors. They do not define ownership and they do not reshape the API around isolation. A lock protects a critical section and ensures that only one thread can execute that section at a time.
That makes the model easy to describe. When code enters the protected region, it acquires the lock. When it leaves, it releases it. Any other thread attempting to enter the same region waits until the lock becomes available.
A basic example with NSLock looks like this:
final class Counter {
private let lock = NSLock()
private var value = 0
func increment() {
lock.lock()
defer { lock.unlock() }
value += 1
}
func currentValue() -> Int {
lock.lock()
defer { lock.unlock() }
return value
}
}This is direct and readable. The critical section is visible in the code, and the API remains synchronous. There is no await, no queue hopping, and no implicit scheduling model beyond the lock itself.
That simplicity is also why locks remain relevant in performance-sensitive paths. The overhead is low, and the execution model is predictable. If the protected work is small, a lock can be an efficient way to guard shared mutable state.
At the same time, the safety model here is entirely manual. The compiler does not know that value should only be accessed while the lock is held. If another method touches the same state without locking, the guarantee is gone.
It is also easy to design a locking scheme that looks reasonable and still breaks at runtime. One of the most common failures is reentrant locking on a non-recursive lock.
final class Counter {
private let lock = NSLock()
private var value = 0
func increment() {
lock.lock()
defer { lock.unlock() }
value += 1
logValue()
}
private func logValue() {
lock.lock()
defer { lock.unlock() }
print(value)
}
}This code deadlocks. increment() acquires the lock and then calls logValue(), which tries to acquire the same lock again on the same thread. Since NSLock is not recursive, the second lock attempt waits forever.
The fix is usually a sign that the critical section was split in the wrong place. Related operations should stay under one lock scope.
final class Counter {
private let lock = NSLock()
private var value = 0
func increment() {
lock.lock()
defer { lock.unlock() }
value += 1
print(value)
}
}Another common use case is protecting a shared dictionary or cache.
final class ImageCache {
private let lock = NSLock()
private var storage: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
lock.lock()
defer { lock.unlock() }
return storage[url]
}
func insert(_ image: UIImage, for url: URL) {
lock.lock()
defer { lock.unlock() }
storage[url] = image
}
}This pattern is simple and often effective. Every access to storage goes through the same lock, so reads and writes cannot interleave unsafely.
Swift 6 adds a more modern form of this model with Mutex in the Synchronization framework. The underlying idea remains the same, but the API is shaped around a closure, which makes the critical section more explicit.
import Synchronization
final class ImageCache {
private let storage = Mutex<[URL: UIImage]>([:])
func image(for url: URL) -> UIImage? {
storage.withLock { storage in
storage[url]
}
}
func insert(_ image: UIImage, for url: URL) {
storage.withLock { storage in
storage[url] = image
}
}
}This version removes the manual lock() and unlock() calls from the call site. That reduces one category of mistakes and makes the protected region easier to spot during review. The underlying trade-offs stay the same. The state remains shared mutable state, and correctness still depends on always going through the synchronization boundary.
The Mutex type from the Synchronization framework is not a completely new mechanism. Under the hood, it relies on the same low-level locking primitives provided by the operating system. On Apple platforms, this ultimately maps to os_unfair_lock, which is designed to provide fast mutual exclusion with minimal overhead.
This is also why the performance characteristics of Mutex and traditional locks are often very close. The higher-level API removes some of the boilerplate and makes the critical section explicit through a closure, but the underlying execution model remains the same: a thread acquires a lock, performs work, and releases it.
os_unfair_lock itself replaced older locking primitives like OSSpinLock. Unlike spin locks, it does not actively burn CPU while waiting. Instead, the system may park the thread and reschedule it later. The “unfair” part refers to the fact that lock acquisition is not strictly first-come, first-served. This improves throughput under contention, but it means there are no guarantees about which waiting thread will acquire the lock next.
In practice, this detail rarely changes how application code is written. What it does explain is why these primitives behave the way they do. They are simple, efficient, and very close to the runtime, but they also come with minimal guarantees beyond mutual exclusion.
Locks and mutexes work well when the protected work is small, synchronous, and frequent. They are often a good fit for counters, caches, registries, and similar structures where introducing an asynchronous boundary would complicate the API more than it would help.
At this point a natural question comes up. If locks are simple and faster, why choose GCD?
The answer is that locks solve a narrower problem. A lock gives you mutual exclusion. It does not say where the code runs, how work is scheduled, or how different pieces of work relate to each other. It simply protects a critical section.
Queues operate at a different level. They let you control execution, not just access. A serial queue can be used as a synchronization mechanism, but it also defines an execution context. Work submitted to that queue runs in a predictable order, on a managed thread pool, without the caller needing to reason about threads directly.
That difference shows up quickly in real code. Many components are not just protecting a value. They are coordinating work: performing I/O off the main thread, sequencing dependent operations, or hopping between background work and UI updates. A queue models that flow directly. A lock does not.
There is also a difference in how easy these approaches are to misuse. A lock relies on discipline. Every access to the protected state must follow the same rules, and the compiler does not help enforce them. As the code grows, it becomes harder to see where those rules are applied and where they are accidentally broken.
A serial queue often leads to a simpler mental model: all access to a piece of state happens on one queue. This is still a convention, but it tends to be easier to maintain in practice, especially in larger codebases.
None of this makes queues inherently safer or faster. They address a broader problem: not just protecting state, but structuring how work is executed.
Atomics: a different class of problems
Atomics address a narrower problem than actors, queues, or locks. They do not protect a block of code or a larger piece of state. Instead, they guarantee that a single operation on a value is performed as an indivisible step, even under heavy concurrency.
This distinction becomes clear with a simple counter. Incrementing an integer may look like a single operation, but at the machine level it involves reading the current value, modifying it, and writing it back. When multiple threads perform this sequence at the same time, updates can be lost.
var value = 0
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1_000_000 {
group.addTask {
value += 1
}
}
}
print(value) // often less than 1_000_000The result depends on timing. Some increments overwrite others, and the final value is lower than expected.
An atomic value ensures that each update happens as a single step.
import Synchronization
let value = Atomic(0)
await withTaskGroup(of: Void.self) { group in
for _ in 0..<1_000_000 {
group.addTask {
value.add(1, ordering: .relaxed)
}
}
}
print(value.load(ordering: .relaxed)) // 1_000_000Each call to add completes without interference from other threads. No updates are lost, even when tasks run concurrently.
This model works well for simple cases such as counters, flags, or state transitions that can be expressed as a single operation. It avoids the overhead of locking and does not require defining a larger critical section.
At the same time, atomics do not scale to more complex state. They cannot protect multiple related values or enforce invariants across a data structure. As soon as the logic spans more than a single operation, a lock or another coordination mechanism becomes necessary.
Another aspect that appears quickly in practice is memory ordering. The ordering parameter controls how operations are observed across threads. A relaxed operation guarantees atomicity of the value itself but does not impose ordering constraints on surrounding memory accesses. Stronger options introduce additional guarantees about how reads and writes are sequenced.
In many application-level scenarios, relaxed ordering is sufficient, especially when the value is independent from the rest of the state. In more complex cases, choosing the correct ordering requires a clear understanding of how different threads interact. The API exposes this level of control because atomics are designed for low-level use.
Because of that, atomics tend to appear in specialized parts of the codebase. They are useful for building synchronization primitives, implementing lock-free structures, or optimizing highly contended paths. For most application logic, they add complexity without a clear benefit.
Seen alongside actors, queues, and locks, atomics occupy the lowest level of abstraction. They are precise and efficient, but they solve a very specific problem. Choosing them makes sense when that problem is exactly the one being addressed.
Performance is not the whole story
Discussions about synchronization often converge on performance. Benchmarks comparing actors, queues, and locks tend to produce clear numbers, and those numbers can be tempting to treat as guidance. In practice, they only describe a narrow slice of behavior under specific conditions.
Simple benchmarks usually measure highly contended, repetitive operations. In that environment, locks often come out ahead. The execution model is minimal. A thread acquires the lock, performs a small amount of work, and releases it. There is no task suspension, no scheduling beyond the operating system, and very little abstraction in the hot path.
Actors follow a different model. A call into an actor becomes a scheduled task. Access requires await, which introduces a suspension point. The runtime coordinates execution, manages priorities, and ensures isolation. That work adds overhead, even if the protected operation itself is trivial. In tight loops or high-frequency updates, this overhead becomes visible.
DispatchQueue sits somewhere in between. It does not introduce await, but it still routes work through a scheduling layer. Blocks are enqueued, threads are selected, and execution is coordinated according to the queue configuration. A serial queue can behave similarly to a lock in simple cases, but the indirection through the queue adds its own cost.
These differences explain the typical results. Locks tend to be faster in microbenchmarks. Actors tend to be slower. Queues vary depending on how they are used. None of this is surprising once the underlying models are understood.
What matters is that these measurements do not capture the full picture. Real systems are rarely dominated by a single tight loop over shared state. Work is distributed, often I/O-bound, and shaped by higher-level concerns such as API design and maintainability. In that context, the cost of synchronization is only one part of the overall behavior.
There is also a second dimension that benchmarks do not show directly. Higher-level abstractions reduce the surface area for bugs. Actors encode isolation in the type system. Queues can enforce ordering when used consistently. Locks rely entirely on discipline. The cost of a concurrency bug is rarely visible in a benchmark, but it is often much higher than the cost of a few extra nanoseconds per operation.
Performance still matters. There are parts of the system where contention is high and operations are small enough for synchronization overhead to dominate. In those cases, lower-level primitives can make a measurable difference. The key is that these decisions are driven by actual constraints, not by general expectations about which tool is faster.
Choosing a synchronization mechanism is a balance between cost and guarantees. Performance is one dimension of that balance, but it is not the only one.
The real trade-offs
Choosing a synchronization mechanism is less about picking a tool and more about deciding which guarantees are necessary in a given part of the system. The differences between actors, queues, and locks show up across several dimensions, and those differences shape both the code and the way it evolves over time.
Safety is often the first concern that surfaces. Actors provide the strongest guarantees in this regard. Isolation is part of the type system, and incorrect access patterns are rejected at compile time. This removes an entire class of errors before the code runs. Queues and locks approach safety differently. They can be used to enforce correct access, but the guarantees are implicit. The compiler does not know that a particular piece of state is protected by a queue or a lock. Correctness depends on consistent usage, and that consistency must be maintained by the developer.
Performance enters the picture once the cost of synchronization becomes visible. Locks operate with minimal overhead and tend to perform well in tight, synchronous paths. Queues add a layer of scheduling, which introduces some cost but can still be efficient when used carefully. Actors rely on task suspension and runtime coordination. This makes them more expensive in scenarios where operations are small and frequent. At the same time, in larger systems where work is already asynchronous or dominated by I/O, this difference may not be the limiting factor.
The shape of the API is another dimension that affects design decisions. Actors introduce asynchronous boundaries. Access to state requires await, and this propagates through the call chain. This can be a natural fit for services that already operate asynchronously. In contrast, locks and queues can preserve a synchronous interface. This may simplify integration with existing code and reduce the spread of asynchronous code into parts of the system where it is not otherwise needed.
Cognitive load tends to increase as the level of abstraction decreases. With actors, the model is explicit. State belongs to the actor, and access goes through well-defined entry points. Queues require a mental model of where and how work is scheduled. The developer needs to track which queue protects which piece of state and ensure that the contract is followed consistently. Locks demand even more discipline. It becomes necessary to reason about lock scope, ordering, and interaction between different parts of the code. These concerns are manageable in small, well-contained components, but they grow quickly as the system becomes more complex.
The risk of bugs follows a similar pattern. Actors reduce the likelihood of data races by construction, but they introduce their own class of issues, such as reentrancy and unexpected interleaving around suspension points. Queues can lead to subtle race conditions or deadlocks if used incorrectly. Locks are straightforward in isolation, but errors in locking strategy can be difficult to detect and reproduce. The absence of compile-time guarantees means that these issues often surface only under specific timing conditions.
In real systems, these factors rarely appear in isolation. A component that requires strict safety guarantees and is naturally asynchronous is a good candidate for an actor. A performance-critical path with small, frequent updates may benefit from a lock. A piece of legacy code that already relies on GCD may be easier to maintain with queues. The decision is shaped by the surrounding context as much as by the properties of the mechanism itself.
Understanding these trade-offs allows the choice to be intentional. The goal is not to identify a single best approach, but to match the level of abstraction to the requirements of the problem.
How to choose in practice
In day-to-day code, the decision usually becomes clearer once the shape of the component is understood. The first question is not which primitive is faster, but what kind of boundary the component needs.
When a type owns mutable state and already lives in an asynchronous environment, starting with an actor is often the most natural choice. Services, coordinators, caches, and stateful managers tend to fit this model well. The ownership is explicit, the isolation rules are clear, and the API communicates that access may suspend. In these cases, the actor model supports the design instead of fighting it.
This becomes less comfortable when the surrounding code is heavily synchronous or when the protected operations are very small and frequent. If the main value of the component lies in preserving a synchronous API and the state can be protected with a narrow critical section, a lock often leads to a simpler result. The code stays direct, the call sites remain synchronous, and the cost of synchronization is low. This tends to work well for counters, registries, small caches, and other structures where the protected work is short and easy to reason about.
Queues usually make the most sense when execution order is part of the design. GCD-based systems often already rely on queues as the unit of coordination, and forcing those components into a different model can add complexity without much benefit. A queue can also be a reasonable fit when a type needs a synchronous interface but the internal logic is naturally expressed through serialized work or barrier-based coordination.
The pressure to move down the abstraction stack usually comes from one of two places. The first is API shape. An actor introduces await, and sometimes that cost propagates into areas of the codebase where asynchronous access adds more friction than clarity. The second is contention. If profiling shows that synchronization overhead is part of the problem, lower-level primitives become worth considering.
In many cases, it is reasonable to start with an actor because it provides the clearest ownership model and the strongest default guarantees. If the asynchronous boundary turns out to be awkward, or if performance data points to synchronization as a hotspot, the implementation can move toward queues or locks with a more specific goal in mind. The reverse path is usually harder. Code that begins with low-level primitives often carries more implicit contracts, and those contracts become expensive to untangle later.
Seen this way, the choice is less about preferring one mechanism over another and more about matching the tool to the pressure in the design. Actors are a strong default when isolation should be visible in the API. Locks are often the right tool when the work is small, synchronous, and performance-sensitive. Queues remain useful when coordination and execution order are central to the component, especially in code that already builds on GCD.
Conclusion
Swift now offers a full range of synchronization tools, from high-level constructs that shape how code is written to low-level primitives that give direct control over execution. This range makes it possible to approach concurrency in different ways depending on the problem at hand. It also shifts part of the responsibility back to the developer. The language provides strong guarantees in some cases, but it does not remove the need to understand where those guarantees begin and where they stop.
In practice, most code does not require the lowest level of control. Higher-level abstractions often lead to designs that are easier to reason about and maintain. At the same time, there are areas where the cost of abstraction becomes visible, and having access to simpler primitives makes a difference. The ability to move between these levels is one of the strengths of the current ecosystem.
The important part is to recognize that these tools solve related but not identical problems. They reflect different ways of structuring access to state, and each comes with its own set of trade-offs. Choosing between them is less about following a rule and more about understanding what the code needs to guarantee.
In the end, selecting a synchronization mechanism is a decision about balance. It is a choice between convenience and control, expressed in terms of safety, performance, and clarity of design, rather than a simple comparison of which option is faster.
