原文 Go 并发赋值的安全性探讨 - 腾讯云开发者社区-腾讯云 (tencent.com)

Go 并发赋值的安全性探讨

1.什么是并发安全

并发安全就是程序在并发情况下执行的结果是正确的。

比如对一个变量简单的自增操作count++,在非并发下很好理解,而在并发情况下却容易出现预期之外的结果,这样的代码就是非并发安全的。

因为count++其实是分成两步执行的,当分成了两步执行,那么其他协程就可以趁着这个时间间隙作怪。

如一下 a b 两个协程同时 count++:

1
2
3
4
5
6
7
count:= 1
a > 读取count : 1
b > 读取count : 1
a > 计算count+1 : 2
b > 计算count+1 : 2
a > 赋值count : 2
b > 赋值count : 2

这就会发生明明 a b 协程计算了两次,可结果还是 2。

2.struct 并发赋值安全吗

对一个简单变量的自增都会出现偏差,那么赋值一个更为复杂的结构体会不会有问题呢?

例如以下代码,在多协程的情况下,并发使用两个不同的值对结构体变量进行赋值,如果结构体成员出现异常情况, 那么说明并发出现了问题。

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
type Test struct {
X int
Y int
}

func main() {
var g Test

for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
g = Test{1,2}
}()

// 协程 2
wg.Add(1)
go func(){
defer wg.Done()
g = Test{3,4}
}()
wg.Wait()

// 赋值异常判断
if !((g.X == 1 && g.Y == 2) || (g.X == 3 && g.Y == 4)) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}

复制

运行一次或多次,将出现赋值异常。

1
concurrent assignment error, i=48714 g={X:1 Y:4}

复制

结构体中有多个字段,协程 1 赋值了字段 X,协程 2 赋值了字段 Y,此时整个结构体既不是协程 1 想要的结果,也不是协程 2 想要的结果。可见 struct 赋值时,并不是原子操作,各个字段的赋值是独立的,在并发操作的情况下可能会出现异常。

3.如何保证并发赋值的安全性

Go 早已想到该问题,并为我们提供一个开箱即用的类型 atomic.Value 来保证赋值的并发安全。

1
2
3
4
5
6
7
8
// A Value provides an atomic load and store of a consistently typed value.
// The zero value for a Value returns nil from Load.
// Once Store has been called, a Value must not be copied.
//
// A Value must not be copied after first use.
type Value struct {
v interface{}
}

复制

让我们借助 atomic.Value 来完成对 struct 的安全并发赋值。

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
func main() {
var v atomic.Value

for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
v.Store(Test{1,2})
}()

// 协程 2
wg.Add(1)
go func(){
defer wg.Done()
v.Store(Test{3,4})
}()
wg.Wait()

// 赋值异常判断
g := v.Load().(Test)
if (g.X == 1 && g.Y == 2) || (g.X == 3 && g.Y == 4) {
} else {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}

复制

上面执行将不会出现并发赋值异常的情况。

4.哪些类型并发赋值是安全的

我们已经知道了 struct 因为存在多个字段,赋值时各个字段时独立完成,所以并发不安全。那么对于 Go 中其他的数据类型,并发赋值是安全的吗?

Go 中数据类型可以分类两大类:基本数据类型和复合数据类型。

基本数据类型有:字节型,布尔型、整型、浮点型、字符型、复数型、字符串。

复合数据类型包括:指针、数组、切片、结构体、字典、通道、函数、接口。

复合数据类又可细分为如下三类: (1)非引用类型:数组、结构体; (2)引用类型:指针、切片、字典、通道、函数; (3)接口。

下面一一列举哪些数据类型是并发不安全的。

4.1 基本类型的并发赋值

4.1.1 字节型、布尔型、整型、浮点型、字符型(安全)

由于字节型、布尔型、整型、浮点型、字符型的位宽不会超过 64 位,在 64 位的指令集架构中可以由一条机器指令完成,不存在被细分为更小的操作单位,所以这些类型的并发赋值是安全的。

下面以浮点型为例进行测试。

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
func main() {
var g float64

for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
g = 1.1
}()

// 协程 2
wg.Add(1)
go func(){
defer wg.Done()
g = 2.2
}()
wg.Wait()

// 赋值异常判断
if g != 1.1) && g != 2.2 {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}

复制

上面个的测试代码对一个 float64 类型的变量进行并发赋值是没有问题的,其他类型读者可自行验证。

4.1.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
func main() {
var g complex64

for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
g = complex(1,2)
}()

// 协程 2
wg.Add(1)
go func(){
defer wg.Done()
g = complex(3,4)
}()
wg.Wait()

// 赋值异常判断
if g != complex(1,2) && g != complex(3,4) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
}

复制

运行输出:

1
concurrent assignment error, i=131512 g=(1+4i)

复制

注意:如果复数并发赋值时,有相同的虚部或实部,那么两个字段赋值就退化成一个字段,这种情况下时并发安全的。读者可自行验证。

4.1.3 字符串(不安全)

字符串在 Go 中是一个只读字节切片。

字符串有两个重要特点: (1)string 可以为空(长度为 0),但不会是 nil; (2)string对象不可以修改。

在源码包src/runtime/string.go我们可以找到 string 的底层数据结构:

1
2
3
4
type stringStruct struct {
str unsafe.Pointer
len int
}

复制

其数据结构很简单: str 为字符串的首地址; len 为字符串的长度(单位字节); string 数据结构跟切片有些类似,只不过切片还有一个表示容量的成员,事实上 string 和字节切片间经常强制互转。

因为 string 底层结构是个 struct,前面已经讨论过 struct 并发赋值是不安全的,所以 string 的并发赋值同样是不安全。我们来验证一下。

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
func main() {
var s string

for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
s = "ab"
}()

// 协程 2
wg.Add(1)
go func() {
defer wg.Done()
s = "abc"
}()
wg.Wait()

// 赋值异常判断
if s != "ab" && s != "abc" {
fmt.Printf("concurrent assignment error, i=%v s=%v", i, s)
break
}
}
}

复制

运行输出:

1
concurrent assignment error, i=509383 s=abi

复制

并发赋值不出意料地出现了异常情况,推测正确。

从这里我们可以得到一个基本结论:只要底层结构是 struct 的类型,那么并发赋值都是不安全的。

注意不安全不代表一定发生错误。就是说不安全不代表任何并发赋值的情况下都会发生错误。比如上面测试代码循环次数少的情况下,很难出现出现异常情况。

不过我这里想说的不是次数的问题,因为次数多少是个概率的问题,我这里说的是和所要赋的值有关。只要不同的值满足一定特点,不管多少次并发,都是安全的。

为什么可以这么说呢,我们还是要回看 string 的底层数据结构。因为是两个字段,字节指针 str 和字符串长度 len,我们只要保证并发赋值情况下,两个字段的赋值正确就行。前面也说了,因为 struct 多个字段的赋值是独立,所以如果两个字段中只要有一个字段是不同的,那么并发赋值就变成了一个字段的并发赋值,这样就不会出现问题。

比如我们并发赋值两个等长度但内容不同的字符串,就不会有问题。验证如下:

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
func main() {
var s string

for i := 0; i < 1000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
s = "123"
}()

// 协程 2
wg.Add(1)
go func() {
defer wg.Done()
s = "abc"
}()
wg.Wait()

// 赋值异常判断
if s != "123" && s != "abc" {
fmt.Printf("concurrent assignment error, i=%v s=%v", i, s)
break
}
}
}

复制

上面的代码,因为字符串 123 和 abc 是等长的,所以并发赋值不管循环多少次都是绝对的安全。因为 struct 赋值蜕变成了一个数值型指针的赋值。

4.2 复合数据类型的并发赋值

4.2.1 指针(安全)

指针是保存另一个变量的内存地址的变量。指针的零值为 nil。

因为是内存地址,所以位宽为 32位(x86平台)或 64位(x64平台),赋值操作由一个机器指令即可完成,不能被中断,所以也不会出现并发赋值不安全的情况。

这在上面讨论 string 等长不同值并发赋值时,已经验证没有问题。

4.2.2 函数(安全)

Go 函数可以像值一样传递。

Go 函数定义形式如下:

1
func some_func_name(arguments) return_values

复制

定义函数类型时去掉函数名:

1
type TypeName func(arguments) return_values

复制

其中 TypeName 是自定义的类型名称。

下面是一个函数类型的使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
// 定义函数类型的变量
add := func(x, y int) int {
return x + y
}
fmt.Println(add(1, 2))
}

// 函数作为形参
func doOperation(fn func(int, int) int, x, y int) int {
return fn(x, y)
}

复制

函数类型的变量赋值时,实际上赋的是函数地址,一条机器指令便可以完成,所以并发赋值是安全的。

我们使用unsafe.Sizeof()可以查看函数类型的宽度(字节)。

1
2
3
type Add func(int, int) int
var add Add
fmt.Println(unsafe.Sizeof(add)) // 8

复制

下面验证一下函数变量并发赋值的安全性。

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
35
36
37
type Add func(int, int) int

func main() {
var g Add

var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
g = func(x, y int) int {
return x + y
}
}()

// 协程 2
wg.Add(1)
go func() {
defer wg.Done()
g = func(x, y int) int {
return x - y
}
}()
wg.Wait()

// 赋值异常判断
if !(g(1, 1) == 2 || g(1, 1) == 0) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}

复制

运行输出:

1
no error

复制

4.2.2 数组、切片、字典、通道、接口(不安全)

数组、切片、字典、通道、接口,这些复合类型,除了数组,其他底层数据结构都是 struct,所以并发都不是安全的,当然数组并发赋值也是不安全的。

下面的讲解不会对所有类型一一验证,不过相关的底层数据我们应该着重了解一下。

数组

array 是相同类型值的集合,数组的长度是其类型的一部分。

数组赋值和传参都会拷贝整个数组的数据,所以数组不是引用类型。

数组的底层数据结构就是其本身,是一个相同类型不同值的顺序排列。所以如果数组位宽不大于 64 位且是 2 的整数次幂(8,16,32,64),那么其并发赋值其实也是安全的,只不过这个大部分情况并非如此,所以其并发赋值是不安全的。

下面以字节数组为例,看下位宽不大于 64 位的并发赋值安全的情况。

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
func main() {
var g [4]byte

var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
g = [...]byte{1, 2, 3, 4}
}()

// 协程 2
wg.Add(1)
go func() {
defer wg.Done()
g = [...]byte{3, 4, 5, 6}
}()
wg.Wait()

// 赋值异常判断
if !(g == [...]byte{1, 2, 3, 4} || g == [...]byte{3, 4, 5, 6}) {
fmt.Printf("concurrent assignment error, i=%v g=%+v", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}

复制

运行输出:

1
no error

复制

可以看到,位宽为 32 位的数组 [4]byte,虽然有四个元素,但是赋值时由一条机器指令完成,所以也是原子操作。

如果你把字节数组的长度换成下面这样子,即使没有超过 64 位,也需要多条指令完成赋值,因为 CPU 中并没有这样位宽的寄存器,需要拆分为多条指令来完成。

1
2
3
[3]byte
[5]byte
[7]byte

复制

切片

slice 也是相同类型值的集合,只不过切片是动态调整大小的,内部是对数组的引用,相当于动态数组。如上所述,数组的大小是固定的,因此切片为数组提供了更灵活的接口。

切片是一种引用类型,它内部由三个字段表示:

  • 数组地址
  • 数组长度
  • 容量大小

在源码包src/runtime/slice.go我们可以找到切片的底层数据结构:

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

复制

因为其是一个 struct,所以并发赋值是不安全的,这里不再以代码验证。

字典

map 是经常被使用的内置 key-value 型容器,是一个同类型元素的无序组,元素通过另一类型唯一键进行索引。

map 的底层结构也是一个 struct,定义于src/runtime/map.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed

buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)

extra *mapextra // optional fields
}

复制

map 并发读写会引发 panic,一般使用读写锁 sync.RWMutex 来保证安全。

通道

channel 在 goroutine 之间提供同步和通信。您可以将其视为 goroutines 通过其发送值和接收值的管道。操作符<-用于发送或接收数据,箭头方向指定数据流的方向。

1
2
ch <- val        // Sending a value present in var variable to channel
val := <-cha // Receive a value from the channel and assign it to val variable

复制

因为 channel 通常用法是初始化后作为共享变量在 goroutine 之间提供同步和通信,很少会发生赋值,就是把一个 channel 赋给另一个 channel,所以这里就不过多讨论其并发赋值的安全性。如果真的有这种情况,那么只要知道其底层数据结构是个 struct,并发赋值时不安全的即可。

关于 channel 的底层数据接口可在 Go 源码src\runtime\chan.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters

// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}

复制

关于 channel 的用法和实现原理,感兴趣的同学可自行查阅资料探究,这里不再赘述。

接口

接口是 Go 中的一个类型,它是方法的集合。实现接口的所有方法的任何类型都属于该接口类型。接口的零值为 nil。

定义一个接口类型的变量后,如果具体类型实现了接口的所有方法,我们可以将任何具体类型的值赋给这个变量。

实际上 Go 中的接口有个特殊情况,就是空接口,其不包含任何方法。因此,默认情况下,所有具体类型都实现空接口。

如果编写的函数接受空接口,则可以向该函数传递任何类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
test("thisisstring")
test("10")
test(true)
}

func test(a interface{}) {
fmt.Printf("(%v, %T)\n", a, a)
}

复制

运行输出:

1
2
3
(thisisstring, string)
(10, string)
(true, bool)

复制

因为存在两种类型的接口,包含方法的非空接口和不包含任何方法的空接口,所以在底层实现上使用runtime.iface表示非空接口,使用runtime.eface表示空接口 interface{}。

在 Go 源码中 runtime 包下,我们可以找到 runtime.iface 和 runtime.eface 的定义。

1
2
3
4
type iface struct { // 16 字节
tab *itab
data unsafe.Pointer
}

复制

这个结构体中有指向原始数据的指针 data 和 runtime.itab。

runtime.itab 结构体是接口类型的核心组成部分,每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter 和 _type 两个字段表示:

1
2
3
4
5
6
7
type itab struct { // 32 字节
inter *interfacetype
_type *_type
hash uint32
_ [4]byte
fun [1]uintptr
}

复制

除了 inter 和 _type 两个用于表示类型的字段之外,上述结构体中的另外两个字段也有自己的作用: hash 是对 _type.hash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 runtime._type 是否一致; fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的。

1
2
3
4
type eface struct { // 16 字节
_type *_type
data unsafe.Pointer
}

复制

由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 Go 语言的任意类型都可以转换成 interface{}。

其中runtime._type是 Go 语言类型的运行时表示。下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。

1
2
3
4
5
6
7
8
9
10
11
12
13
type _type struct {
size uintptr
ptrdata uintptr
hash uint32
tflag tflag
align uint8
fieldAlign uint8
kind uint8
equal func(unsafe.Pointer, unsafe.Pointer) bool
gcdata *byte
str nameOff
ptrToThis typeOff
}

复制

size 字段存储了类型占用的内存空间,为内存空间的分配提供信息; hash 字段能够帮助我们快速确定类型是否相等; equal 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的。

我们只需要对 runtime._type 结构体中的字段有一个大体的概念,不需要详细理解所有字段的作用和意义。

根据上面对接口底层结构的分析,我们可以得出如下结论:

接口底层数据结构包含两个字段,相互赋值时如果是相同具体类型不同值并发赋给一个接口,那么只有一个字段 data 的值是不同的,此时退化成指针的并发赋值,所以是安全的。但如果是不同具体类型的值并发赋给一个接口,那么并引发 panic。

不同具体类型并发赋值接口非安全验证如下:

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
func main() {
var g interface{}

var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
g = "a"
}()

// 协程 2
wg.Add(1)
go func() {
defer wg.Done()
g = 1
}()
wg.Wait()

// 赋值异常判断
v1, _ := g.(string)
v2, _ := g.(int)
if !(v1 == "a" || v2 == 1) {
fmt.Printf("concurrent assignment error, i=%v g=%v ", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}

复制

运行输出:

1
2
3
unexpected fault address 0x1fffffa8
fatal error: fault
[signal 0xc0000005 code=0x0 addr=0x1fffffa8 pc=0x5f5585]

复制

把上面的示例代码中协议 1 中的字符串换成一个 int 值,那么并发是安全的。

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
func main() {
var g interface{}

var i int
for ; i < 10000000; i++ {
var wg sync.WaitGroup
// 协程 1
wg.Add(1)
go func() {
defer wg.Done()
g = 0
}()

// 协程 2
wg.Add(1)
go func() {
defer wg.Done()
g = 1
}()
wg.Wait()

// 赋值异常判断
v1, _ := g.(int)
v2, _ := g.(int)
if !(v1 == 0 || v2 == 1) {
fmt.Printf("concurrent assignment error, i=%v g=%v ", i, g)
break
}
}
if i == 10000000 {
fmt.Println("no error")
}
}

复制

运行输出:

1
no error

复制

5.小结

Go 多协程并发的场景无处不在,并发对同一变量的赋值也是经常遇到。本文尝试探讨了 Go 中所有类型并发赋值的安全性。

(1)由一条机器指令完成赋值的类型并发赋值是安全的,这些类型有:字节型,布尔型、整型、浮点型、字符型、指针、函数。

(2)数组由一个或多个元素组成,大部分情况并发不安全。注意:当位宽不大于 64 位且是 2 的整数次幂(8,16,32,64),那么其并发赋值是安全的。

(3)struct 或底层是 struct 的类型并发赋值大部分情况并发不安全,这些类型有:复数、字符串、 数组、切片、字典、通道、接口。注意:当 struct 赋值时退化为单个字段由一个机器指令完成赋值时,并发赋值又是安全的。这种情况有: (a)实部或虚部相同的复数的并发赋值; (b)等长字符串的并发赋值; (c)同长度同容量切片的并发赋值; (d)同一种具体类型不同值并发赋给接口。

注意: 以上结论基于x86-64指令集架构,其他平台未作实际验证。


参考文献

[1] 简书.Golang并发操作变量需要注意的问题 [2] All data types in Go with examples [3] The Go Blog.Go Slices: usage and internals [4] CSDN.Go map 三板斧第三式:实现原理 [5] CSDN.Go channel 快速入门 [6] Go 语言设计与实现.4.2接口