内容
竞态问题可能程序员面临的最困难和最隐蔽的错误之一。作为 Go 开发者,必须理解数据竞争和竞态条件等关键方面,包括它们可能产生的影响以及如何避免。接下来将首先讨论数据竞争与竞态条件的区别,然后研究 Go 内存模型及其重要性。
数据竞争与竞态条件
让我们首先关注数据竞赛。当两个或多个 goroutine 同时访问同一内存位置并且至少有一个正在写入时,就会发生数据争用。下面是一个示例,其中两个 goroutine 递增一个共享变量:
i := 0
go func() {i++
}()
go func() {i++
}()
如果我们使用 Go 语言的竞态检测工具(通过 -race
选项)来运行相关代码,该工具会警告我们出现了数据竞争的问题。
==================
WARNING: DATA RACE
Write at 0x00c00008e000 by goroutine 7:main.main.func2()
Previous write at 0x00c00008e000 by goroutine 6:main.main.func1()
==================
最终的结果也是不可预测的,有时可能是1,有时可能是2。
这段代码存在什么问题? i++
语句可以分解为三个操作。
- 读取i,
- 对个值进行加,
- 将值写会i.
假设在某种情况下,第一个 goroutine
在第二个 goroutine
之前执行并完成,接下来将会说明发生的事情。
第一个 goroutine
读取 i
的值,将其递增,并将结果 1
写回 i
。然后第二个 goroutine
执行相同的操作,但它开始时 i
的值为 1
,最终将结果 2
写回 i
。
然而,不能保证第一个 goroutine
会在第二个 goroutine
之前开始或完成,还可能出现两个 goroutine
交叉并发执行、竞争访问共享变量 i
的情况,并提出接下来要介绍另一种可能的场景。
首先,两个 goroutine
都从 i
读取到值 0
,然后都将其递增并把本地计算结果 1
写回,导致结果不是预期的。这可能就是数据竞争产生的影响,当两个 goroutine
同时访问同一内存位置且至少有一个进行写入时,结果可能是危险的,在某些情况下,该内存位置最终可能会保存一个无意义的位组合值。
接下来,我们来看一下,可以通过哪些技术手段来避免数据竞争的发生。
第一种方法是使递增操作具有原子性(atomic),即在单个操作中完成,以防止并发操作相互干扰。
即使第二个 goroutine
在第一个之前运行,结果仍然是 2
。
原子操作在 Go 语言中可以使用 sync/atomic
包,这是一个我们如何以原子方式递增 int64 的示例:
var i int64go func() {atomic.AddInt64(&i, 1)
}()
go func() {atomic.AddInt64(&i, 1)
}()
sync/atomic
包中提供了 int32, int64, uint32, and uint64原子操作,但是没有提供int的原子操作。
sync/atomic
包仅适用于特定类型,如果涉及到切片、映射和结构体等类型,就不能依赖sync/atomic
另一种防止数据竞争的选项,即使用像互斥锁(Mutex)这样的特定数据结构来同步两个 goroutine
。互斥锁的作用是实现互斥访问,确保最多只有一个 goroutine
可以访问所谓的临界区。下边i++
就是临界区,通过mutex
来保证,无论goroutine
的顺序如何,最终的结果都是2。
i := 0
mutex := sync.Mutex{}
go func() {mutex.Lock()i++mutex.Unlock()
}()
go func() {mutex.Lock()i++mutex.Unlock()
}()
另一种防止数据竞争的方式,即避免共享相同的内存位置,而是在 goroutine
之间进行通信。例如,创建一个通道,每个 goroutine
都使用该通道来产生递增的值。
i := 0
ch := make(chan int)
go func() {ch <- 1
}()
go func() {ch <- 1
}()
i += <-ch
i += <-chfmt.Println(i)
每个goroutine
通过channel发送一个通知,将i增加1,父goroutine
收到信号后,将i的值增加1,这样就避免了数据竞争。
上文小结,关于避免数据竞争的常用操作:
- 使用atomic操作:通过原子性的操作来确保对共享数据的并发访问是安全的。
- 使用互斥锁(mutex)保护临界区:确保在同一时间只有一个
goroutine
可以访问被保护的临界区(包含对共享数据的操作)。 - 使用channel通信:利用channel进行
goroutine
之间的通信,保证只有一个goroutine
对特定变量进行更新操作。
有了对上述内容的了解,是否意味着,所有的没有数据竞争的程序结果都是符合预期的了。来看下一段程序:
i := 0
mutex := sync.Mutex{}
go func() {mutex.Lock()defer mutex.Unlock()i = 1
}()
go func() {mutex.Lock()defer mutex.Unlock()i = 2
}()
这段代码通过mutex避免了同一刻访问相同的内存资源,所以没有数据竞争,那么是否意味这个结果一定是2?答案是否定的,该程序强依赖于两个goroutine
执行的先后顺序,这就是竟态条件。**「竞态条件」**是指行为取决于无法控制的事件的序列或时间,在这里事件的时间是 goroutine
的执行顺序。如果要保证特定的执行顺序,可通过channel进行解决。同时也就不需要互斥锁了。
内容小结:
数据竞争是多个 goroutine
同时访问同一内存位置且至少有一个进行写入操作;数据竞争会导致意外行为。没有数据竞争的应用程序不一定有确定的结果,当应用程序没有数据竞争,但行为取决于不可控事件(如 goroutine
执行顺序、消息发布到通道的速度、数据库调用持续时间等)时,就存在竞态条件。
go的内存模型
- 创建先于执行。
创建一个 goroutine
的操作先于该 goroutine
的执行开始。因此,读取一个变量,然后启动一个新的 goroutine
对该变量进行写入,不会导致数据竞争。
i := 0
go func() {i++
}()
- 退出不保证顺序
不能保证一个 goroutine
的退出先于任何事件发生。因此,下面的示例存在数据竞争。
i := 0
go func() {i++
}()
fmt.Println(i)
- 发送优先于接收
在通道上的发送操作先于相应的从该通道接收操作的完成而发生。在接下来的示例中,一个父 goroutine
在发送之前先递增一个变量,而另一个 goroutine
在通道接收之后读取该变量。
i := 0
ch := make(chan struct{})
go func() {<-chfmt.Println(i)
}()
i++
ch <- struct{}{}
- 关闭优先于关闭信息的接收
如果 goroutine A
关闭了一个通道,之后 goroutine B
从该通道接收数据,那么 goroutine A
关闭通道这一事件在顺序上先于 goroutine B
对通道关闭的感知和相应的接收操作。
i := 0
ch := make(chan struct{})
go func() {<-chfmt.Println(i)
}()
i++
close(ch)
- 有缓冲区的发送依赖于接收
父协程发送完数据,然后读取变量i,同时子协程同时写入变量i,然后接收channel中的信息,这里是有数据竞争的。
i := 0
ch := make(chan struct{}, 1)
go func() {i = 1<-ch
}()
ch <- struct{}{}
fmt.Println(i)
如果我们需要保证顺序的话,可以改为无缓冲区的channel。
i := 0ch := make(chan struct{})go func() {i = 1<-ch}()ch <- struct{}{}fmt.Println(i)