Go 并发赋值的安全性探讨
1.什么是并发安全
并发安全就是程序在并发情况下执行的结果是正确的。
比如对一个变量简单的自增操作count++
,在非并发下很好理解,而在并发情况下却容易出现预期之外的结果,这样的代码就是非并发安全的。
因为count++
其实是分成两步执行的,当分成了两步执行,那么其他协程就可以趁着这个时间间隙作怪。
如一下 a b 两个协程同时 count++:
1 | count:= 1 |
这就会发生明明 a b 协程计算了两次,可结果还是 2。
2.struct 并发赋值安全吗
对一个简单变量的自增都会出现偏差,那么赋值一个更为复杂的结构体会不会有问题呢?
例如以下代码,在多协程的情况下,并发使用两个不同的值对结构体变量进行赋值,如果结构体成员出现异常情况, 那么说明并发出现了问题。
1 | type Test struct { |
复制
运行一次或多次,将出现赋值异常。
1 | concurrent assignment error, i=48714 g={X:1 Y:4} |
复制
结构体中有多个字段,协程 1 赋值了字段 X,协程 2 赋值了字段 Y,此时整个结构体既不是协程 1 想要的结果,也不是协程 2 想要的结果。可见 struct 赋值时,并不是原子操作,各个字段的赋值是独立的,在并发操作的情况下可能会出现异常。
3.如何保证并发赋值的安全性
Go 早已想到该问题,并为我们提供一个开箱即用的类型 atomic.Value 来保证赋值的并发安全。
1 | // A Value provides an atomic load and store of a consistently typed value. |
复制
让我们借助 atomic.Value 来完成对 struct 的安全并发赋值。
1 | func main() { |
复制
上面执行将不会出现并发赋值异常的情况。
4.哪些类型并发赋值是安全的
我们已经知道了 struct 因为存在多个字段,赋值时各个字段时独立完成,所以并发不安全。那么对于 Go 中其他的数据类型,并发赋值是安全的吗?
Go 中数据类型可以分类两大类:基本数据类型和复合数据类型。
基本数据类型有:字节型,布尔型、整型、浮点型、字符型、复数型、字符串。
复合数据类型包括:指针、数组、切片、结构体、字典、通道、函数、接口。
复合数据类又可细分为如下三类: (1)非引用类型:数组、结构体; (2)引用类型:指针、切片、字典、通道、函数; (3)接口。
下面一一列举哪些数据类型是并发不安全的。
4.1 基本类型的并发赋值
4.1.1 字节型、布尔型、整型、浮点型、字符型(安全)
由于字节型、布尔型、整型、浮点型、字符型的位宽不会超过 64 位,在 64 位的指令集架构中可以由一条机器指令完成,不存在被细分为更小的操作单位,所以这些类型的并发赋值是安全的。
下面以浮点型为例进行测试。
1 | func main() { |
复制
上面个的测试代码对一个 float64 类型的变量进行并发赋值是没有问题的,其他类型读者可自行验证。
4.1.2 复数型(不安全)
按照上面的分析,因为复数型分为实部和虚部,两者的赋值是分开进行的,所以复数类型并发赋值是不安全的。
1 | func main() { |
复制
运行输出:
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 | type stringStruct struct { |
复制
其数据结构很简单: str 为字符串的首地址; len 为字符串的长度(单位字节); string 数据结构跟切片有些类似,只不过切片还有一个表示容量的成员,事实上 string 和字节切片间经常强制互转。
因为 string 底层结构是个 struct,前面已经讨论过 struct 并发赋值是不安全的,所以 string 的并发赋值同样是不安全。我们来验证一下。
1 | func main() { |
复制
运行输出:
1 | concurrent assignment error, i=509383 s=abi |
复制
并发赋值不出意料地出现了异常情况,推测正确。
从这里我们可以得到一个基本结论:只要底层结构是 struct 的类型,那么并发赋值都是不安全的。
注意不安全不代表一定发生错误。就是说不安全不代表任何并发赋值的情况下都会发生错误。比如上面测试代码循环次数少的情况下,很难出现出现异常情况。
不过我这里想说的不是次数的问题,因为次数多少是个概率的问题,我这里说的是和所要赋的值有关。只要不同的值满足一定特点,不管多少次并发,都是安全的。
为什么可以这么说呢,我们还是要回看 string 的底层数据结构。因为是两个字段,字节指针 str 和字符串长度 len,我们只要保证并发赋值情况下,两个字段的赋值正确就行。前面也说了,因为 struct 多个字段的赋值是独立,所以如果两个字段中只要有一个字段是不同的,那么并发赋值就变成了一个字段的并发赋值,这样就不会出现问题。
比如我们并发赋值两个等长度但内容不同的字符串,就不会有问题。验证如下:
1 | func main() { |
复制
上面的代码,因为字符串 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 | package main |
复制
函数类型的变量赋值时,实际上赋的是函数地址,一条机器指令便可以完成,所以并发赋值是安全的。
我们使用unsafe.Sizeof()
可以查看函数类型的宽度(字节)。
1 | type Add func(int, int) int |
复制
下面验证一下函数变量并发赋值的安全性。
1 | type Add func(int, int) int |
复制
运行输出:
1 | no error |
复制
4.2.2 数组、切片、字典、通道、接口(不安全)
数组、切片、字典、通道、接口,这些复合类型,除了数组,其他底层数据结构都是 struct,所以并发都不是安全的,当然数组并发赋值也是不安全的。
下面的讲解不会对所有类型一一验证,不过相关的底层数据我们应该着重了解一下。
数组
array 是相同类型值的集合,数组的长度是其类型的一部分。
数组赋值和传参都会拷贝整个数组的数据,所以数组不是引用类型。
数组的底层数据结构就是其本身,是一个相同类型不同值的顺序排列。所以如果数组位宽不大于 64 位且是 2 的整数次幂(8,16,32,64),那么其并发赋值其实也是安全的,只不过这个大部分情况并非如此,所以其并发赋值是不安全的。
下面以字节数组为例,看下位宽不大于 64 位的并发赋值安全的情况。
1 | func main() { |
复制
运行输出:
1 | no error |
复制
可以看到,位宽为 32 位的数组 [4]byte,虽然有四个元素,但是赋值时由一条机器指令完成,所以也是原子操作。
如果你把字节数组的长度换成下面这样子,即使没有超过 64 位,也需要多条指令完成赋值,因为 CPU 中并没有这样位宽的寄存器,需要拆分为多条指令来完成。
1 | [3]byte |
复制
切片
slice 也是相同类型值的集合,只不过切片是动态调整大小的,内部是对数组的引用,相当于动态数组。如上所述,数组的大小是固定的,因此切片为数组提供了更灵活的接口。
切片是一种引用类型,它内部由三个字段表示:
- 数组地址
- 数组长度
- 容量大小
在源码包src/runtime/slice.go
我们可以找到切片的底层数据结构:
1 | type slice struct { |
复制
因为其是一个 struct,所以并发赋值是不安全的,这里不再以代码验证。
字典
map 是经常被使用的内置 key-value 型容器,是一个同类型元素的无序组,元素通过另一类型唯一键进行索引。
map 的底层结构也是一个 struct,定义于src/runtime/map.go
:
1 | // A header for a Go map. |
复制
map 并发读写会引发 panic,一般使用读写锁 sync.RWMutex 来保证安全。
通道
channel 在 goroutine 之间提供同步和通信。您可以将其视为 goroutines 通过其发送值和接收值的管道。操作符<-
用于发送或接收数据,箭头方向指定数据流的方向。
1 | ch <- val // Sending a value present in var variable to channel |
复制
因为 channel 通常用法是初始化后作为共享变量在 goroutine 之间提供同步和通信,很少会发生赋值,就是把一个 channel 赋给另一个 channel,所以这里就不过多讨论其并发赋值的安全性。如果真的有这种情况,那么只要知道其底层数据结构是个 struct,并发赋值时不安全的即可。
关于 channel 的底层数据接口可在 Go 源码src\runtime\chan.go
。
1 | type hchan struct { |
复制
关于 channel 的用法和实现原理,感兴趣的同学可自行查阅资料探究,这里不再赘述。
接口
接口是 Go 中的一个类型,它是方法的集合。实现接口的所有方法的任何类型都属于该接口类型。接口的零值为 nil。
定义一个接口类型的变量后,如果具体类型实现了接口的所有方法,我们可以将任何具体类型的值赋给这个变量。
实际上 Go 中的接口有个特殊情况,就是空接口,其不包含任何方法。因此,默认情况下,所有具体类型都实现空接口。
如果编写的函数接受空接口,则可以向该函数传递任何类型。
1 | package main |
复制
运行输出:
1 | (thisisstring, string) |
复制
因为存在两种类型的接口,包含方法的非空接口和不包含任何方法的空接口,所以在底层实现上使用runtime.iface
表示非空接口,使用runtime.eface
表示空接口 interface{}。
在 Go 源码中 runtime 包下,我们可以找到 runtime.iface 和 runtime.eface 的定义。
1 | type iface struct { // 16 字节 |
复制
这个结构体中有指向原始数据的指针 data 和 runtime.itab。
runtime.itab 结构体是接口类型的核心组成部分,每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter 和 _type 两个字段表示:
1 | type itab struct { // 32 字节 |
复制
除了 inter 和 _type 两个用于表示类型的字段之外,上述结构体中的另外两个字段也有自己的作用: hash 是对 _type.hash 的拷贝,当我们想将 interface 类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型 runtime._type 是否一致; fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的。
1 | type eface struct { // 16 字节 |
复制
由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 Go 语言的任意类型都可以转换成 interface{}。
其中runtime._type
是 Go 语言类型的运行时表示。下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。
1 | type _type struct { |
复制
size 字段存储了类型占用的内存空间,为内存空间的分配提供信息; hash 字段能够帮助我们快速确定类型是否相等; equal 字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的。
我们只需要对 runtime._type 结构体中的字段有一个大体的概念,不需要详细理解所有字段的作用和意义。
根据上面对接口底层结构的分析,我们可以得出如下结论:
接口底层数据结构包含两个字段,相互赋值时如果是相同具体类型不同值并发赋给一个接口,那么只有一个字段 data 的值是不同的,此时退化成指针的并发赋值,所以是安全的。但如果是不同具体类型的值并发赋给一个接口,那么并引发 panic。
不同具体类型并发赋值接口非安全验证如下:
1 | func main() { |
复制
运行输出:
1 | unexpected fault address 0x1fffffa8 |
复制
把上面的示例代码中协议 1 中的字符串换成一个 int 值,那么并发是安全的。
1 | func main() { |
复制
运行输出:
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接口