SyMind / learning

路漫漫其修远兮,吾将上下而求索。
10 stars 1 forks source link

竞态条件(Race condition)与数据竞争(Data Race)的区别 #27

Open SyMind opened 2 years ago

SyMind commented 2 years ago

原文:https://www.avanderlee.com/swift/race-condition-vs-data-race

竞态条件和数据竞争是相似的,但有一些区别。当开发多线程应用时,会经常使用这两个术语。它们也是许多异常的源头,包括著名的 EXC_BAD_ACCESS。通过理解两者的不同,你将学会如何在你的应用中解决并避免它们。

竞态条件与数据竞争

在深入研究这两种情况的代码示例之前,让我们简要地对比一下竞态条件和数据竞争:

当事件的时序影响一段代码的正确性时,就会发生竞态条件。

当一个线程访问一个可变对象的同时,另一个线程正在写入它,就会发生数据竞争。

使用代码示例解释竞态条件和数据竞争

我们可以使用银行转账的经典代码示例来进行说明。如果事件并非同步,那么由于事件顺序的不同,我们余额可能也会不同。

想象拥有两个账户:

let bankAccountOne = BankAccount(balance: 100)
let bankAccountTwo = BankAccount(balance: 100)

两个银行账号目前都拥有 10 欧元。银行提供一种转账方法,核实拥有足够的余额,并进行转账:

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
    }
}

在单线程应用中执行转账方法

对于单线程应用程序,我们可以精确地预测以下两次转账的结果:

bank.transfer(amount: 50, from: bankAccountOne, to: bankAccountTwo)
bank.transfer(amount: 70, from: bankAccountOne, to: bankAccountTwo)

在第一次转账 50 欧元后,第一个银行账号仅剩下 50 欧元,不足以进行第二次转账。所以,结果是:

print(bankAccountOne.balance) // 50 欧元
print(bankAccountTwo.balance) // 150 欧元

通过在多线程应用中进行转账来演示竞态条件

在多线程应用程序的情况下,我们将首先遇到一个竞态条件。当多个线程触发不同的转账时,我们无法预测哪个线程先执行。转账的顺序是不可预测的,并且执行顺序可能导致两个不同的结果。

bank.transfer(amount: 50, from: bankAcountOne, to: bankAcountTwo) // 在线程1上执行
bank.transfer(amount: 70, from: bankAcountOne, to: bankAcountTwo) // 在线程2上执行

当先执行的是 50 欧元转账时,我们会得到与第一个示例相同的结果:

print(bankAccountOne.balance) // 50 欧元
print(bankAccountTwo.balance) // 150 欧元

然而,当先执行的是 70 欧元转账时,得到的结果是:

print(bankAccountOne.balance) // 30 欧元
print(bankAccountTwo.balance) // 170 欧元

上述示例演示了竞态条件的影响。

这个实例很容易理解,并且有一定的意义。同样的情况也可能发生在现实生活中,支付顺序决定了哪些支付仍然可以完成。但是,如果我们深入转账方法,就会发现可能会发生的数据竞争问题。

在转账时数据竞争的影响

转账方法没有进行任何同步方案,来保证同一时间只有一个线程能够操作余额数据。

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
    }
}

在某些时序下,我们能够得到更加奇怪的结果。例如,当线程1正要更新余额时,线程 2 读取余额:

let bankAcountOne = BankAccount(balance: 100)
let bankAcountTwo = BankAccount(balance: 100)
bank.transfer(amount: 50, from: bankAcountOne, to: bankAcountTwo) // 在线程 1 上执行
bank.transfer(amount: 70, from: bankAcountOne, to: bankAcountTwo) // 在线程 2 上执行

----

// 线程 1 检查余额:
guard fromAccount.balance >= amount else {
    return false
}

// 线程 2,在同一时间也检查余额:
guard fromAccount.balance >= amount else {
    return false
}

// 线程 1 更新余额:
toAccount.balance += amount // 150
fromAccount.balance -= amount // 50

// 线程 2 更新余额:
toAccount.balance += amount // 170
fromAccount.balance -= amount // 30

// 结果:
print(bankAccountOne.balance) // 30 欧元
print(bankAccountTwo.balance) // 170 欧元

理论上,我们甚至可以得到以下结果:

print(bankAccountOne.balance) // 200 欧元
print(bankAccountTwo.balance) // -30 欧元

上面的示例详细演示了在没有进行任何同步方案时数据竞争的影响。在演示如何使用锁机制解决这个问题之前,我想解释一下为何发生数据竞争。

两个线程正在读取和写入相同的余额,这意味着我们符合数据竞争的预先定义:

当一个线程访问一个可变对象的同时,另一个线程正在写入它,就会发生数据竞争。

当对一块可被修改的内存进行读操作时,可能导致意想不到的行为和潜在的危机。

我们可以通过为转账方法增加一个锁定机制来解决竞争条件和数据竞争:

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
    }
}

该示例使用一个默认为串行队列的派发队列,确保一次只有一个线程可以访问余额。锁定机制消除了数据竞争,因为不能再有多个线程访问相同的余额。尽管如此,竞态条件仍然可能发生,因为执行顺序仍然没有定义。但是,竞争条件是可以接受的,只要您确保数据竞争不会发生,它就不会破坏你的应用程序。