Data Race vs Race Condition in Swift

May 24, 2023#swift#concurrency

Data race and race condition are not the same thing in the context of concurrent programming. A data race happens when two threads access the same mutable object without synchronization, while a race condition happens when the order of events affects the correctness of the program.

A data race can cause a race condition, but not always. A race condition can also occur without a data race. Both are problems with atomicity and they can be solved by synchronization mechanisms like locks or serial queues.

Data Race

A data race occurs when one thread accesses a mutable object while another thread is writing to it, without any synchronization.

// 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
queue.async {
  for _ in 0..<1000 {
    counter += 1
  }
}

queue.async {
  for _ in 0..<1000 {
    counter += 1
  }
}

// Wait for the threads to finish
queue.sync(flags: .barrier) {}

// 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()
  // Increment the counter
  counter += 1
  // Release the lock
  lock.unlock()
}

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

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

// Wait for the threads to finish
queue.sync(flags: .barrier) {}

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

This code will also print 2000 each time it runs, because the serial queue ensures that only one thread can execute at a time. 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
queue.async {
  withdraw(amount: 80)
}

queue.async {
  withdraw(amount: 90)
}

// Wait for the threads to finish
queue.sync(flags: .barrier) {}

// 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()
  // 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
  }
  // Release the lock
  lock.unlock()
}

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

queue.async {
  withdraw(amount: 90)
}

// Wait for the threads to finish
queue.sync(flags: .barrier) {}

// 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.

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:

  • OSAllocatedUnfairLock: A structure that creates an unfair lock, which is a type of mutex that grants access to only one thread at a time.
  • 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 specific requirements of your concurrent task and choose the appropriate mechanism accordingly. DispatchQueue and NSLock are commonly used and provide a flexible and efficient way to synchronize and coordinate concurrent tasks in Swift.