Data Race vs Race Condition in Swift

Updated Jun 01, 2026#swift#concurrency

Data race and race condition are not the same thing in concurrent programming. A data race happens when multiple threads access the same memory without synchronization and at least one access is a write. A race condition is a broader correctness bug where the result depends on the timing or order of events.

A data race can cause a race condition, but a race condition can also occur without a data race. The right fix depends on the invariant: isolate shared mutable state, protect a critical section with a lock, serialize work, or redesign the operation so that ordering no longer affects correctness.

Data Race

A data race occurs when multiple threads access the same memory without synchronization and at least one access is a write.

// A shared mutable object
import Dispatch

var counter = 0

// A concurrent queue
let queue = DispatchQueue(label: "com.example.queue", attributes: .concurrent)

// Two threads incrementing the counter without synchronization
let group = DispatchGroup()
queue.async(group: group) {
  for _ in 0..<1000 {
    counter += 1
  }
}

queue.async(group: group) {
  for _ in 0..<1000 {
    counter += 1
  }
}

group.wait()

// Print the final value of the counter
print(counter)

This code may print different values each time it runs, because the two threads may access and modify the counter at the same time, causing a data race. The expected value is 2000, but it could be less due to lost updates.

To avoid the data race, we can use a lock or a serial queue to synchronize the access to the counter.

import Dispatch
import Foundation

// A shared mutable object
var counter = 0

// A concurrent queue
let queue = DispatchQueue(label: "com.example.queue", attributes: .concurrent)

// A lock object
let lock = NSLock()

// A function that increments the counter with a lock
func incrementWithLock() {
  // Acquire the lock
  lock.lock()
  defer { lock.unlock() }
  // Increment the counter
  counter += 1
}

// Two threads incrementing the counter with the same function
let group = DispatchGroup()
queue.async(group: group) {
  for _ in 0..<1000 {
    incrementWithLock()
  }
}

queue.async(group: group) {
  for _ in 0..<1000 {
    incrementWithLock()
  }
}

group.wait()

// Print the final value of the counter
print(counter)

This code will reliably print 2000 each time, because the lock ensures the two threads never modify the counter simultaneously. This avoids the data race and ensures the correctness of the program.

Race Condition

A race condition occurs when the timing or order of events affects the correctness of a piece of code. A data race can cause a race condition, but not always.

import Dispatch
import Foundation

// A shared mutable object
var balance = 100

// A concurrent queue
let queue = DispatchQueue(label: "com.example.queue", attributes: .concurrent)

// A function that withdraws money from the balance
func withdraw(amount: Int) {
  // Check if there is enough balance
  if balance >= amount {
    // Simulate some delay
    Thread.sleep(forTimeInterval: 0.1)
    // Deduct the amount from the balance
    balance -= amount
  }
}

// Two threads trying to withdraw money at the same time
let group = DispatchGroup()
queue.async(group: group) {
  withdraw(amount: 80)
}

queue.async(group: group) {
  withdraw(amount: 90)
}

group.wait()

// Print the final value of the balance
print(balance)

This code may print different values each time it runs, because the two threads may check and modify the balance at the same time, causing a data race and a race condition. The expected value is either 20 or 10, depending on which thread withdraws first. But it could also be -70, if both threads check the balance before either of them deducts the amount. This would result in an incorrect state of the program.

To avoid the race condition, we can use a lock or a serial queue to synchronize the access to the balance.

import Dispatch
import Foundation

// A shared mutable object
var balance = 100

// A concurrent queue
let queue = DispatchQueue(label: "com.example.queue", attributes: .concurrent)

// A lock object
let lock = NSLock()

// A function that withdraws money from the balance
func withdraw(amount: Int) {
  // Acquire the lock
  lock.lock()
  defer { lock.unlock() }
  // Check if there is enough balance
  if balance >= amount {
    // Simulate some delay
    Thread.sleep(forTimeInterval: 0.1)
    // Deduct the amount from the balance
    balance -= amount
  }
}

// Two threads trying to withdraw money at the same time
let group = DispatchGroup()
queue.async(group: group) {
  withdraw(amount: 80)
}

queue.async(group: group) {
  withdraw(amount: 90)
}

group.wait()

// Print the final value of the balance
print(balance)

This code will print either 20 or 10 each time it runs, depending on which thread withdraws first. But it will never print -70, because the lock ensures that only one thread can access the balance at a time. This avoids the race condition and ensures the correctness of the program.

Actor Isolation

Swift’s actor type provides compiler-enforced isolation for its mutable state. Actor-isolated code runs one piece at a time, and external access crosses the isolation boundary with await.

actor BankAccount {
  private var balance = 100

  func withdraw(amount: Int) -> Bool {
    guard balance >= amount else { return false }
    balance -= amount
    return true
  }
}

let account = BankAccount()
Task {
  async let first = account.withdraw(amount: 80)
  async let second = account.withdraw(amount: 90)
  print(await (first, second))
}

The synchronous body of withdraw has no suspension point, so another operation cannot observe or modify the balance midway through the update.

Actors prevent data races on their isolated state, but they do not prevent every race condition. An actor method can be interleaved with other work when it suspends:

actor BankAccount {
  private var balance = 100

  func withdraw(amount: Int) async -> Bool {
    guard balance >= amount else { return false }
    try? await Task.sleep(nanoseconds: 100_000_000)
    balance -= amount
    return true
  }
}

Two calls can both pass the balance check before either deduction occurs. The actor still prevents simultaneous memory access, but the account can become overdrawn. Keep invariant-sensitive state changes together without an await, or recheck the invariant after suspension.

Synchronization mechanisms

In a concurrent program, multiple threads or processes can execute simultaneously, and they may access shared variables, data structures, or resources concurrently. Without proper synchronization, these concurrent accesses can lead to various issues such as data corruption, race conditions, inconsistent states, and unexpected behavior.

Synchronization, in the context of concurrent programming, refers to the coordination and control of multiple threads or processes accessing shared resources or executing concurrently. It is the practice of ensuring that concurrent operations or tasks are executed in a well-defined and orderly manner to maintain data consistency.

Synchronization mechanisms provide the means to control the access and execution of concurrent operations to ensure correctness and maintain data integrity. They typically involve the use of synchronization primitives, such as locks, semaphores, mutexes, condition variables, or atomic operations, to enforce mutual exclusion, coordination, and serialization of concurrent tasks.

In Swift, there are several synchronization mechanisms available for coordinating concurrent access to shared resources:

  • Actors: Swift reference types that isolate mutable state so concurrent tasks take turns accessing it.
  • OSAllocatedUnfairLock: A structure that creates an unfair lock, which is a type of mutex that grants access to only one thread at a time.
  • Mutex: A Swift synchronization primitive that stores and protects mutable state with exclusive access through withLock.
  • DispatchQueue: A class that manages the execution of tasks on one or more dispatch queues, which can be serial or concurrent.
  • NSLock: A class that implements a basic mutex lock, which can be used to protect a critical section of code.
  • NSRecursiveLock: A class that implements a recursive lock, which is a variant of a mutex lock that allows a single thread to acquire the lock multiple times before releasing it.
  • NSCondition: A class that implements a condition variable, which is a type of synchronization mechanism that allows threads to wait for a certain condition to be true before proceeding.
  • NSConditionLock: A class that implements a condition lock, which is a combination of a condition variable and a mutex lock.

When choosing a synchronization mechanism, consider the lifetime of the shared state and whether the code needs to suspend. Prefer actor isolation for mutable state shared by asynchronous tasks. For synchronous critical sections, use a scoped lock API such as Mutex.withLock or OSAllocatedUnfairLock.withLock. Never hold a thread lock across an await.