Modern applications rarely execute on a single thread. Networking, background processing, UI updates, and database operations often run concurrently to keep applications responsive and performant.
While concurrency improves performance, it also introduces a new class of bugs that can be difficult to detect and reproduce: data races.
Swift provides powerful tools for concurrency, but to build reliable concurrent applications, developers must understand thread safety — how to ensure code behaves correctly when accessed from multiple threads simultaneously.
In this article, we will explore:
By the end, you will have a strong conceptual foundation for designing safe concurrent systems in Swift.
A piece of code is thread-safe if it behaves correctly when accessed by multiple threads at the same time.
In other words:
Multiple threads can read or modify shared data without causing inconsistent results or crashes.
Thread safety becomes important when dealing with shared mutable state.
Consider the following example:
class Counter {
var value: Int = 0
func increment() {
value += 1
}
}This code appears harmless. However, if multiple threads call increment() simultaneously, the result may become incorrect.
The reason is that value += 1 is not an atomic operation. It actually consists of multiple steps:
If two threads perform these steps concurrently, updates may be lost.
Without proper synchronization, concurrent code can lead to several problems.
A data race occurs when:
When this happens, the final result may become unpredictable.
Consider the following example:
class Counter {
var value = 0
func increment() {
value += 1
}
}Now imagine multiple threads calling increment() at the same time:
let counter = Counter()
let group = DispatchGroup()
for _ in 0..<1000 {
group.enter()
DispatchQueue.global().async {
counter.increment()
group.leave()
}
}
group.wait()
print(counter.value)Intuitively, we might expect the output to always be:
1000However, when you run this code multiple times, you may see results like:
941
876
997The result varies because multiple threads are modifying the same value concurrently.
Many Swift types, including collections like Array and Dictionary, are not safe for concurrent mutation.
Consider the following example:
var numbers: [Int] = []
let group = DispatchGroup()
for i in 0..<10000 {
group.enter()
DispatchQueue.global().async {
numbers.append(i)
group.leave()
}
}
group.wait()
print(numbers.count)At first glance, we might expect the final count to always be:
10000However, because multiple threads are mutating the same array concurrently, the result may become unpredictable.
Possible outcomes include:
9831
9974
10000Or in some cases the program may crash with errors such as:
Swift/UnsafePointer.swift:1104: Fatal error: UnsafeMutablePointer.initialize overlapping rangeor
signal SIGABRTor
EXC_BAD_ACCESSConcurrent modification of collections can lead to memory corruption and crashes.
Shared objects can become partially updated when multiple threads modify them simultaneously.
Example:
class BankAccount {
var balance: Int = 0
}If two threads update balance at the same time, the final value may be incorrect.
Thread safety issues usually arise when mutable state is shared across threads.
Typical examples include:
Example:
class Logger {
var logs: [String] = []
func add(_ message: String) {
logs.append(message)
}
}
let logger = Logger()
DispatchQueue.concurrentPerform(iterations: 5) { i in
logger.add("Log \(i)")
}
print("Expected logs:", 5)
print("Actual logs:", logger.logs.count)Output:
Expected logs: 5
Actual logs: 4If multiple threads call add() concurrently, the underlying array may become corrupted.
There are several techniques developers use to write thread-safe code in Swift.
The most common approaches include:
Each technique offers different trade-offs in complexity, safety, and performance.
The simplest way to achieve thread safety is to avoid shared mutable state entirely.
Instead of sharing mutable objects between threads, pass copies of data.
Example:
struct User {
let name: String
let age: Int
}Since the properties are immutable, multiple threads can safely read them without synchronization.
Swift’s value types encourage this pattern.
A common approach to protecting shared state is using a serial dispatch queue.
A serial queue ensures that tasks execute one at a time, preventing simultaneous access.
Example:
class SafeCounter {
private var value = 0
private let queue = DispatchQueue(label: "counter.queue")
func increment() {
queue.sync {
value += 1
}
}
func getValue() -> Int {
queue.sync {
value
}
}
}
let counter = SafeCounter()
let group = DispatchGroup()
let iterations = 10000
for _ in 0..<iterations {
group.enter()
DispatchQueue.global().async {
counter.increment()
group.leave()
}
}
group.wait()
print("Expected:", iterations)
print("Actual:", counter.getValue())Output:
Expected: 10000
Actual: 10000All access to value occurs on the same queue, guaranteeing mutual exclusion.
Advantages:
Drawbacks:
sync) may affect performance if overusedAnother common technique is protecting critical sections with locks.
Locks ensure that only one thread can access a specific piece of code at a time.
Example using NSLock:
class SafeCounter {
private var value = 0
private let lock = NSLock()
func increment() {
lock.lock()
value += 1
lock.unlock()
}
func getValue() -> Int {
lock.lock()
defer { lock.unlock() }
return value
}
}
let counter = SafeCounter()
let group = DispatchGroup()
let iterations = 10000
for _ in 0..<iterations {
group.enter()
DispatchQueue.global().async {
counter.increment()
group.leave()
}
}
group.wait()
print("Expected:", iterations)
print("Actual:", counter.getValue())Output:
Expected: 10000
Actual: 10000The lock ensures that concurrent threads cannot modify value simultaneously.
Advantages:
Drawbacks:
Actors are a concurrency feature introduced in Swift 5.5 to simplify thread-safe programming.
An actor protects its internal state by ensuring only one task can access it at a time.
Example:
actor Counter {
private var value = 0
func increment() {
value += 1
}
func getValue() -> Int {
value
}
}Using the actor:
let counter = Counter()
Task {
await counter.increment()
}The await keyword indicates asynchronous interaction with the actor.
Swift guarantees that access to the actor’s internal state is serialized, preventing concurrent mutations.
Immutable data structures are inherently thread-safe.
let numbers = [1,2,3,4]Since the array is never mutated, multiple threads can read it safely.
Many functional programming patterns rely heavily on immutability.
| Technique | Safety | Complexity | Performance |
|---|---|---|---|
| Avoid shared state | Very high | Low | High |
| Serial queues | High | Low | Moderate |
| Locks | Medium | Medium | High |
| Actors | Very high | Low | High |
| Immutable data | Very high | Low | High |
Xcode provides a powerful tool called Thread Sanitizer that helps detect concurrency bugs.
You can enable it in:
Scheme → Diagnostics → Thread SanitizerThread Sanitizer detects:
Running your application with this tool can help identify problems that are difficult to reproduce.
These practices help reduce the complexity of concurrent programming.
Concurrency is essential for modern applications, but it introduces significant complexity.
Traditional techniques rely on protecting shared mutable state using synchronization primitives like locks and queues.
Swift’s modern concurrency model takes a different approach by encouraging data isolation.
Actors combine:
This design dramatically reduces the risk of data races and makes concurrent code easier to reason about.
By combining good design principles, immutability, and Swift’s concurrency tools, developers can build applications that scale safely across multiple threads.
Thank you for reading. If you have any questions, feel free to follow me on X and send me a DM. If you enjoyed this article and would like to support my work, Buy me a coffee ☕️