Go错误集锦 | 通过示例理解数据竞争及竞争条件

2022年4月3日 377点热度 0人点赞 0条评论

大家好,我是渔夫子。今天跟大家聊聊Go并发中的两个重要的概念:数据竞争(data race)和竞争条件(race condition)。

在并发程序中,竞争问题可能是程序面临的最难也是最不容易发现的错误之一。作为Go研发人员,必须要理解竞争的关键特性,例如数据竞争以及竞争条件。下面我们就来看下数据竞争和竞争条件(也称为资源竞争)各自的特性,然后看看各自在何时会产生。

数据竞争(data race)

当两个或多个协程同时访问同一个内存地址,并且至少有一个是在写时,就会发生数据竞争。下面是两个协程对同一个共享变量进行+1操作的例子:

i := 0
go func() {
i++
}()

go func() {
i++
}()

我们运行go run -race main.go,会输出如下提示表明发生了数据竞争:

==================
WARNING: DATA RACE
Write at 0x00c00008e000 by goroutine 7:
main.main.func2()
Previous write at 0x00c00008e000 by goroutine 6:
main.main.func1()
==================

同时,i 的值也是不可预知的。可能是1,也可能是2。

这段代码的问题在哪里呢?实际上i++是三个操作的组合:

  • i中读取值value

  • 将value的值+1

  • 将值写回到 i

场景一:Goroutine1在Goroutine2之前运行完成

在这种场景下,情况将会是如下这样:

Goroutine1 Goroutine2 i 值
初始值 0
读取i的值value 0
将value值+1 0
将值写回到i 1
读取i的值value 1
将value值+1 1
将值写回到i 2

第一个协程读取i的值,然后将值进行+1操作,最后将值写回给i。然后第二个协程再开始执行。因此,i的结果是2.

但是,在上面的示例中,并没有任何机制来保证协程一 一定是在协程二读之前完成的。我们再来看接下来并发的场景。

场景二:Goroutine1和Goroutine2并发执行

在这种场景下,情况将会是如下这样:

Goroutine1 Goroutine2 i
0
读取i的值value 0
读取i的值value 0
将value值+1 0
将value值+1 0
将值写回到i 1
将值写回到i 1

首先,两个协程都从i中读取,得到结果都是0。然后,都将读到的值+1,然后将各自的值写回给i,结果是1。这是不符合我们预期的。

这是数据竞争造成的影响。如果两个协程同时访问同一块内存,并且至少有一个协程写入,就会导致一个不可预期的结果。

如何避免数据竞争的发生?

第一种解决方案是让i++变成原子操作。如下:

Goroutine1 Goroutine2 i
0
读取值并+1操作 1
读取值并+1操作 2

使用这种方式,即使是协程2在协程1之前完成,最终结果也是2。

在Go中,原子操作可以使用atomic包。下面是一个具体使用的示例:

var i int64
go func() {
atomic.AddInt64(&i, 1)
}()
go func() {
atomic.AddInt64(&i, 1)
}()

两个协程对i的操作都是原子性的。一个原子操作是不能被中断。因此,可以避免多个线程在同一时间访问同一共享数据。无论协程的执行顺序如何,i的最终结果都是2。

第二种解决方案是使用同步原语mutex。mutex表示互斥,它确保最多一个goroutine访问所谓的关键部分。在Go中,sync包提供了Mutex类型:

i := 0
mutex := sync.Mutex{}
go func() {
mutex.Lock()
i++
mutex.Unlock()
}()

go func() {
mutex.Lock()
i++
mutex.Unlock()
}()

在该示例中,对i进行+1操作是关键部分。无论协程的顺序如何,该示例中的i都会有一个确定的输出:2。

哪种方法好呢?首先,atomic包只能操作特定的类型(例如int32,int64等整数)。如果我们有一些其他类型的操作(比如,切片,map以及结构体),我们就不能依赖atomic包来解决问题了。

另一种避免同时读取同一块内存的方法是使用通道在多协程间进行通信。例如,我们可以创建一个channel,然后每个协程将要增加的值输入到通道中,如下:

i := 0
ch := make(chan int)
go func() {
ch <- 1
}()

go func() {
ch <- 1
}()

i += <-ch
i += <-ch

该示例中,每个协程都将增量值(这里是1)依次输入到通道中。父协程管理通道并从通道中读取中对i进行算数加操作。因为只有一个协程在对i进行写操作,所以这种方法不存在数据竞争。

我们对上面做个小结。当多个协程同时访问同一块内存区域时,并且存在至少一个协程在进行写操作时,就会发生数据竞争(data-race)

我们共演示了3种避免数据竞争的方法:

  • 使用原子操作

  • 使用mutex对同一区域进行互斥操作

  • 使用通道进行通信以保证仅且只有一个协程在进行写操作

在这3种方法中,无论协程的顺序的执行如何,i的值都会是2。

那么,如果一个应用中没有数据竞争的存在,那么是否意味着一定能输出一个确定的结果呢?

竞争条件(race condition)

我们先看一个示例。该示例中在两个协程中对变量i都进行直接赋值操作。我们使用mutex来避免数据竞争:

i := 0
mutex := sync.Mutex{}
go func() {
mutex.Lock()
defer mutex.Unlock()
i = 1
}()

go func() {
mutex.Lock()
defer mutex.Unlock()
i = 2
}()

第一个协程把1赋给i,第二个协程把2赋给i

在该示例中会产生数据竞争吗?当然不会。两个协程虽然访问同一个变量,但由于我们使用了mutex机制,在同一时间只有一个协程能进行操作。那么,该示例的输出结果是确定的吗?当然不是确定。

变量i的结果依赖于协程的执行顺序,可能是1也可能是2。该示例不会产生数据竞争。但是,存在竞争条件(race condition),也称为资源竞争。当程序的行为依赖于执行顺序或事件发生的时机不可控时就会发生竞争条件

在该示例中,事件发生的时机就是协程执行的顺序。

保证协程间的执行顺序是协调和编排问题。如果要确保状态从0到1,然后再从1到2,我们就需要找到一种保证协程按序执行的方式。一种方式就是使用通道来解决该问题。此外,如果我们使用了通道进行协调和编排,也可以保证在同一时间只有一个协程在访问公共的部分。这也就意味着我们可以移除mutex。


总结

当我们研发并发程序时,一定要理解数据竞争和竞争条件之间的不同。

数据竞争(data race)的发生条件是:当多个协程同时访问一个相同内存位置,并且至少有一个在进行写入操作时。数据竞争意味着不确定的行为。

然而不存在数据竞争不代表结果就是确定的。实际上,一个应用程序即使不存在数据竞争,但它的行为可能依赖于不可控的发生时间或执行顺序,这就是竞争条件(race condition)

了解这两个方面对于熟练设计并发应用程序至关重要。


欢迎关注「Go学堂」,让知识活起来

28650Go错误集锦 | 通过示例理解数据竞争及竞争条件

这个人很懒,什么都没留下

文章评论