分类 Go 下的文章

Go中map是一个指针,占用8个字节,指向hmap结构

hmap包含若干个结构为bmap的数组,每个bmap底层都是采用链表结构,bmap通常叫做bucket

hmap结构:

type hmap struct{
    count int //元素数量
  flags uint8 //状态标志
  B uint8 //buckets的对数,如果B=5,则buckets的数量=2^5 = 32
  noverflow uint16 //溢出桶的数量
  hash0  uint32 //hash种子
  buckets unsafe.Pointer //指向buckets数组的指针,数组长度为2^B
  oldbuckets  unsafe.Pointer //如果发生扩容,指向老的buckets数组的指针,
  //oldbuckets数组大小为buckets数组长度的一半,未发生扩容时是null
  nevacuate  uintptr //表示扩容速度,小于此地址的都已经扩容完成
  extra *mapextra //存储溢出桶
}

bmap结构:

type bmap struct {
    tophash [bucketCnt]uint8
  //len为8的数组,用于快速定位key是否在这个bmap中
  //一个桶油8个槽,如果key的高8位在tophash中,则表示该key在这个捅中
}

bmap就是我们所说的桶,一个桶最多存放8个key,这些key之所以存放在一个桶,是因为经过hash计算之后,hash结果的低8位相同,在桶内又会根据hash结果的高8位来决定key到底落入那个位置(共有8个位置)。

bmap结构的定义是一个静态结构,在编译过程中,会扩展成下面的结构:

type bmap struct {
    tophash [8]uint8
  keys  [8]keytype
  values [8]elementtype
  overflow uintptr //指向下一个bmap,溢出捅存放在hmap的extra字段中
}

注意key和value是分开放的,这样的形式,当key和value的类型不一样的时候,key和value占用的字节大小不一样,key/value这种形式可能会因为内存对齐而浪费空间,所以分开放更节省内存空间。

mapextra的结构:

type mapextra struct {
    overflow *[]*bmap //包含的是bmap.buckets的overflow的bucket 
    oldoverflow *[]*bmap//包含的扩容时的是bmap.oldbuckets的overflow的bucket 
    nextoverflow *bmap //指向空闲的overflow bucket的指针
}

当map中的key、value都不含指针时,bmap将完全不包含指针,bmap指向溢出桶的字段overflow是uintptr类型,为了防止overflow被gc掉,所以需要mapextra.overflow将它保存起来。

map遍历

map遍历是无序的,原因有2点:

  • map的遍历时,并不是从0号bucket开始遍历,而是从一个随机序号的bucket,再从其中随机的key开始遍历
  • map在扩容后,可能一部分key已经搬迁到了新的内存,此时key就是无序的了

map是非线程安全的,因为Go官方认为,map的典型应用场景是不需要并发访问的,而不是为了小部分情况,导致大部分程序付出加锁的代价。map可以并发读,但不能并发写,否则会panic。

map的扩容

扩容时机

在向map插入新key的时候,会进行2个条件检查,如果符合就会触发扩容

扩容条件

  1. 超过负载:map元素个数 > 6.5 *桶个数
  2. 溢出桶太多

    1. 当桶总数 < 2 ^ 15时,如果溢出桶总数 > 桶总数,则认为溢出桶太多了
    2. 当桶总数 ≥ 2 ^ 15时,如果溢出桶总数 > 2 ^ 15时,则认为溢出桶太多了

扩容机制

  1. 双倍扩容:针对扩容条件1,新建buckets数组,新的buckets大小为原来的2倍,然后旧的buckets数据搬迁到新的buckets,称之为双倍扩容
  2. 等量扩容:针对扩容条件2,并不扩大容量,buckets数量维持不变,将原来的元素搬迁到一起,将松散的键值对重新排列,使得同一个bucket中的key排列更紧密,节省空间,提高bucket利用率

元素搬迁

并不会在扩容之后立马搬迁,而是在插入或者对key的修改和删除操作时,都会尝试进行搬迁,一次最多搬迁2个bucket

什么叫goroutine泄漏:

如果goroutine数量是在不断增加的,资源长时间无法释放,最终导致资源耗尽,程序崩溃

泄漏原因:

  • goroutine内运行channel、mutex等读写操作被一直阻塞
  • goroutine内业务逻辑陷入死循环,资源一直无法释放
  • goroutine的业务逻辑进入长时间等待,同时又不断有新增的goroutine进入等待

泄漏场景:

  • nil channel

    
    channel忘记初始化,那么无论是读写都会造成阻塞
    
  • 无缓冲channel只发送不接收,会造成阻塞
  • 无缓冲channel只接收不发送,会造成阻塞
  • 有缓冲channel只发送不接收,缓冲队列满了之后会阻塞
  • 发起http请求时,body未被关闭,goroutine不会退出
  • 互斥锁只加锁未解锁,第一个goroutine加锁成功后不进行解锁,其他goroutine都会等待加锁而阻塞
  • sync.WaitGroup add数量大于done的数量

如何排查:pprof监测goroutine数量

在并发编程中,常见的并发调度模型是多进程、多线程,而Go中则实现了协程的并发调度大大提高了并发性能。

协程与进程、线程:

  • 进程:

    • 进程是系统进行资源分配的最小单位,是程序运行的基本单位
    • 每个进程都有独立的内存空间,不同进程之间不共享也不抢占资源
    • 进程由程序、数据集合、进程控制块(PCB)三部分组成
    • 由于进程占用的资源多,上下文切换时开销大,但是因为独享资源,所以安全稳定
  • 线程

    • 线程是CPU调度的最小单位,是进程的运行实体
    • 多个线程共享一个进程的资源,同时也有属于自己的线程级资源
    • 由线程控制块(TCB)进行控制和管理
    • 线程资源比进程少,上下文切换开销更小
  • 协程

    • 是用户态的调度单位,比线程更轻量级
    • 协程由用户程序调度不涉及用户态与内核态的切换,开销更小

什么是上下文切换?

因为CPU是时间片轮转调度的,所以CPU要知道每个任务从哪里加载,从哪里开始运行,也就是CPU寄存器和程序计数器,这就是CPU 上下文,而每个任务都有对应的CPU上下文,切换不同的任务执行的时候,就需要切换CPU 上下文。

Go中的协程:

goroutine是go中实现的用户级并发调度单元,也被称为轻量级线程

线程、goroutine的对比:

线程协程
内存占用线程栈内存消耗为1MB协程栈大小为2KB,但是可以自动扩容
创建和销毁会导致用户态和内核态的切换,解决办法是线程池只需要在用户态执行,创建和销毁的消耗小
切换需要保存各种寄存器只需保存3个寄存器

goroutine的结构:

type g struct {
    goid    int64 // 唯一的goroutine的ID
  sched   gobuf //goroutine切换时,保存g的上下文
    stack   stack //栈
    gopc //调用goroutine的地址
  startpc //调用goroutine的入口
  ...
}

type gobuf struct{
    sp uintptr //栈指针的位置
  pc uintptr //运行到程序位置
  g  guintptr //指向goroutine
  ret uintptr //保存系统调用的返回值
  ...
}

type stack struct {
    lo uintptr //栈的下界地址
  hi uintptr // 栈的上界地址
}

goroutine的状态:

  1. 空闲中:_Gidle【G刚刚创建,仍未初始化】
  2. 待运行:_Grunable 【就绪状态,G在运行队列中,等待M取出来并运行】
  3. 运行中:_Grunning【M在运行G,此时M拥有一个P】
  4. 系统调用中:_Gsyscall【M正在运行的G发起系统调用,此时M不拥有P】
  5. 等待中:_Gwaiting【G被挂起,在等待某些条件完成,此时G不在运行也不在运行队列中(可能在channel的等待队列中)】
  6. 已终止:_Gdead【G未被使用,也可能执行完毕】
  7. 栈复制中:_Gcopystack【G正在获取一个新的栈空间,并把原来的内容复制过去(用于防止GC扫描)】

goroutine状态流转:

943E311D-9879-49BF-B7AD-25581B61D604.png

创建:通过go关键的创建goroutine会新建自己的栈空间,同时在sched中维护栈地址和程序计数器这些信息,G被创建好之后,会优先放入本地队列中,本地队列满了之后,回放在全局队列中

运行:goroutine只是一个数据结构,让goroutine运行起来的是调度器,也就是GMP模型

阻塞:channel的读写操作、等待锁、等待网络数据、系统调用都可能发生阻塞,通过执行runtime.gopark() 让出CPU时间片,让调度器安排其他等待的任务运行

唤醒:处理_Gwating 的G,在调用runtime.goready()之后,会被唤醒,唤醒之后毁被重新放在M对应的P的本地队列中等待执行

退出:G执行完之后,会调用底层函数runtime.Goexit(),当调用该函数之后,goroutine会设置成dead状态

slice的结构:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • array 属性指向一个数组, 占用8个字节,Go中数组是固定长度的
  • len 属性则是当前slice的元素数量,占用8个字节
  • cap 属性是cap的容量,占用8个字节,也就是当前这个切片最大可容纳的元素数量

slice的初始化:

  1. var a []int

    
    使用 `var` 关键字直接申明
    
  2. a := []int{1,2,3}

    
    使用字面量直接`:=` 
    
  3. a := make([]int, 1)

    
    使用make
    
  4. a := b[1:2]

    
    从其他切片中截取
    
    

slice的浅拷贝与深拷贝:

  • 浅拷贝就是只拷贝当前数据的地址,新旧对象指向同一个内存地址
  • 深拷贝就是拷贝数据本身,完全复制一份,新旧对象不共享内存

将slice对象赋给另一个slice,默认是浅拷贝,以下两种方式可以实现slice的深拷贝:

  • copy
  • 遍历元素然后append

slice的扩容发生在append增加元素的时候,有3种机制:

  • 当append之后的cap > 原来的cap的2倍,则新cap直接是append之后的cap
  • 当append之后的cap 不大于 原来的cap的2倍,但是>1024时,将原来的cap按1.25倍扩大,直到新的cap > 原来cap
  • 当append之后的cap 不大于 原来的cap的2倍,且不大于1024时,新的cap为原先cap的2倍

slice是否线程安全的?答案:不是。为什么呢?

因为slice结构未使用锁机制,在多线程并发读写的时候,结果可能是不符合预期的

defer 的实现原理是,将defer函数直接插入到函数末尾,将defer函数在当前函数内展开并直接调用

defer特性:

  1. 延迟特性:defer后的函数不会立即执行,而是延迟到函数结束后执行
  2. 参数预计算:defer后的函数的参数会立即求值,并固定下来,不会等到函数执行的时候再将参数传递到defer中

    比如:

    import "fmt"
    
    func main() {
        a := 1
        defer func(a int) {
            fmt.Println(a)
        }(a +1)
        a = 100
    }

    会在执行到该defer的时候,计算a+1=2,然后把2当作参数固定下来,最后在函数返回前,执行defer函数内的语句,所以输出的是:2

  3. 执行顺序:按先进后出的顺序执行defer函数

    比如:

    import "fmt"
    
    func main() {
        defer func() {
            fmt.Println(111)
        }()
        defer func() {
            fmt.Println(222)
        }()
        defer func() {
            fmt.Println(333)
        }()
        defer func() {
            fmt.Println(444)
        }()
    }

    输出为:

    444
    333
    222
    111
  4. 返回值陷阱:

    return包含了下面几步:将返回值保存在栈上-》执行defer函数-〉函数返回

    比如:

    func deferReturn1() (r int) {
        defer func() {
            g = 200
        }()
        return g
    }
    func deferReturn2() (r int) {
        r = g
        defer func() {
            r = 200
        }()
        return r
    }
    
    func TestDeferReturn(t *testing.T) {
        t.Log(deferReturn1())
        t.Log(deferReturn2())
    }
    
    //输出
    index_test.go:65: 100
    index_test.go:66: 200

    deferReturn1中,先计算了返回值并保存在栈上,此时r=100,再执行defer将g=200,实际返回的r则还是100

    deferReturn2中,先将r=g,计算了返回值保存在栈上,此时r=100,再执行defer将r=200,最终返回的r为200

其他特点:

  1. panic之后的defer不会被执行
  2. panic没有recover时,抛出的panic到当前的grouting最上层函数时,最上层函数直接终止
  3. 当panic被捕获之后,当前gorouting上层函数正常执行
  4. 在panic之前定义的defer,会在panic之前执行

    func TestDefer(t *testing.T) {
        defer func() {
            fmt.Println("c")
        }()
        panic("b")
        fmt.Println("TestDefer")
    }
    
    //输出
    c
    --- FAIL: TestDefer (0.00s)
    panic: b [recovered]
        panic: b