在这里插入图片描述


我们知道 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
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,所以并发都不是安全的,当然数组并发赋值也是不安全的。

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

数组

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

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

数组的底层数据结构就是其本身,是一个相同类型不同值的顺序排列。所以如果数组位宽不大于 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 型容器,是一个同类型元素的无序组,元素通过另一类型唯一键进行索引。

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 来保证安全。

通道

<-
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,并发赋值时不安全的即可。

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.efaceruntime.iface
runtime.efaceruntime.iface
  • runtime.eface
type eface struct { // 16 字节
	_type *_type
	data  unsafe.Pointer
}
interface{}interface{}
runtime._type
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
  • runtime.iface
type iface struct { // 16 字节
	tab  *itab
	data unsafe.Pointer
}
dataruntime.itab
runtime.itabruntime.itab
type itab struct { // 32 字节
	inter *interfacetype
	_type *_type
	hash  uint32
	_     [4]byte
	fun   [1]uintptr
}
inter_type
_type.hashitab._type

(2)fun 是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以 fun 数组中保存的元素数量是不确定的。

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

接口底层数据结构包含两个字段,相互赋值时如果是相同具体类型不同值并发赋给一个接口,那么只有一个字段 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指令集架构,其他平台未作实际验证。

最后,请一定要记住来自 The Go Memory Model 中的 advice:

Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access.

To serialize access, protect the data with channel operations or other synchronization primitives such as those in the sync and sync/atomic packages.

If you must read the rest of this document to understand the behavior of your program, you are being too clever.

Don’t be clever.

参考文献

[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接口