竞争条件与数据竞争

竞争条件和数据竞争是两个相关的概念,它们都涉及多线程环境中的数据竞争。但是,它们也有如下一些重要的区别。

  • 竞争条件:指的是在多线程环境中,由于操作顺序的不确定性导致的程序执行结果的不确定性。例如,如果两个线程同时对同一个变量进行读/写操作,那么它们的执行顺序将会对最终的结果产生影响。这就是竞争条件。外部时序或排序的非确定性会产生竞争条件;典型的示例包括上下文切换、操作系统信号、多处理器上的内存操作和硬件中断等。竞争条件有时候难以避免,因为在很多情况下我们无法精确控制goroutine的运行顺序;竞争条件有时候是我们可以接受的。
  • 数据竞争:指的是在多线程环境中,由于操作顺序的不确定性导致的数据不一致问题。例如,如果两个线程同时对同一个变量进行读/写操作,并且没有使用任何同步机制,那么它们的操作将会导致数据的不一致。这就是数据竞争。

竞争条件和数据竞争之间既不是子集的关系,也不是充分必要条件的关系。竞争条件和数据竞争的区别在于:前者是一种状态,而后者是一种问题。对于数据竞争,它的定义是非常明确的:一个线程中的内存操作可能会尝试访问内存位置,同时另一个线程中的内存操作正在写入该内存位置,并且它们之间没有同步控制,这就会发生数据竞争。

我们以一个银行转账的例子来说明竞争条件和数据竞争的区别。银行转账的时候,不能凭空增加钱,也不能莫名其妙地丢失钱,同时还会面临并发的问题,所以它是一个很好的演示竞争条件和数据竞争的例子。

一个单线程的程序,没有竞争条件和数据竞争的转账函数如下(当然,这只是一个示例,相信没有银行会使用下面的方式):

go
// 银行账户 type Account struct { Balance int64 // 余额 InTx bool //是否在操作中 } // 转账 // amount: 转账金额 // accountFrom:转账的源账户 // accountTo: 转账的目的账户 func transfer1(amount int64, accountFrom, accountTo *Account) bool { // 检查余额是否小于转账的金额 if accountFrom.Balance < amount { return false } // 目的账户的余额加上转账金额 accountTo.Balance += amount // 源账户的余额减去转账金额 accountFrom.Balance -= amount return true }

这个函数既有竞争条件问题,也有数据竞争问题。如果按照这个函数转账,用户的账目最终将变得一塌糊涂,全乱了。比如源账户的余额有100万元,如果同时有两个转账操作,都从这个源账户中转出100万元,那么源账户的余额就有可能变成0元,而两个目的账户都增加了100万元,银行莫名其妙地损失了100万元,这是数据竞争问题。在不同的转账顺序下,账户的余额可能还会不同,这是由执行顺序导致的竞争条件问题。

于是,我们修改上面的函数,使用原子操作,保证没有数据竞争问题。

go
func transfer2(amount int64, accountFrom, accountTo *Account) bool { bal := atomic.LoadInt64(&accountFrom.Balance) //原子操作:读取 if bal < amount { return false } atomic.AddInt64(&accountTo.Balance, amount) //原子操作:增加 atomic.AddInt64(&accountFrom.Balance, -amount) //原子操作:减少 return true }

在多线程并发操作的情况下,transfer2函数没有数据竞争问题,对源账户的余额操作都是原子操作,不会出现部分被修改的情况,所以账户里的钱不会凭空消失。但是很明显,这是一个有问题的函数,它存在竞争条件问题。比如源账户的余额有100万元,两个goroutine同时转账,其中g1转账50万元,g2转账70万元,我们无法预测它们执行的顺序。如果g1先执行,那么g2执行不成功,最后源账户的余额还剩50万元;如果g2先执行,那么g1执行不成功,最后源账户的余额还剩30万元。执行顺序的不同导致结果不同,这是竞争条件问题。目前这个转账最终是转了30万元还是转了70万元,银行是可以接受的,毕竟它也没有什么损失。但是如果g1和g2同时调用transfer2,都检查到源账户有100万元的余额,它们都认为可以转账,结果执行完转账后,源账户的余额变成了-20万元,这次银行可不愿意了,用户的余额不足以支付这两笔转账,而这个函数却导致两个转账操作都成功了。

为了修正这个问题,我们实现第三版的转账函数,使用互斥锁来解决特定的竞争条件。

go
var txMutex sync.Mutex func transfer3(amount int64, accountFrom, accountTo *Account) bool { txMutex.Lock() //加锁 defer txMutex.Unlock() bal := atomic.LoadInt64(&accountFrom.Balance) if bal < amount { return false } atomic.AddInt64(&accountTo.Balance, amount) atomic.AddInt64(&accountFrom.Balance, -amount) return true }

这里使用了一个互斥锁Mutex来解决竞争条件问题,其中转账那几行代码被称为临界区。这个函数足够完美了,即使在多线程并发调用的情况下,transler3也和我们期望的一样,要么成功,要么失败,不会因为多线程执行顺序的不同而得到不期望的结果:用户的账户不会被透支,保证在余额充足的情况下可以转账。这个函数解决了我们所关注的竞争条件的问题。

那么,如何实现一个有数据竞争,但是没有竞争条件的例子呢?请看下面的代码。

go
var txMutex sync.Mutex func transfer4(amount int64, accountFrom, accountTo *Account) bool { accountFrom.InTx = true accountTo.InTx = true defer func() { accountTo.InTx = false accountFrom.InTx = false }() txMutex.Lock() //加锁 defer txMutex.Unlock() bal := atomic.LoadInt64(&accountFrom.Balance) if bal < amount { return false } atomic.AddInt64(&accountTo.Balance, amount) atomic.AddInt64(&accountFrom.Balance, -amount) return true }

在这个例子中,我们给账户增加了一个变量InTx,代表这个账户在事务之中。多线程执行转账操作时,可能会同时修改这个变量,产生数据竞争。实际上,我们并没有使用这个变量进行逻辑处理,所以在这个简单的例子中,数据竞争对业务没有什么影响。这个函数依然不会导致超额转账的竞争条件问题,只是存在访问InTx的数据竞争问题。

总结一下,上面的四个函数演示了一个函数可能存在竞争条件和数据竞争的问题,或者二者之一。

存在数据竞争不存在数据竞争
存在竞争条件transfer1transfer2
不存在竞争条件transfer4transfer3

这是一类非常常见的竞争条件和数据竞争的问题。为了帮助开发者解决这类问题,各种编程语言都提供了同步原语,这次我们就来学习互斥锁Mutex,以后还会介绍其他的同步原语。

还有一类问题是关于并发编排的,我们需要一组线程(在Go语言中指的是goroutine)按照一定的顺序执行,还需要一些工具对它们进行编排,这些用来帮助我们编排的类型被称为“并发原语(concurrency primitive)”,比如 WaitGroup、channel等。

有些人会严格区分并发原语和同步原语,比如下面一种划分方式:

  • 同步原语是一种用于控制多个线程同时执行的操作。它通常用于实现并发操作,例如多线程并行计算。常见的同步原语包括原子操作、信号量、互斥锁等。
  • 并发原语是一种用于控制多个线程之间的执行顺序的操作。它通常用于实现同步操作,例如线程间的数据传递。常见的并发原语包括条件变量、消息队列、事件通知等。

按照这种分法,同步原语用来处理竞争条件和数据竞争,而并发原语用来编排。不过我并不严格区分这两个概念,将其统一称为“同步原语”。