在总结go标准库中的同步原语易错使用场景时,多个原语都提到了nocopy,比如Mutex、WaitGroup等。以Mutex为例:
func main() {
mutex := sync.Mutex{}
mutex.Lock()
logic(mutex)
mutex.Lock() // 此处panic
}
func logic(m sync.Mutex) {
defer m.Unlock()
log.Print("done")
}
在mutex第一次Lock后,对其复制品m做了Unlock操作,再次对mutex的Lock,会报错deadlock。
func main() {
mutex := &sync.Mutex{}
mutex.Lock()
logic(mutex)
mutex.Lock()
mutex.Unlock()
}
func logic(m *sync.Mutex) {
defer m.Unlock()
log.Print("done")
}
通过指针的形式把mutex传到logic就没问题。那就证明指针形式的复制品还是它本身。
Mutex实际是一个struct,有的说法struct的在函数中传值时是值传递,而指针是引用传递。又有的说法是go没有引用传递的概念。
对此提出问题:
- go到底有没有引用传递?为什么传struct和指针的copy会有不同?
- 在哪些地方会发生copy?
- 哪些数据类型的copy方式和struct一样,哪些和指针一样?
数据类型
go的数据类型可分为以下几种:
- 基础类型:int、string、float等
- 复合类型:array、struct
- 引用类型:指针、slice、map、channel、func
- 接口类型:interface
真正意义的引用类型让多个变量共享同一片内存空间,实际上 go做不到:
var a = 1
var b *int
var c *int
b, c = &a, &a
log.Println(b, c) // 0xc00000a0b8 0xc00000a0b8
log.Println(&b, &c) // 0xc00005a020 0xc00005a028
b、c的实际内容虽然是同一个内存地址,但是他们各自又都存放在不同的内存空间。
var a = 1
var b *int
var c *int
b, c = &a, &a
log.Print(*b, *c)
*c = 2
log.Print(*b, *c)
对*c重新赋值的时候,就是先取到c的内容所指向的内存空间,然后对这个空间的数据重新赋值。通过这种方式来实现go的“引用类型”。
所以go没有真正意义的引用类型,在函数传参的过程中也没有引用传递。对于指针的传递也是值传递,但是值的内容是一个内存地址,通过这个地址可以实现类似的引用传递的效果。
copy
copy的时机主要是:
- 函数传值
- 变量赋值
- for循环
- 通过channel传递数据
Mutex的copy案例中,struct和指针的copy是不同的,关于copy,浅copy和深copy有什么区别?
- 深copy后,复制品和原品内存空间是完全不同的。浅copy后,复制品和原品指向同一片内存空间,自然go不会这样。但是上面的描述中,指针的copy就会出现浅copy的效果,为了方便理解,就当成指针的copy就叫浅copy。
- 结论:基础类型、复合类型的copy是深copy,引用类型的copy是浅copy,接口类型要看具体的实现值是什么类型。所以struct和指针copy的效果是有区别的。
for循环中的copy:
funcs := make([]func(), 0)
for i := 0; i < 3; i++ {
funcs = append(funcs, func() {
log.Printf("i=%d", i)
})
}
for _, f := range funcs {
f()
}
- 打印的结果会是3个3
- for循环中的i在 i:=0时,开辟了一片内存空间,每次i++都是修改的这片空间的内容
- func中append的3个闭包函数,最后打印的i,都是同一片内存空间,其内容就是最后一次的值
array和slice:
array := [2]int{1, 2}
for index, value := range array {
// value:0xc00000a0b8 real:0xc00000a0d0
// value:0xc00000a0b8 real:0xc00000a0d8
log.Printf("value:%p real:%p", &value, &(array[index]))
}
slice := []int{1, 2}
for index, value := range slice {
// value:0xc00000a5e8 real:0xc00000a600
// value:0xc00000a5e8 real:0xc00000a608
log.Printf("value:%p real:%p", &value, &(slice[index]))
}
- array和slice的for循环中,value的地址与实际值的地址不同,且一次for中没变过。
- 上面例子中,对funcs遍历得来的f,其实也是个复制品,但是由于func是引用类型,所以最终执行的f,是原来func。
几个数据类型特性扩展
都到这了,就继续探探go中几个数据类型的特点。
string
address := "changsha"
log.Printf("pointer:%p content:%s", &address, address) // pointer:0xc0000220a0 content:changsha
address2 := address
log.Printf("pointer:%p content:%s", &address2, address2) // pointer:0xc0000220c0 content:changsha
address2 = "hunan"
log.Printf("pointer:%p content:%s", &address2, address2) // pointer:0xc0000220c0 content:hunan
- address2 := address后,address2有新内存空间,且内容和address一致。
- 对address2重新赋值后,内存空间不变,内容发生变化
疑问:为什么有说法字符串是不可变的?
str := "wang"
str[0] = 'W'
log.Print(str)
- 第二行对字符串索引为0的字节做重新赋值,编译不通过:cannot assign to str[0] (neither addressable nor a map index expression)
这段代码倒是体现了字符串是不可变的,但是上面的例子为什么能做改变:
go中string的底层结构实际上是一个struct:
type stringStruct struct {
str unsafe.Pointer
len int
}
str是一个指针,指向的结构是一个byte数组,len表示数组的长度,用点手段强行把string转成这种结构观察一下变化:
address := "changsha"
log.Printf("address pointer:%p content:%s inner:%+v", &address, address, *(*stringStruct)(unsafe.Pointer(&address)))
address2 := address
log.Printf("address2 pointer:%p content:%s inner:%+v", &address2, address2, *(*stringStruct)(unsafe.Pointer(&address2)))
address2 = "hunan"
log.Printf("after address pointer:%p content:%s inner:%+v", &address, address, *(*stringStruct)(unsafe.Pointer(&address)))
log.Printf("after address2 pointer:%p content:%s inner:%+v", &address2, address2, *(*stringStruct)(unsafe.Pointer(&address2)))
输出结果如下:
address pointer:0xc00008a040 content:changsha inner:{str:0x9e9f35 len:8}
address2 pointer:0xc00008a090 content:changsha inner:{str:0x9e9f35 len:8}
after address pointer:0xc00008a040 content:changsha inner:{str:0x9e9f35 len:8}
after address2 pointer:0xc00008a090 content:hunan inner:{str:0x9e9b63 len:5}
- 可以发现,真正的不可变是
str指针指向的内容,对于address2来说,它改变了的是它自己的整个str指针,也就是字符串发生变化时,是新创建一个str来替换老的str。
这样设计最直接目的就是:
- 多个string的副本,共享同一个str空间,可节省内存消耗。它不能修改,就不用担心并发修改的竞争问题,也不用担心正在使用的string被它的某一个副本影响到。
slice
slice是基于数组的引用类型,它的底层结构是这样:
type slice struct {
array unsafe.Pointer // 元素指针
len int // 长度
cap int // 容量
}
array是指向数组的指针,整个结构和string一样非常类似,都是基于struct实现,但是为什么字符串是基本类型,slice是引用类型?
- 因为字符串中的str指针指向的内容是不可变的,而array指针指向的内容是可变的,这就导致复制品是有可能影响到原品的。
关于怎么影响的具体细节参考:qcrao91.gitbook.io/go/shu-zu-h…,直接把题扒出来:
func main() {
slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := slice[2:5]
s2 := s1[2:6:7]
s2 = append(s2, 100)
s2 = append(s2, 200)
s1[2] = 20
log.Printf("s1:%v len:%d cap:%d", s1, len(s1), cap(s1))
log.Printf("s2:%v len:%d cap:%d", s2, len(s2), cap(s2))
log.Printf("slice:%v len:%d cap:%d", slice, len(slice), cap(slice))
}
// 结果
s1:[2 3 20] len:3 cap:8
s2:[4 5 6 7 100 200] len:6 cap:10
slice:[0 1 2 3 20 5 6 7 100 9] len:10 cap:10
-
s2的第一次append时,原本还剩下一个容量空间,可直接将100替换掉底层array中对应索引的内容
-
s2的第二次append,需要扩容了,s2丢弃了原有的array,创建了新的array,所以第二次的append不影响s1和slice
- 扩容多少会和原有长度有关系,具体关系比较复杂,感觉不是很重要
-
s1[2] = 20,能影响到slice,但影响不到s2
map
也都知道map的实现是依靠的哈希函数和哈希桶,对于go的map,问题:
- 如何解决哈希冲突?
- 为什么遍历map,key是无序的?
map的底层也是一个结构体,它的实现也比较复杂,参考:qcrao91.gitbook.io/go/shu-zu-h…,这里只聚焦问题:
type hmap struct {
B uint8
// 指向 buckets 数组,大小为 2^B
buckets unsafe.Pointer
// 扩容的时候,buckets 长度会是 oldbuckets 的两倍
oldbuckets unsafe.Pointer
}
buckets是一个指针,指向一个bmap数组,bmap的结构:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
bucket(bmap)是桶,buckets是一堆桶。如果一个bmap只放一个元素,那太浪费内存空间了,所以一个bmap中还有8个槽位。体现在keys个values的长度都是8。分开保存key和value是为了内存优化的问题。
现在对要将某一对key、value存入map
- 先最key求哈希值,以64位为例,比如得到结果:
1001011100001111011011001000111100101010001001011001010101000110 - 当hmap.B的值为5时,取出
10010111000011110110110010001111001010100010010110010101010 | 00110的后5位,00110,转10进制为:6。所以选择6号bucket。总的bucket的数量为5bit为能表示最大十进制值:2^5 = 32。 10010111 | 00001111011011001000111100101010001001011001010101000110前8位,为topbit,10010111十进制为151,那就将151写入topbits中的空位中,其索引i,就是keys、values中存具体数据的索引。
当哈希冲突时,也是先找地方将原始的key和value找槽存起来。查找的时候,发现从keys中找到的key,和待查找的时候不一致,就只能遍历所有的keys去寻找,当然也可能找不到。
通过哈希值的后B位找bucket,意味着有很多key会进入同一个bucket,放不下了怎么办?
-
如果不够了,可以再新建一个bmap,新bmap的地址,存放在老bmap的bmap.overflow指针。发生哈希冲突不得不遍历所有的keys的时候,也需要遍历overflow的。
-
但是当数量太多的时候,加了太多的overflow,整个map的查找性能就从O(1)退化成O(n)。此时需要扩容:
-
调大hmap.B的值,bucktes的数量变多后,需要对老数据的迁移
- 迁移逻辑较复杂,重点是渐进式迁移,所以hmap中还有oldbuckets参数
-
如果触发了扩容,map中的key和value,相应的buckets位置就会发生变化,所以map遍历的时候,key就是无序的。也不能取地址,因为它是地址可变的。
最后再看看map的初始化
m := make(map[string]string)
问题:make和new有什么区别?
-
make和new都是分配内存的函数,其签名为:
func make(t Type, size ...IntegerType) Type func new(Type) *Typemake除了Type之外,还能接收参数,new不可以。另外new返回的一定是指针,make是具体类型。
对于slice,map、channel这种引用类型初始化时使用make,比如slice可以通过make指定len和cap。用new的效果是:
s := new([]int) log.Printf("%d %d", len(s), cap(s))这段编译是会报错的,new返回的是slice的指针,要想操作得:
s := new([]int) log.Printf("%d %d", len(*s), cap(*s)) *s = append(*s, 1) log.Println(*s)反人类,且new的时候,没办法指定len和cap。
而make得到的slice和map是可以直接操作的。
附
整个过程中的完整的问题列表:
- go到底有没有引用传递?为什么传struct和指针的copy会有不同?
- 在哪些地方会发生copy?
- 哪些数据类型的copy方式和struct一样,哪些和指针一样?
- 浅copy和深copy有什么区别?具体在go中的体现是怎样?
- 为什么有说法字符串是不可变的?为什么slice和string的底层结构都是struct,但是一个是值类型,一个是“引用”类型?
- go的map如何解决哈希冲突问题?
- 为什么map遍历时,key是无序的,且对key、value无法取到地址?
- make和new有什么区别?