Thread Safety in Swift - Preventing Data Races with Locks, Queues, and Actors

March 7, 2026

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:

  • What thread safety means
  • Why thread safety matters
  • How data races occur
  • Techniques for achieving thread safety in Swift
  • How Swift’s modern concurrency model improves safety

By the end, you will have a strong conceptual foundation for designing safe concurrent systems in Swift.


What Is Thread Safety?

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:

  1. Read the current value
  2. Add one
  3. Write the new value back

If two threads perform these steps concurrently, updates may be lost.


Why Thread Safety Matters?

Without proper synchronization, concurrent code can lead to several problems.

Data Races

A data race occurs when:

  1. Two or more threads access the same memory location
  2. At least one access is a write
  3. The accesses are not synchronized

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:

1000

However, when you run this code multiple times, you may see results like:

941
876
997

The result varies because multiple threads are modifying the same value concurrently.

Crashes

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:

10000

However, because multiple threads are mutating the same array concurrently, the result may become unpredictable.

Possible outcomes include:

9831
9974
10000

Or in some cases the program may crash with errors such as:

Swift/UnsafePointer.swift:1104: Fatal error: UnsafeMutablePointer.initialize overlapping range

or

signal SIGABRT

or

EXC_BAD_ACCESS

Concurrent modification of collections can lead to memory corruption and crashes.

Inconsistent State

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.


Common Sources of Thread Safety Issues

Thread safety issues usually arise when mutable state is shared across threads.

Typical examples include:

  • Shared arrays or dictionaries
  • Global variables
  • Singleton objects
  • Shared caches
  • Mutable properties inside classes

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: 4

If multiple threads call add() concurrently, the underlying array may become corrupted.


Strategies for Achieving Thread Safety

There are several techniques developers use to write thread-safe code in Swift.

The most common approaches include:

  • Avoid shared mutable state
  • Use serial dispatch queues
  • Use locks
  • Use actors
  • Prefer immutable data

Each technique offers different trade-offs in complexity, safety, and performance.

Avoid Shared Mutable State

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.

Using Serial Dispatch Queues

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: 10000

All access to value occurs on the same queue, guaranteeing mutual exclusion.

Advantages:

  • Simple and easy to reason about
  • No manual lock management

Drawbacks:

  • Blocking calls (sync) may affect performance if overused

Using Locks

Another 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: 10000

The lock ensures that concurrent threads cannot modify value simultaneously.

Advantages:

  • Efficient and low-level control
  • Minimal overhead

Drawbacks:

  • Easy to misuse
  • Risk of deadlocks if locks are not managed carefully

Using Actors for Thread Safety

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

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.


Comparing Approaches

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

Detecting Thread Safety Issues

Xcode provides a powerful tool called Thread Sanitizer that helps detect concurrency bugs.

You can enable it in:

Scheme → Diagnostics → Thread Sanitizer

Thread Sanitizer detects:

  • Data races
  • Unsafe concurrent memory access
  • Synchronization issues

Running your application with this tool can help identify problems that are difficult to reproduce.


Best Practices for Writing Thread-Safe Swift Code

  1. Prefer value types and immutable data
  2. Avoid shared mutable state whenever possible
  3. Use actors for shared state in modern Swift
  4. Use serial queues when actors are not appropriate
  5. Be cautious with locks
  6. Use Thread Sanitizer during development

These practices help reduce the complexity of concurrent programming.


Final Thoughts

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:

  • isolated state
  • asynchronous messaging
  • serialized execution

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 ☕️