我们知道 Golang 中变量的赋值不是并发安全的,实际情况果真如此吗?
1.什么是并发安全
并发安全就是程序在并发情况下执行的结果是正确的。
比如对一个变量简单的自增操作count++
,在非并发下很好理解,而在并发情况下却容易出现预期之外的结果,这样的代码就是非并发安全的。
因为count++
其实是分成两步执行的,当分成了两步执行,那么其他协程就可以趁着这个时间间隙作怪。
如一下 a b 两个协程同时 count++:
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 并发赋值安全吗
对一个简单变量的自增都会出现偏差,那么赋值一个更为复杂的结构体会不会有问题呢?
例如以下代码,在多协程的情况下,并发使用两个不同的值对结构体变量进行赋值,如果结构体成员出现异常情况, 那么说明并发出现了问题。
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 } } }
运行一次或多次,将出现赋值异常。
concurrent assignment error, i=48714 g={X:1 Y:4}
结构体中有多个字段,协程 1 赋值了字段 X,协程 2 赋值了字段 Y,此时整个结构体既不是协程 1 想要的结果,也不是协程 2 想要的结果。可见 struct 赋值时,并不是原子操作,各个字段的赋值是独立的,在并发操作的情况下可能会出现异常。
3.如何保证并发赋值的安全性
Golang 早已想到该问题,并为我们提供一个开箱即用的类型 atomic.Value 来保证赋值的并发安全。
// 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 的安全并发赋值。
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 因为存在多个字段,赋值时各个字段时独立完成,所以并发不安全。那么对于 Golang 中其他的数据类型,并发赋值是安全的吗?
Golang 中数据类型可以分类两大类:基本数据类型和复合数据类型。
基本数据类型有:字节型,布尔型、整型、浮点型、字符型、复数型、字符串。
复合数据类型包括:指针、数组、切片、结构体、字典、通道、函数、接口。
复合数据类又可细分为如下三类: (1)非引用类型:数组、结构体; (2)引用类型:指针、切片、字典、通道、函数; (3)接口。
下面一一列举哪些数据类型是并发不安全的。
4.1 基本类型的并发赋值
4.1.1 字节型、布尔型、整型、浮点型、字符型(安全)
由于字节型、布尔型、整型、浮点型、字符型的位宽不会超过 64 位,在 64 位的指令集架构中可以由一条机器指令完成,不存在被细分为更小的操作单位,所以这些类型的并发赋值是安全的。
下面以浮点型为例进行测试。
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 复数型(不安全)
按照上面的分析,因为复数型分为实部和虚部,两者的赋值是分开进行的,所以复数类型并发赋值是不安全的。
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 } } }
运行输出:
concurrent assignment error, i=131512 g=(1+4i)
注意:如果复数并发赋值时,有相同的虚部或实部,那么两个字段赋值就退化成一个字段,这种情况下时并发安全的。读者可自行验证。
4.1.3 字符串(不安全)
字符串在 Go 中是一个只读字节切片。
字符串有两个重要特点: (1)string 可以为空(长度为 0),但不会是 nil; (2)string对象不可以修改。
在源码包src/runtime/string.go
我们可以找到 string 的底层数据结构:
type stringStruct struct { str unsafe.Pointer len int }
其数据结构很简单: str 为字符串的首地址; len 为字符串的长度(单位字节); string 数据结构跟切片有些类似,只不过切片还有一个表示容量的成员,事实上 string 和字节切片间经常强制互转。
因为 string 底层结构是个 struct,前面已经讨论过 struct 并发赋值是不安全的,所以 string 的并发赋值同样是不安全。我们来验证一下。
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 } } }
运行输出:
concurrent assignment error, i=509383 s=abi
并发赋值不出意料地出现了异常情况,推测正确。
从这里我们可以得到一个基本结论:只要底层结构是 struct 的类型,那么并发赋值都是不安全的。
注意不安全不代表一定发生错误。就是说不安全不代表任何并发赋值的情况下都会发生错误。比如上面测试代码循环次数少的情况下,很难出现出现异常情况。
不过我这里想说的不是次数的问题,因为次数多少是个概率的问题,我这里说的是和所要赋的值有关。只要不同的值满足一定特点,不管多少次并发,都是安全的。
为什么可以这么说呢,我们还是要回看 string 的底层数据结构。因为是两个字段,字节指针 str 和字符串长度 len,我们只要保证并发赋值情况下,两个字段的赋值正确就行。前面也说了,因为 struct 多个字段的赋值是独立,所以如果两个字段中只要有一个字段是不同的,那么并发赋值就变成了一个字段的并发赋值,这样就不会出现问题。
比如我们并发赋值两个等长度但内容不同的字符串,就不会有问题。验证如下:
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 函数定义形式如下:
func some_func_name(arguments) return_values
定义函数类型时去掉函数名:
type TypeName func(arguments) return_values
其中 TypeName 是自定义的类型名称。
下面是一个函数类型的使用示例:
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()
可以查看函数类型的宽度(字节)。
type Add func(int, int) int var add Add fmt.Println(unsafe.Sizeof(add)) // 8
下面验证一下函数变量并发赋值的安全性。
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") } }
运行输出:
no error
4.2.2 数组、切片、字典、通道、接口(不安全)
数组、切片、字典、通道、接口,这些复合类型,除了数组,其他底层数据结构都是 struct,所以并发都不是安全的,当然数组并发赋值也是不安全的。
下面的讲解不会对所有类型一一验证,不过相关的底层数据我们应该着重了解一下。
数组
array 是相同类型值的集合,数组的长度是其类型的一部分。
数组赋值和传参都会拷贝整个数组的数据,所以数组不是引用类型。
数组的底层数据结构就是其本身,是一个相同类型不同值的顺序排列。所以如果数组位宽不大于 64 位且是 2 的整数次幂(8,16,32,64),那么其并发赋值其实也是安全的,只不过这个大部分情况并非如此,所以其并发赋值是不安全的。
下面以字节数组为例,看下位宽不大于 64 位的并发赋值安全的情况。
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") } }
运行输出:
no error
可以看到,位宽为 32 位的数组 [4]byte,虽然有四个元素,但是赋值时由一条机器指令完成,所以也是原子操作。
如果你把字节数组的长度换成下面这样子,即使没有超过 64 位,也需要多条指令完成赋值,因为 CPU 中并没有这样位宽的寄存器,需要拆分为多条指令来完成。
[3]byte [5]byte [7]byte
切片
slice 也是相同类型值的集合,只不过切片是动态调整大小的,内部是对数组的引用,相当于动态数组。如上所述,数组的大小是固定的,因此切片为数组提供了更灵活的接口。
切片是一种引用类型,它内部由三个字段表示:
- 数组地址
- 数组长度
- 容量大小
在源码包src/runtime/slice.go
我们可以找到切片的底层数据结构:
type slice struct { array unsafe.Pointer len int cap int }
因为其是一个 struct,所以并发赋值是不安全的,这里不再以代码验证。
字典
map 是经常被使用的内置 key-value 型容器,是一个同类型元素的无序组,元素通过另一类型唯一键进行索引。
map 的底层结构也是一个 struct,定义于src/runtime/map.go
:
// 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 通过其发送值和接收值的管道。操作符<-
用于发送或接收数据,箭头方向指定数据流的方向。
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
。
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 中的接口有个特殊情况,就是空接口,其不包含任何方法。因此,默认情况下,所有具体类型都实现空接口。
如果编写的函数接受空接口,则可以向该函数传递任何类型。
package main import "fmt" func main() { test("thisisstring") test("10") test(true) } func test(a interface{}) { fmt.Printf("(%v, %T)\n", a, a) }
运行输出:
(thisisstring, string) (10, string) (true, bool)
因为存在两种类型的接口,包含方法的非空接口和不包含任何方法的空接口,所以在底层实现上使用runtime.iface
表示非空接口,使用runtime.eface
表示空接口 interface{}。
在 Go 源码中 runtime 包下,我们可以找到 runtime.iface 和 runtime.eface 的定义。
type iface struct { // 16 字节 tab *itab data unsafe.Pointer }
这个结构体中有指向原始数据的指针 data 和 runtime.itab。
runtime.itab 结构体是接口类型的核心组成部分,每一个 runtime.itab 都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter 和 _type 两个字段表示:
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 数组中保存的元素数量是不确定的。
type eface struct { // 16 字节 _type *_type data unsafe.Pointer }
由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 Go 语言的任意类型都可以转换成 interface{}。
其中runtime._type
是 Go 语言类型的运行时表示。下面是运行时包中的结构体,其中包含了很多类型的元信息,例如:类型的大小、哈希、对齐以及种类等。
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。
不同具体类型并发赋值接口非安全验证如下:
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") } }
运行输出:
unexpected fault address 0x1fffffa8 fatal error: fault [signal 0xc0000005 code=0x0 addr=0x1fffffa8 pc=0x5f5585] ...
把上面的示例代码中协议 1 中的字符串换成一个 int 值,那么并发是安全的。
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") } }
运行输出:
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 Golang with examples [3] The Go Blog.Go Slices: usage and internals [4] CSDN.Golang map 三板斧第三式:实现原理 [5] CSDN.Golang channel 快速入门 [6] Go 语言设计与实现.4.2接口