一个奇怪的golang对切片的竞争检测问题

问题示例

1、首先,在开始之前,先说一点相关的东西。

在 Golang 中,有很多数据结构的操作,都不是线程安全的,比如大家熟知的 map ,比如 container/list 包。线程安全,指的是基于这类数据结构实例化的变量,可以并发操作,也就是多个 goroutine 同时进行操作。

另外,也许你也知道,golang 在编译时,是支持并发竞争检测的。go build –race ,很多 gopher 其实并不陌生。这里需要说一点是,–race 并非只支持构建时,也支持单测时,也就是 go test –race。

好了,结合上面2个点,我们看一个例子(文件 xx_test.go)(代码示例1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"sync"
"testing"
)

func TestAppend(t *testing.T) {
x := []string{"start"}

wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
y := append(x, "hello", "world")
t.Log(cap(y), len(y))
}()
go func() {
defer wg.Done()
z := append(x, "goodbye", "bob")
t.Log(cap(z), len(z))
}()
wg.Wait()
}

代码很简单,初始化一个切片,起2个协程,并发操作这个切片。

我们做一下单测并做竞争检测:

1
2
3
$ go test --race
PASS
ok test/test12 1.017s

从结果来看,竞争检测结果是通过的。


2、我们将上面的代码做一点变更,上面代码第 9 行,切片初始化是这样的:

1
x := []string{"start"}

我们做一个改动,给它一个大小

1
x := make([]string, 0, 6)

仅此而已,什么都不变,然后我们再看一下完整的代码,并再次做一次竞争检测(代码示例2)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"sync"
"testing"
)

func TestAppend(t *testing.T) {
x := make([]string, 0, 6)

wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
y := append(x, "hello", "world")
t.Log(cap(y), len(y))
}()
go func() {
defer wg.Done()
z := append(x, "goodbye", "bob")
t.Log(cap(z), len(z))
}()
wg.Wait()
}

我们再次做测试,看一下测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ go test --race
==================
WARNING: DATA RACE
Write at 0x00c0000a4120 by goroutine 8:
test/test12.TestAppend.func2()
/Users/shuai/go/src/test/test12/aaa_test.go:20 +0xbe

Previous write at 0x00c0000a4120 by goroutine 7:

// ...

--- FAIL: TestAppend (0.00s)
aaa_test.go:16: 6 2
aaa_test.go:21: 6 2
testing.go:809: race detected during execution of test
FAIL
exit status 1
FAIL test/test12 0.012s

结果:

很直接,golang 直接告诉我们有数据竞争,数据竞争检测不通过。而我们仅仅只改了 slice 的初始化方式而已。


为什么测试会失败

要理解为什么会失败,就需要看我们2个例子中,切片的内存变化。

代码示例1的切片内存布局

竞争检测通过的代码示例1(也就是第一个代码例子)中的 x 初始化方式我们回顾一下:

1
x := []string{"start"}

pic1

在这个切片中,名称为 x ,长度为 1,容量也为 1 。

但是需要注意,在代码示例1,2个不同的协程,要向 x 中分别添加元素:”hello”, “world” 和 “goodbye”, “bob” ,所以,Golang 需要新开辟内存空间,切片 x 的内存变化如图:

pic2

这个图有几个关键点:

  1. 原始切片为 x,长度和容量都是 1。
  2. 协程1为切片 x,添加元素,并将结果赋值给新的变量 y。相当于直接开辟了内存空间 y,做元素新增的操作。
  3. 协程2为切片 x,添加元素,并将结果赋值给新的变量 z。相当于直接开辟了内存空间 z,做元素新增的操作。
  4. 当多个线程读取内存 x 时,由于 x 底层一直就没变化,因此,不会发生数据争用。竞争检测是通过的。

代码示例2的切片内存布局

在后来的例子,也就是代码示例2中,代码有所变化,我们回顾一下:

1
x := make([]string, 0, 6)

pic3

从图中可以看到,切片 x 的内存布局有所变化,长度为 0,但是容量为 6。在代码示例2中,有2个协程,在往 x 中,分别添加2个 元素。问题是,在这个切片 x 中,是有足够的空间,可以放下 6个新元素的。因此,协程1和协程2,都会往切片 x 的内存空间中,添加新元素。

而竞争,就是发生是因为两个goroutine都试图写入相同的内存区域。因此,数据竞争产生了。golang test –race 也就失败了。

竞争对切片 x 写数据的示意图如下:

pic4

如图,协程1 和 协程2 ,竞争操作了同一个切片 x。最终也不知道谁赢了。

结论:

在 Golang 的切片操作中,每次调用 append 并不会强制执行新的内存分配。因此,上面的情况,这是 golang 本身的特性,而不是bug。

如何避免上述问题

解决方式1:预先分配好目标变量内存

最简单的解决方法是,做 append 操作时,如果你希望 append 后是一个新的数据,那么,一开始就不要不使用有共享状态的变量,作为要追加的第一个变量。

比如,使用你需要的总容量创建一个新切片,并使用新切片作为要追加的第一个变量。

下面是一个代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"sync"
"testing"
)

func TestAppend(t *testing.T) {
// 原始切片x
x := make([]string, 0, 6)
x = append(x, "start")

wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
// 先为变量 y,初始化切片。
y := make([]string, 0, len(x)+2)
// 将 x 的所有元素放到 y 中,然后再执行 append 操作。
y = append(y, x...)
y = append(y, "hello", "world")
t.Log(cap(y), len(y), y[0])
}()
go func() {
defer wg.Done()
z := make([]string, 0, len(x)+2)
z = append(z, x...)
z = append(z, "goodbye", "bob")
t.Log(cap(z), len(z), z[0])
}()
wg.Wait()
}

总的来说(以协程1的操作为例):

  1. append 之前,先创建新的变量 y。
  2. 将 x 原有的数据,添加到 y 中。
  3. 执行你需要 append 的新元素。

这个操作其实有点繁琐,谈不上优雅,而且内存效率也有一定程度上的浪费。

解决方式2:加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"github.com/k0kubun/pp"
"sync"
"testing"
)

func TestAppend(t *testing.T) {
x := make([]string, 0, 6)
// 实例化一个锁
lock := sync.Mutex{}

wg := sync.WaitGroup{}
wg.Add(2)
go func() {
// 协程1在进行append前加锁
lock.Lock()
defer lock.Unlock()
defer wg.Done()
y := append(x, "hello", "world")
t.Log(cap(y), len(y))
}()
go func() {
// 协程2在进行append前加锁
lock.Lock()
defer lock.Unlock()
defer wg.Done()
z := append(x, "goodbye", "bob")
t.Log(cap(z), len(z))
}()
wg.Wait()
pp.Println(x)
}

当然,如果你有更好的解决方式,欢迎指正。

Donate comment here