Race condition vs. Data Race: the differences explained

Race conditions and data races are similar but have a few significant differences you should know. Both terms are often used when developing multi-threaded applications and are the root cause for several kinds of exceptions, including the well-known EXC_BAD_ACCESS. By understanding the differences between them, you’ll learn how to solve and prevent them in your apps.

Xcode comes with several sanitizers that can help detect race conditions and data races. This article will only dive into the differences between both. Still, I encourage you to read my articles EXC_BAD_ACCESS crash error: Understanding and solving it and Thread Sanitizer explained: Data Races in Swift to dive deeper into the solution Xcode provides.

Race Conditions versus Data Races

Before diving into code examples for both, let’s take a brief moment to compare race conditions to data races:

  • Race Condition
    A race condition occurs when the timing or order of events affects the correctness of a piece of code.
  • Data Race
    A data race occurs when one thread accesses a mutable object while another thread is writing to it.

A race condition can occur without a data race, while a data race can occur without a race condition. For example, the order of events can be consistent, but if there’s always a read at the same time as a write, there’s still a data race.

Using code examples to explain Race Conditions and Data Races

We can illustrate this using a classic code example of a money transfer using a bank. If the events aren’t synchronized, we could have different balances due to a separate order of events.

Imagine having two bank accounts:

let bankAcountOne = BankAccount(balance: 100)
let bankAcountTwo = BankAccount(balance: 100)

Both bank accounts have a current balance of 100 euros. The bank provides a transfer method that verifies sufficient balance and performs the transfer accordingly:

final class Bank {
    
    @discardableResult
    func transfer(amount: Int, from fromAccount: BankAccount, to toAccount: BankAccount) -> Bool {
        guard fromAccount.balance >= amount else {
            return false
        }
        toAccount.balance += amount
        fromAccount.balance -= amount
        
        return true
    }
}

Performing money transfers in a single-threaded application

In the case of a single-threaded application, we can precisely predict the outcome of the following two transfers:

bank.transfer(amount: 50, from: bankAcountOne, to: bankAcountTwo)
bank.transfer(amount: 70, from: bankAcountOne, to: bankAcountTwo)

After the first transaction of 50 euros, there are only 50 euros left on the first bank account, which is not enough to perform the second transaction. Therefore, the outcome will always be:

print(bankAccountOne.balance) // 50 euros
print(bankAccountTwo.balance) // 150 euros

Demonstrating a race condition using money transfers in a multi-threaded application

In the case of a multi-threaded application, we will first witness a race condition. When multiple threads trigger different transfers, we can’t predict which thread executes first. The order of the same two transfers is unpredictable and can lead to two different outcomes based on the order of execution.

bank.transfer(amount: 50, from: bankAcountOne, to: bankAcountTwo) // Executed on Thread 1
bank.transfer(amount: 70, from: bankAcountOne, to: bankAcountTwo) // Executed on Thread 2

When the transfer of 50 euros executes first, we’ll have the same outcome value as our first example:

print(bankAccountOne.balance) // 50 euros
print(bankAccountTwo.balance) // 150 euros

However, if the second transfer executes first, we’ll end up with a different balance:

print(bankAccountOne.balance) // 30 euros
print(bankAccountTwo.balance) // 170 euros

The above code example demonstrates the effect of a race condition which we defined earlier as:

A race condition occurs when the timing or order of events affects the correctness of a piece of code.

This example might be easy to reason about and kind of makes sense. The same can happen in real life, where the order of outgoing payments defines which payment can still be done. However, if we zoom into the transfer method, we’ll find out that a data race can occur.

The effect of a Data Race during a money transfer

The transfer method that I shared earlier didn’t contain any synchronization solution to ensure only one thread is accessing the balances data:

final class Bank {
    
    @discardableResult
    func transfer(amount: Int, from fromAccount: BankAccount, to toAccount: BankAccount) -> Bool {
        guard fromAccount.balance >= amount else {
            return false
        }
        toAccount.balance += amount
        fromAccount.balance -= amount
        
        return true
    }
}

When the timing is correct, we could end up with even weirder outcomes of balances. For example, thread two can read the from balance while thread one is just about to update the from balance:

let bankAcountOne = BankAccount(balance: 100)
let bankAcountTwo = BankAccount(balance: 100)
bank.transfer(amount: 50, from: bankAcountOne, to: bankAcountTwo) // Executed on Thread 1
bank.transfer(amount: 70, from: bankAcountOne, to: bankAcountTwo) // Executed on Thread 2

----

// Thread 1 passes the balance check:
guard fromAccount.balance >= amount else {
    return false
}

// Thread 2, at the same time, performs the balance check:
guard fromAccount.balance >= amount else {
    return false
}

// Thread 1 updates the balances:
toAccount.balance += amount // 150
fromAccount.balance -= amount // 50

// Thread 2 updates the balances:
toAccount.balance += amount // 170
fromAccount.balance -= amount // 30

// Outcome:
print(bankAccountOne.balance) // 30 euros
print(bankAccountTwo.balance) // 170 euros

In theory, we could even end up with the following outcome:

print(bankAccountOne.balance) // 220 euros
print(bankAccountTwo.balance) // -20 euros

The above example demonstrates the effect of a race condition in detail when there is no synchronization in place. Before showing how to solve this using a locking mechanism, I want to explain how a data race can occur in the same example.

Two threads are reading and writing the same balances, which means that we match the earlier definition of what a data race is:

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

The same memory is mutated while a read can be active, leading to unexpected behavior and potential crashes.

We can solve both race condition and data race by adding a locking mechanism around our transfer method:

private let lockQueue = DispatchQueue(label: "bank.lock.queue")

@discardableResult
func transfer(amount: Int, from fromAccount: BankAccount, to toAccount: BankAccount) -> Bool {
    lockQueue.sync {
        guard fromAccount.balance >= amount else {
            return false
        }
        toAccount.balance += amount
        fromAccount.balance -= amount
        
        return true
    }
}

The example makes use of a dispatch queue that defaults to a serial queue, making sure only one thread at a time can access the balances. The locking mechanism eliminates the data race as there can’t be multiple threads accessing the same balance anymore. Though, race conditions can still occur as the order of execution is still undefined. However, race conditions are acceptable and won’t break your application as long as you make sure data races can’t occur.

Finally, I’d like to mention the use of Actors, which the new concurrency framework introduced.

actor BankAccountActor {
    var balance: Int
    
    init(balance: Int) {
        self.balance = balance
    }
    
    func transfer(amount: Int, to toAccount: BankAccountActor) async -> Bool {
        guard balance >= amount else {
            return false
        }
        balance -= amount
        await toAccount.deposit(amount: amount)
        
        return true
    }
    
    func deposit(amount: Int) {
        balance = balance + amount
    }
}

final class BankActor {
    @discardableResult
    func transfer(amount: Int, from fromAccount: BankAccountActor, to toAccount: BankAccountActor) async -> Bool {
        await fromAccount.transfer(amount: amount, to: toAccount)
    }
}

The isolation takes place within the actor, so the bank now delegates the actual transfer method to the actor. You can learn more about actors in my article Actors in Swift: how to use and prevent data races.

Conclusion

Race conditions and Data Races can lead to unexpected behavior in our code. A race condition can be acceptable, but it’s good to realize its consequences as the order of execution can lead to different outcomes. It would be best if you solved Data Races to prevent crashes in your apps.

If you like to improve your Swift knowledge even more, check out the Swift category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.

Thanks!