问题示例
1、首先,在开始之前,先说一点相关的东西。
在 Golang 中,有很多数据结构的操作,都不是线程安全的,比如大家熟知的 map ,比如 container/list 包。线程安全,指的是基于这类数据结构实例化的变量,可以并发操作,也就是多个 goroutine 同时进行操作。
另外,也许你也知道,golang 在编译时,是支持并发竞争检测的。go build –race ,很多 gopher 其实并不陌生。这里需要说一点是,–race 并非只支持构建时,也支持单测时,也就是 go test –race。
好了,结合上面2个点,我们看一个例子(文件 xx_test.go)(代码示例1):
1 | package main |
代码很简单,初始化一个切片,起2个协程,并发操作这个切片。
我们做一下单测并做竞争检测:
1 | $ go test --race |
从结果来看,竞争检测结果是通过的。
2、我们将上面的代码做一点变更,上面代码第 9 行,切片初始化是这样的:
1 | x := []string{"start"} |
我们做一个改动,给它一个大小
1 | x := make([]string, 0, 6) |
仅此而已,什么都不变,然后我们再看一下完整的代码,并再次做一次竞争检测(代码示例2)。
1 | package main |
我们再次做测试,看一下测试结果:
1 | $ go test --race |
结果:
很直接,golang 直接告诉我们有数据竞争,数据竞争检测不通过。而我们仅仅只改了 slice 的初始化方式而已。
为什么测试会失败
要理解为什么会失败,就需要看我们2个例子中,切片的内存变化。
代码示例1的切片内存布局
竞争检测通过的代码示例1(也就是第一个代码例子)中的 x 初始化方式我们回顾一下:
1 | x := []string{"start"} |
在这个切片中,名称为 x ,长度为 1,容量也为 1 。
但是需要注意,在代码示例1,2个不同的协程,要向 x 中分别添加元素:”hello”, “world” 和 “goodbye”, “bob” ,所以,Golang 需要新开辟内存空间,切片 x 的内存变化如图:
这个图有几个关键点:
- 原始切片为 x,长度和容量都是 1。
- 协程1为切片 x,添加元素,并将结果赋值给新的变量 y。相当于直接开辟了内存空间 y,做元素新增的操作。
- 协程2为切片 x,添加元素,并将结果赋值给新的变量 z。相当于直接开辟了内存空间 z,做元素新增的操作。
- 当多个线程读取内存 x 时,由于 x 底层一直就没变化,因此,不会发生数据争用。竞争检测是通过的。
代码示例2的切片内存布局
在后来的例子,也就是代码示例2中,代码有所变化,我们回顾一下:
1 | x := make([]string, 0, 6) |
从图中可以看到,切片 x 的内存布局有所变化,长度为 0,但是容量为 6。在代码示例2中,有2个协程,在往 x 中,分别添加2个 元素。问题是,在这个切片 x 中,是有足够的空间,可以放下 6个新元素的。因此,协程1和协程2,都会往切片 x 的内存空间中,添加新元素。
而竞争,就是发生是因为两个goroutine都试图写入相同的内存区域。因此,数据竞争产生了。golang test –race 也就失败了。
竞争对切片 x 写数据的示意图如下:
如图,协程1 和 协程2 ,竞争操作了同一个切片 x。最终也不知道谁赢了。
结论:
在 Golang 的切片操作中,每次调用 append 并不会强制执行新的内存分配。因此,上面的情况,这是 golang 本身的特性,而不是bug。
如何避免上述问题
解决方式1:预先分配好目标变量内存
最简单的解决方法是,做 append 操作时,如果你希望 append 后是一个新的数据,那么,一开始就不要不使用有共享状态的变量,作为要追加的第一个变量。
比如,使用你需要的总容量创建一个新切片,并使用新切片作为要追加的第一个变量。
下面是一个代码示例:
1 | package main |
总的来说(以协程1的操作为例):
- append 之前,先创建新的变量 y。
- 将 x 原有的数据,添加到 y 中。
- 执行你需要 append 的新元素。
这个操作其实有点繁琐,谈不上优雅,而且内存效率也有一定程度上的浪费。
解决方式2:加锁
1 | package main |
当然,如果你有更好的解决方式,欢迎指正。