为什么说map是非线程安全的
golang的map 跟hash map 是一样的,但是go的分配过程又有自己独特的方式。
因为hash map 的内存是按照2的倍数开辟的,当前面开辟的内存不够的时候,会新开辟一段内存,将原来内存的数据转移到新的内存块中,这个过程是没有加锁的,如果这个时候同时有个读的线程过来获取这块内存数据,就会出现安全问题 。
所以多个goroutine同时操作map的时候可能会出现 concurrent map writes 的问题,自己实现一个加好读写锁的map结构,建议直接用golang 的sync.Map。性能好,同时简单易用。
为什么说slice是非线程安全的 根据 golang 中 slice 的数据结构可知,slice 依托数组实现,在底层数组容量充足时,append 操作不是只读操作,会将元素直接加入数组的空闲位置。因此,在多协程 对全局 slice 进行 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 package mainimport ( "sync" "fmt" ) func main () { x := []string {"Start" } wg := sync.WaitGroup{} wg.Add(2 ) go func () { defer wg.Done() y := append (x, "Hello" , "World" ) fmt.Printf("y slice len:%d, cap:%d\n" , len (y), cap (y)) }() go func () { defer wg.Done() z := append (x, "Java" , "Golang" , "React" ) fmt.Printf("z len:%d, cap:%d\n" , len (z), cap (z)) }() wg.Wait() }
在终端执行 go run -race main.go 命令运行程序,发现正常执行,不存在数据竞争。
线程不安全(数据竞争)的例子 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 mainimport ( "fmt" "sync" ) func main () { x := make ([]string , 0 , 6 ) wg := sync.WaitGroup{} wg.Add(2 ) go func () { defer wg.Done() y := append (x, "Hello" , "World" ) fmt.Printf("y slice len:%d, cap:%d, value:%+v\n" , len (y), cap (y), y) }() go func () { defer wg.Done() z := append (x, "Java" , "Go" , "React" ) fmt.Printf("z slice len:%d, cap:%d, value:%+v\n" , len (z), cap (z), z) }() wg.Wait() }
在终端执行 go run -race main.go 命令运行程序,发现提示 WARNING:DATA RACE,存在数据竞争。结果如下:
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 38 39 40 41 42 43 44 45 46 47 48 49 sh-3.2# go run -race main.go y slice len:2, cap:6, value:[Hello World] ================== WARNING: DATA RACE Write at 0x00c0000b4120 by goroutine 8: main.TestAppendNotSafeThread.func2() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:89 +0xd7 Previous write at 0x00c0000b4120 by goroutine 7: main.TestAppendNotSafeThread.func1() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:83 +0xd7 Goroutine 8 (running) created at: main.TestAppendNotSafeThread() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:87 +0x12c main.main() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f Goroutine 7 (finished) created at: main.TestAppendNotSafeThread() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:81 +0xee main.main() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f ================== ================== WARNING: DATA RACE Write at 0x00c0000b4130 by goroutine 8: main.TestAppendNotSafeThread.func2() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:89 +0x145 Previous write at 0x00c0000b4130 by goroutine 7: main.TestAppendNotSafeThread.func1() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:83 +0x12c Goroutine 8 (running) created at: main.TestAppendNotSafeThread() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:87 +0x12c main.main() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f Goroutine 7 (finished) created at: main.TestAppendNotSafeThread() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:81 +0xee main.main() /Users/shaoyu.yang/htdocs/goproj/demo/main.go:9 +0x2f ================== z slice len:3, cap:6, value:[Java Go React] Found 2 data race(s) exit status 66
根因分析 在分析根因之前,我们先来看下 slice 的数据结构
1 2 3 4 5 type slice struct { array unsafe.Pointer len int cap int }
从结构上看 slice 很清晰,array 指针指向底层数组,len 标识切片长度,cap 表示底层数组容量.
例如,slice := make([] int, 4, 8) 语句所创建的 slice 数据结构如下图所示:
了解了 slice 的底层结构,我们看两个例子的不同之处,在于初始化 slice 时的容量。线程安全的例子中,x := [] string{“start”} 的容量为 1,在 append 操作时,会自动分配新的内存空间,故不存在数据竞争关系。如下图:
线程不安全的例子中,x := make([] string, 0, 6)
的容量为 6。这里执行 append 操作时,Go 注意到有空闲空间可以存放 “Hello”, “World” 等新的元素,而另一个协程也注意到有空间可以存放 “Java”, “Go”,“React” 等新的元素,这时两个协程同时试图往同一块空闲空间中写入数据,竞争就出现了。最终谁胜出也就存在不确定性。如下图:
这是 append 的一个特点,而非 bug。当每次调用 append 操作时,不用每次都关注是否需要分配新的内存。优势是,允许用户在循环内追加,而无需破坏垃圾回收。缺点是,开发者必须意识到,当多个 goroutine 中的同一个原始切片被操作时,会存在线程不安全风险 。
解决方案 最简单的解决方法是不使用多个切片操作同一个数组,以防止读写冲突。相反,创建一个具有所需总容量的新切片,并将新切片用作要追加的第一个变量。
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 package mainimport ( "fmt" "sync" ) func main () { x := make ([]string , 0 , 6 ) wg := sync.WaitGroup{} wg.Add(2 ) go func () { defer wg.Done() y := make ([]string , 0 , len (x) + 2 ) y = append (y, x...) y = append (y, "Hello" , "World" , "!" ) fmt.Printf("y slice len:%d, cap:%d, value:%+v\n" , len (y), cap (y), y) }() go func () { defer wg.Done() z := make ([]string , 0 , len (x) + 2 ) z = append (z, x...) z = append (z, "PHP" , "Go" , "Java" ) fmt.Printf("z slice len:%d, cap:%d, value:%+v\n" , len (z), cap (z), z) }() wg.Wait() }
切片扩容基本规则 这里引用《Go 专家编程》里面的基本扩容原则
如果原slice的容量小于1024,则新slie的容量将扩大为原来的2倍
如果原slice的容量大于或等于1024,则新slice的容量将扩大为原来的1.25倍
在该规则的基础上,还会考虑元素类型与内存分配规则,对实际扩张值做一些微调。从这个规则中可以看出 Go 对 slice 的性能和空间使用率的思考。
当切片较小时,采用较大的扩容倍速,可以避免频繁地扩容,从而减少内存分配的
次数和数据拷贝的代价 当切片较大时,采用较小的扩容倍速,主要是为了避免浪费空间。