Go由浅入深(1): 常见数据结构原理

chan

channel 是Golang在语言层面提供的goroutine间的通信方式,比Unix管道更易用也更轻便。channel主要用于进程内各goroutine间通信,如果需要跨进程通信,建议使用分布式系统的方法来解决。

chan数据结构:循环队列+阻塞队列

  • 源代码

    type hchan struct {
        qcount   uint           // 当前队列中剩余元素个数
        dataqsiz uint           // 环形队列长度,即可以存放的元素个数
        buf      unsafe.Pointer // 环形队列指针
        elemsize uint16         // 每个元素的大小,用于在buf中定位元素位置
        closed   uint32            // 标识关闭状态
        elemtype *_type         // 元素类型,用于数据传递过程中的赋值;
        sendx    uint           // 队列下标,指示元素写入时存放到队列中的位置
        recvx    uint           // 队列下标,指示元素从队列的该位置读出
        recvq    waitq          // 等待读消息的goroutine队列
        sendq    waitq          // 等待写消息的goroutine队列
        lock mutex              // 互斥锁,chan不允许并发读写。一个channel同时仅允许被一个goroutine读写,为简单起见
    }
    
  • 循环队列 chan内部实现了一个环形队列作为其缓冲区buf,队列的长度是创建chan时指定的,存储了recvx 和 sendx 两个指针,表示现在已经读到了哪里,接下来往哪里写

  • 等待队列 recvq 和 sendq

    • 从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞,放到sendq中
    • 向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞,放到recvq中。
    • 如果是没有缓冲区的chan,那么两个队列至少有一个为空。
  • 类型信息:一个channel只能传递一种类型的值,类型信息存储在hchan数据结构中。

channel读写

  • 创建channel
    创建channel的过程实际上是初始化hchan结构。其中类型信息和缓冲区长度由make语句传入,buf的大小则与元素大小和缓冲区长度共同决定。

    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
    }
    
  • 向channel写数据

    • 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
    • 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
    • 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;
  • 从channel读数据

    • 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
    • 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
    • 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;
  • 关闭channel
    关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。

单向channel

顾名思义,单向channel指只能用于发送或接收数据,实际上也没有单向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只能用来写。

select

使用select可以监控多channel,比如监控多个channel,当其中某一个channel有数据时,就从其读出数据。理解为会把这个groutine挂在所有channel的读等待队列中

一个简单的示例程序如下:

package main

import (
    "fmt"
    "time"
)

func addNumberToChan(chanName chan int) {
    for {
        chanName <- 1
        time.Sleep(1 * time.Second)
    }
}

func main() {
    var chan1 = make(chan int, 10)
    var chan2 = make(chan int, 10)

    go addNumberToChan(chan1)
    go addNumberToChan(chan2)

    for {
        select {
        case e := <- chan1 :
            fmt.Printf("Get element from chan1: %d\n", e)
        case e := <- chan2 :
            fmt.Printf("Get element from chan2: %d\n", e)
        default:
            fmt.Printf("No element in chan1 and chan2.\n")
            time.Sleep(1 * time.Second)
        }
    }
}

range

通过range可以持续从channel中读出数据,好像在遍历一个数组一样,当channel中没有数据时会阻塞当前goroutine,与读channel时阻塞处理机制一样。

func chanRange(chanName chan int) {
    for e := range chanName {
        fmt.Printf("Get element from chan: %d\n", e)
    }
}

slice

Slice依托数组实现,底层数组对用户屏蔽,在底层数组容量不足时可以实现自动重分配并生成新的Slice。 自动重分配会重新找一片空间,即新老使用的数组不一样
接下来按照实际使用场景分别介绍其实现机制。

Slice数据结构

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

从数据结构看Slice很清晰, array指针指向底层数组,len表示切片长度,cap表示底层数组容量。

  • 使用make创建Slice
    使用make来创建Slice时,可以同时指定长度和容量,创建时底层会分配一个数组,数组的长度即容量。

  • 使用数组创建Slice
    使用数组来创建Slice时,Slice将与原数组共用一部分内存。
    例如,语句slice := array[5:7]所创建的Slice
    根据数组或切片生成新的切片一般使用slice := array[start:end]方式,这种新生成的切片并没有指定切片的容量,实际上新切片的容量是从start开始直至array的结束

  • Slice 扩容
    使用append向Slice追加元素时,如果Slice空间不足,将会触发Slice扩容,扩容实际上是重新分配一块更大的内存,将原Slice数据拷贝进新Slice,然后返回新Slice,扩容后再将数据追加进去。

  • 扩容容量的选择遵循以下规则

    • 如果原Slice容量小于1024,则新Slice容量将扩大为原来的2倍;
    • 如果原Slice容量大于等于1024,则新Slice容量将扩大为原来的1.25倍;
  • 使用append()向Slice添加一个元素的实现步骤如下:

    • 假如Slice容量够用,则将新元素追加进去,Slice.len++,返回原Slice
    • 原Slice容量不够,则将Slice先扩容,扩容后得到新Slice
    • 将新元素追加进新Slice,Slice.len++,返回新的Slice。
  • Slice Copy
    使用copy()内置函数拷贝两个切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片长度的最小值。例如长度为10的切片拷贝到长度为5的切片时,将会拷贝5个元素。也就是说,copy过程中不会发生扩容。

Slice总结

  • 每个切片都指向一个底层数组=
  • 每个切片都保存了当前切片的长度、底层数组可用容量
  • 使用len()计算切片长度时间复杂度为O(1),不需要遍历切片
  • 使用cap()计算切片容量时间复杂度为O(1),不需要遍历切片
  • 通过函数传递切片时,不会拷贝整个切片,因为切片本身只是个结构体而已
  • 使用append()向切片追加元素时有可能触发扩容,扩容后将会生成新的切片

map

Golang的map使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,也即bucket,而每个bucket就保存了map中的一个或一组键值对。

type hmap struct {
    count     int // 当前保存的元素个数
    ...
    B         uint8 //指针数组的size
    ...
    buckets    unsafe.Pointer // bucket数组指针,数组的大小为2^B
    ...
}

下图展示一个拥有4个bucket的map:
在这里插入图片描述
本例中, hmap.B=2, 而hmap.buckets长度是2^B为4. 元素经过哈希运算后会落到某个bucket中进行存储。查找过程类似。 一个k-v表示一个桶

bucket数据结构

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,据此将所有冲突的键连接起来。

下图展示bucket存放8个key-value对:

  • 所以可以简单的理解为GO语言中的map其实是桶+数组(数组大小为8)+链表法解决冲突的结构

哈希冲突

  • 当有两个或以上数量的键被哈希到了同一个bucket时,我们称这些键发生了冲突。Go使用链地址法来解决键冲突。

  • 由于每个bucket可以存放8个键值对,所以同一个bucket存放超过8个键值对时就会再创建一个键值对,用类似链表的方式将bucket连接起来

  • 下图展示产生冲突后的map:
    在这里插入图片描述

  • bucket数据结构指示下一个bucket的指针称为overflow bucket,意为当前bucket盛不下而溢出的部分。事实上哈希冲突并不是好事情,它降低了存取效率,好的哈希算法可以保证哈希值的随机性,但冲突过多也是要控制的,后面会再详细介绍。

负载因子

负载因子用于衡量一个哈希表冲突情况,公式为:

负载因子 = 键数量/bucket数量

例如,对于一个bucket数量为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为1.

哈希表需要将负载因子控制在合适的大小,超过其阀值需要进行rehash,也即键值对重新组织:

  • 哈希因子过小,说明空间利用率低
  • 哈希因子过大,说明冲突严重,存取效率低

每个哈希表的实现对负载因子容忍程度不同,比如Redis实现中负载因子大于1时就会触发rehash,而Go则在在负载因子达到6.5时才会触发rehash,因为Redis的每个bucket只能存1个键值对,而Go的bucket可能存8个键值对,所以Go可以容忍更高的负载因子。

map 中的扩容

渐进式扩容

  • 前提条件: 为了保证访问效率,当新元素将要添加进map时,都会检查是否需要扩容,扩容实际上是以空间换时间的手段。触发扩容的条件有二个:
    • 负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个。
    • overflow数量 > 2^15时,也即overflow数量超过32768时。
  • 增量扩容: 当负载因子过大时,就新建一个bucket,新的bucket长度是原来的2倍,然后旧bucket数据搬迁到新的bucket。
    考虑到如果map存储了数以亿计的key-value,一次性搬迁将会造成比较大的延时,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,每次搬迁2个键值对。
    • 当前map存储了7个键值对,只有1个bucket。此地负载因子为7。再次插入数据时将会触发扩容操作,扩容之后再将新插入键写入新的bucket。
    • 当第8个键值对插入时,将会触发扩容:hmap数据结构中oldbuckets成员指身原bucket,而buckets指向了新申请的bucket。新的键值对被插入新的bucket中。
    • 后续对map的访问操作会触发迁移,将oldbuckets中的键值对逐步的搬迁过来。当oldbuckets中的键值对全部搬迁完毕后,删除oldbuckets。

等量扩容

  • 所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。
  • 在极端场景下,比如不断地增删,而键值对正好集中在一小部分的bucket,这样会造成overflow的bucket数量增多,但负载因子又不高,从而无法执行增量搬迁的情况,可以理解为将稀疏数据压缩到一起

查找过程

查找过程如下:

  • 根据key值算出哈希值
  • 取哈希值低位与hmap.B取模确定bucket位置
  • 取哈希值高位在tophash数组中查询(这个过程是一个遍历的过程
    • 如果tophash[i]中存储值也哈希值相等,则去找到该bucket中的key值进行比较
    • 当前bucket没有找到,则继续从下个overflow的bucket中查找。
  • 如果当前处于搬迁过程,则优先从oldbuckets查找

    注:如果查找不到,也不会返回空值,而是返回相应类型的0值。

插入过程

新元素插入过程如下:

  • 根据key值算出哈希值
  • 取哈希值低位与hmap.B取模确定bucket位置
  • 查找该key是否已经存在,如果存在则直接更新值
  • 如果没找到将key,将key插入

struct

Tag

Go的struct声明允许字段附带 Tag 来对字段做一些标记。

  • 该Tag不仅仅是一个字符串那么简单,因为其主要用于反射场景,reflect包中提供了操作Tag的方法,所以Tag写法也要遵循一定的规则。
  • Tag本身是一个字符串,但字符串中却是:以空格分隔的 key:value 对。
    • key: 必须是非空字符串,字符串不能包含控制字符、空格、引号、冒号。
    • value: 以双引号标记的字符串
      注意:冒号前后不能有空格

如下代码所示,如此写没有实际意义,仅用于说明Tag规则

type Server struct {
    ServerName string `key1: "value1" key11:"value11"`
    ServerIP   string `key2: "value2"`
}

上述代码 ServerName 字段的Tag包含两个key-value对。ServerIP字段的Tag只包含一个key-value对。

反射

Tag只有在反射场景中才有用,而反射包中提供了操作Tag的方法。在说方法前,有必要先了解一下Go是如何管理struct字段的。

  • 以下是reflect包中的类型声明,省略了部分与本文无关的字段。

    // 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其实是结构体字段的一个组成部分。

获取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
    

Tag存在的意义

  • 官方的encoding/json包,可以将一个JSON数据Unmarshal进一个结构体,此过程中就使用了Tag。该包定义一些规则,只要参考该规则设置tag就可以将不同的JSON数据转换成结构体。

Tag常见用法

  • 常见的tag用法,主要是JSON数据解析、ORM映射等。

string

Go标准库builtin给出了所有内置类型的定义。其中关于string的描述如下:

// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

所以string是8比特字节的集合,通常是但并不一定非得是UTF-8编码的文本。

另外,还提到了两点,非常重要:

  • string可以为空(长度为0),但不会是nil;
  • string对象不可以修改。

string 数据结构
源码包src/runtime/string.go:stringStruct定义了string的数据结构:

type stringStruct struct {
    str unsafe.Pointer
    len int
}

其数据结构很简单:

  • stringStruct.str:字符串的首地址;
  • stringStruct.len:字符串的长度;

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

  • byte切片可以很方便的转换成string,如下所示:

    func GetStringBySlice(s []byte) string {
        return string(s)
    }
    

    需要注意的是这种转换需要一次内存拷贝。

  • 转换过程如下:

    • 根据切片的长度申请内存空间,假设内存地址为p,切片长度为len(b);
    • 构建string(string.str = p;string.len = len;)
    • 拷贝数据(切片中数据拷贝到新申请的内存空间)
      转换示意图:
      在这里插入图片描述

string转[]byte

  • string也可以方便的转成byte切片,如下所示:

    func GetSliceByString(str string) []byte {
        return []byte(str)
    }
    
  • string转换成byte切片,也需要一次内存拷贝,其过程如下:

    • 申请切片内存空间
    • 将string拷贝到切片
  • 转换示意图:
    在这里插入图片描述

字符串拼接

  • 字符串可以很方便的拼接,像下面这样:

    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
    }
    

为什么字符串不允许修改?

像C++语言中的string,其本身拥有内存空间,修改string是支持的。但Go的实现中,string不包含内存空间,只有一个内存的指针,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。

  • 因为string通常指向字符串字面量,而字符串字面量存储位置是只读段,而不是堆或栈上,所以才有了string不可修改的约定。

[]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。

string和[]byte如何取舍

string和[]byte都可以表示字符串,但因数据结构不同,其衍生出来的方法也不同,要根据实际应用场景来选择。

string 擅长的场景:

  • 需要字符串比较的场景;
  • 不需要nil字符串的场景;

[]byte擅长的场景:

  • 修改字符串的场景,尤其是修改粒度为1个字节;
  • 函数返回值,需要用nil表示含义的场景;

需要切片操作的场景;

  • 虽然看起来string适用的场景不如[]byte多,但因为string直观,在实际应用中还是大量存在,在偏底层的实现中[]byte使用更多。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值