golang常见数据结构实现原理
chan
1.chan数据结构
src/runtime/chan.go:hchan
定义了channel的数据结构:
type hchan struct { qcount uint // 当前队列中剩余元素个数 dataqsiz uint // 环形队列长度,即可以存放的元素个数 buf unsafe.Pointer // 环形队列指针 elemsize uint16 // 每个元素的大小 closed uint32 // 标识关闭状态 elemtype *_type // 元素类型 sendx uint // 队列下标,指示元素写入时存放到队列中的位置 recvx uint // 队列下标,指示元素从队列的该位置读出 recvq waitq // 等待读消息的goroutine队列 sendq waitq // 等待写消息的goroutine队列 lock mutex // 互斥锁,chan不允许并发读写 }
1.1 环形队列
chan内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。
下图展示了一个可缓存6个元素的channel示意图:
- dataqsiz指示了队列长度为6,即可缓存6个元素;
- buf指向队列的内存,队列中还剩余两个元素;
- qcount表示队列中还有两个元素;
- sendx指示后续写入的数据存储的位置,取值[0, 6);
- recvx指示从该位置读取数据, 取值[0, 6);
1.2 等待队列
从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞。 向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。
被阻塞的goroutine将会挂在channel的等待队列中:
- 因读阻塞的goroutine会被向channel写入数据的goroutine唤醒;
- 因写阻塞的goroutine会被从channel读数据的goroutine唤醒;
下图展示了一个没有缓冲区的channel,有几个goroutine阻塞等待读数据:
注意,一般情况下recvq和sendq至少有一个为空。只有一个例外,那就是同一个goroutine使用select语句向channel一边写数据,一边读数据。
1.3 类型信息
一个channel只能传递一种类型的值,类型信息存储在hchan数据结构中。
- elemtype代表类型,用于数据传递过程中的赋值;
- elemsize代表类型大小,用于在buf中定位元素位置。
1.4 锁
一个channel同时仅允许被一个goroutine读写
2.channel读写
2.1 创建channel
创建channel的过程实际上是初始化hchan结构。其中类型信息和缓冲区长度由make语句传入,buf的大小则与元素大小和缓冲区长度共同决定。
创建channel的伪代码如下所示:
func makechan(t *chantype, size int) *hchan { var c *hchan c = new(hchan) c.buf = malloc(元素类型大小*size) c.elemsize = 元素类型大小 c.elemtype = 元素类型 c.dataqsiz = size return c }
2.2 向channel写数据
向一个channel中写数据简单过程如下:
- 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
- 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
- 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;
简单流程图如下:
2.3 从channel读数据
从一个channel读数据简单过程如下:
- 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
- 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
- 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
- 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;
简单流程图如下:
2.4 关闭channel
关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。
除此之外,panic出现的常见场景还有:
- 关闭值为nil的channel
- 关闭已经被关闭的channel
- 向已经关闭的channel写数据
3.常见用法
3.1 单向channel
我们知道channel可以通过参数传递,所谓单向channel只是对channel的一种使用限制,这跟C语言使用const修饰函数参数为只读是一个道理。
- func readChan(chanName <-chan int): 通过形参限定函数内部只能从channel中读取数据
- func writeChan(chanName chan<- int): 通过形参限定函数内部只能向channel中写入数据
func readChan(chanName <-chan int) { <- chanName } func writeChan(chanName chan<- int) { chanName <- 1 } func main() { var mychan = make(chan int, 10) writeChan(mychan) readChan(mychan) }
mychan是个正常的channel,而readChan()参数限制了传入的channel只能用来读,writeChan()参数限制了传入的channel只能用来写。
3.2 select
通过select可以监控两个channel,任意一个可读时就从其中读出数据。
select的case语句读channel不会阻塞,尽管channel中没有数据。这是由于case语句编译后调用读channel时会明确传入不阻塞的参数,此时读不到数据时不会将当前goroutine加入到等待队列,而是直接返回。
3.3 range
通过range可以持续从channel中读出数据,好像在遍历一个数组一样,当channel中没有数据时会阻塞当前goroutine,与读channel时阻塞处理机制一样。
func chanRange(chanName chan int) { for e := range chanName { fmt.Printf("Get element from chan: %d\n", e) } }
注意:如果向此channel写数据的goroutine退出时,系统检测到这种情况后会panic,否则range将会永久阻塞。
slice
1.slice实现原理
1.1 slice数据结构
源码包中src/runtime/slice.go:slice
定义了Slice的数据结构:
type slice struct { array unsafe.Pointer len int cap int }
从数据结构看Slice很清晰, array指针指向底层数组,len表示切片长度,cap表示底层数组容量。
1.2 使用make创建slice
使用make来创建Slice时,可以同时指定长度和容量,创建时底层会分配一个数组,数组的长度即容量。
1.3 使用数组创建slice
使用数组来创建Slice时,Slice将与原数组共用一部分内存,所以数组和切片操作可能作用于同一块内存
1.4 slice扩容
扩容操作只关心容量,会把原Slice数据拷贝到新Slice,追加数据由append在扩容结束后完成。上图可见,扩容后新的Slice长度仍然是5,但容量由5提升到了10,原Slice的数据也都拷贝到了新Slice指向的数组中。
扩容容量的选择遵循以下规则:
- 如果原Slice容量小于1024,则新Slice容量将扩大为原来的2倍;
- 如果原Slice容量大于等于1024,则新Slice容量将扩大为原来的1.25倍;
使用append()向Slice添加一个元素的实现步骤如下:
- 假如Slice容量够用,则将新元素追加进去,Slice.len++,返回原Slice
- 原Slice容量不够,则将Slice先扩容,扩容后得到新Slice
- 将新元素追加进新Slice,Slice.len++,返回新的Slice。
1.5 slice copy
使用copy()内置函数拷贝两个切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片长度的最小值。
例如长度为10的切片拷贝到长度为5的切片时,将会拷贝5个元素。
也就是说,copy过程中不会发生扩容。
1.6 特殊切片
根据数组或切片生成新的切片一般使用slice := array[start:end]
方式,这种新生成的切片并没有指定切片的容量,实际上新切片的容量是从start开始直至array的结束。
比如下面两个切片,长度和容量都是一致的,使用共同的内存地址:
sliceA := make([]int, 5, 10) sliceB := sliceA[0:5]
根据数组或切片生成切片还有另一种写法,即切片同时也指定容量,即slice[start🔚cap], 其中cap即为新切片的容量,当然容量不能超过原切片实际值,如下所示:
sliceA := make([]int, 5, 10) //length = 5; capacity = 10 sliceB := sliceA[0:5] //length = 5; capacity = 10 sliceC := sliceA[0:5:5] //length = 5; capacity = 5
order[low:high:max]操作意思是对order进行切片,新切片范围是[low, high),新切片容量是max
map
1.map数据结构
Golang的map使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,也即bucket,而每个bucket就保存了map中的一个或一组键值对。
map数据结构由runtime/map.go:hmap
定义:
type hmap struct { count int // 当前保存的元素个数 ... B uint8 ... buckets unsafe.Pointer // bucket数组指针,数组的大小为2^B ... }
下图展示一个拥有4个bucket的map:
本例中, hmap.B=2
, 而hmap.buckets长度是2^B为4. 元素经过哈希运算后会落到某个bucket中进行存储。查找过程类似。
bucket
很多时候被翻译为桶,所谓的哈希桶
实际上就是bucket。
2.bucket数据结构
bucket数据结构由runtime/map.go:bmap
定义:
type bmap struct { tophash [8]uint8 //存储哈希值的高8位 data byte[1] //key value数据:key/key/key/.../value/value/value... overflow *bmap //溢出bucket的地址 }
每个bucket可以存储8个键值对。
- tophash是个长度为8的数组,哈希值相同的键(准确的说是哈希值低位相同的键)存入当前bucket时会将哈希值的高位存储在该数组中,以方便后续匹配。
- data区存放的是key-value数据,存放顺序是key/key/key/…value/value/value,如此存放是为了节省字节对齐带来的空间浪费。
- overflow 指针指向的是下一个bucket,据此将所有冲突的键连接起来。
注意:上述中data和overflow并不是在结构体中显示定义的,而是直接通过指针运算进行访问的。
下图展示bucket存放8个key-value对:
3.哈希冲突
当有两个或以上数量的键被哈希到了同一个bucket时,我们称这些键发生了冲突。Go使用链地址法来解决键冲突。 由于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个键值对,用类似链表的方式将bucket连接起来。
下图展示产生冲突后的map:
4.负载因子
负载因子 = 键数量/bucket数量
例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.
哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:
- 哈希因子过小,说明空间利用率低
- 哈希因子过大,说明冲突严重,存取效率低
每个哈希表的实现对负载因子容忍程度不同,比如Redis实现中负载因子大于1时就会触发rehash,而Go则在在负载因子达到6.5时才会触发rehash,因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,所以Go可以容忍更高的负载因子。
5.渐进式扩容
5.1 扩容的前提条件
- 负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个。
- overflow数量 > 2^15时,也即overflow数量超过32768时。
5.2 增量扩容
当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。 考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。
下图展示了包含一个bucket满载的map(为了描述方便,图中bucket省略了value区域):
当前map存储了7个键值对,只有1个bucket。此地负载因子为7。再次插入数据时将会触发扩容操作,扩容之后再将新插入键写入新的bucket。
当第8个键值对插入时,将会触发扩容,扩容后示意图如下:
hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的bucket中。 后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets。
搬迁完成后的示意图如下:
5.3 等量扩容
buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。 在极端场景下,比如不断地增删,而键值对正好集中在一小部分的bucket,这样会造成overflow的bucket数量增多,但负载因子又不高,从而无法执行增量搬迁的情况,如下图所示:
上图可见,overflow的bucket中大部分是空的,访问效率会很差。此时进行一次等量扩容,即buckets数量不变,经过重新组织后overflow的bucket数量会减少,即节省了空间又会提高访问效率。
6.查找过程
查找过程如下:
- 根据key值算出哈希值
- 取哈希值低位与hmap.B取模确定bucket位置
- 取哈希值高位在tophash数组中查询
- 如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较
- 当前bucket没有找到,则继续从下个overflow的bucket中查找。
- 如果当前处于搬迁过程,则优先从oldbuckets查找
注:如果查找不到,也不会返回空值,而是返回相应类型的0值。
7.插入过程
新元素插入过程如下:
- 根据key值算出哈希值
- 取哈希值低位与hmap.B取模确定bucket位置
- 查找该key是否已经存在,如果存在则直接更新值
- 如果没找到将key,将key插入
struct
1.Tag本质
1.1 数据结构
数据结构如下:
// A StructField describes a single field in a struct. type StructField struct { // Name is the field name. Name string ... Type Type // field type Tag StructTag // field tag string ... } type StructTag string
可见,描述一个结构体成员的结构中包含了StructTag
,而其本身是一个string
。也就是说Tag
其实是结构体字段的一个组成部分。
1.2 获取Tag
StructTag
提供了Get(key string) string
方法来获取Tag
,示例如下:
package main import ( "reflect" "fmt" ) type Server struct { ServerName string `key1:"value1" key11:"value11"` ServerIP string `key2:"value2"` } func main() { s := Server{} st := reflect.TypeOf(s) field1 := st.Field(0) fmt.Printf("key1:%v\n", field1.Tag.Get("key1")) fmt.Printf("key11:%v\n", field1.Tag.Get("key11")) filed2 := st.Field(1) fmt.Printf("key2:%v\n", filed2.Tag.Get("key2")) }
程序输出如下:
key1:value1
key11:value11
key2:value2
iota
很多书上或博客描述的规则是这样的:
- iota在const关键字出现时被重置为0
- const声明块中每新增一行iota值自增1
我曾经也这么理解,看过编译器代码后发现,其实规则只有一条:
- iota代表了const声明块的行索引(下标从0开始)
这样理解更贴近编译器实现逻辑,也更准确。除此之外,const声明还有个特点,即第一个常量必须指定一个表达式,后续的常量如果没有表达式,则继承上面的表达式。
下面再来根据这个规则看下这段代码:
const ( bit0, mask0 = 1 << iota, 1<<iota - 1 //const声明第0行,即iota==0 bit1, mask1 //const声明第1行,即iota==1, 表达式继承上面的语句 _, _ //const声明第2行,即iota==2 bit3, mask3 //const声明第3行,即iota==3 )
- 第0行的表达式展开即
bit0, mask0 = 1 << 0, 1<<0 - 1
,所以bit0 == 1,mask0 == 0; - 第1行没有指定表达式继承第一行,即
bit1, mask1 = 1 << 1, 1<<1 - 1
,所以bit1 == 2,mask1 == 1; - 第2行没有定义常量
- 第3行没有指定表达式继承第一行,即
bit3, mask3 = 1 << 3, 1<<3 - 1
,所以bit0 == 8,mask0 == 7;
string
1.数据结构
源码包src/runtime/string.go:stringStruct
定义了string的数据结构:
type stringStruct struct { str unsafe.Pointer len int }
其数据结构很简单:
- stringStruct.str:字符串的首地址;
- stringStruct.len:字符串的长度;
string数据结构跟切片有些类似,只不过切片还有一个表示容量的成员,事实上string和切片,准确的说是byte切片经常发生转换。这个后面再详细介绍。
2.string操作
如下代码所示,可以声明一个string变量变赋予初值:
var str string str = "Hello World"
字符串构建过程是先根据字符串构建stringStruct,再转换成string。转换的源码如下:
func gostringnocopy(str *byte) string { // 根据字符串地址构建string ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)} // 先构造stringStruct s := *(*string)(unsafe.Pointer(&ss)) // 再将stringStruct转换成string return s }
string在runtime包中就是stringStruct,对外呈现叫做string。
[]byte转string,有内存开销
3.字符串拼接
str := "Str1" + "Str2" + "Str3"
即便有非常多的字符串需要拼接,性能上也有比较好的保证,因为新字符串的内存空间是一次分配完成的,所以性能消耗主要在拷贝数据上。
一个拼接语句的字符串编译时都会被存放到一个切片中,拼接过程需要遍历两次切片,第一次遍历获取总的字符串长度,据此申请内存,第二次遍历会把字符串逐个拷贝过去。
字符串拼接伪代码如下:
func concatstrings(a []string) string { // 字符串拼接 length := 0 // 拼接后总的字符串长度 for _, str := range a { length += len(str) } s, b := rawstring(length) // 生成指定大小的字符串,返回一个string和切片,二者共享内存空间 for _, str := range a { copy(b, str) // string无法修改,只能通过切片修改 b = b[len(str):] } return s }
因为string是无法直接修改的,所以这里使用rawstring()方法初始化一个指定大小的string,同时返回一个切片,二者共享同一块内存空间,后面向切片中拷贝数据,也就间接修改了string。
rawstring()源代码如下:
func rawstring(size int) (s string, b []byte) { // 生成一个新的string,返回的string和切片共享相同的空间 p := mallocgc(uintptr(size), nil, false) stringStructOf(&s).str = p stringStructOf(&s).len = size *(*slice)(unsafe.Pointer(&b)) = slice{p, size, size} return }
4.为什么字符串不允许修改?
像C++语言中的string,其本身拥有内存空间,修改string是支持的。但Go的实现中,string不包含内存空间,只有一个内存的指针,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。
因为string通常指向字符串字面量,而字符串字面量存储位置是只读段,而不是堆或栈上,所以才有了string不可修改的约定。
5.[]byte转换成string一定会拷贝内存吗?
byte切片转换成string的场景很多,为了性能上的考虑,有时候只是临时需要字符串的场景下,byte切片转换成string时并不会拷贝内存,而是直接返回一个string,这个string的指针(string.str)指向切片的内存。
比如,编译器会识别如下临时场景:
- 使用m[string(b)]来查找map(map是string为key,临时把切片b转成string);
- 字符串拼接,如”<” + “string(b)” + “>”;
- 字符串比较:string(b) == “foo”
因为是临时把byte切片转换成string,也就避免了因byte切片同容改成而导致string引用失败的情况,所以此时可以不必拷贝内存新建一个string。
6.string和[]byte如何取舍
string和[]byte都可以表示字符串,但因数据结构不同,其衍生出来的方法也不同,要根据实际应用场景来选择。
string 擅长的场景:
- 需要字符串比较的场景;
- 不需要nil字符串的场景;
[]byte擅长的场景:
- 修改字符串的场景,尤其是修改粒度为1个字节;
- 函数返回值,需要用nil表示含义的场景;
- 需要切片操作的场景;
虽然看起来string适用的场景不如[]byte多,但因为string直观,在实际应用中还是大量存在,在偏底层的实现中[]byte使用更多。