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.
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.
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.
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.
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:
withLock.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.