知识体系之Golang

目录

1. string字符串

1.1. 结构

1.2. string和[]byte转换

2. Slice切片

2.1.原理

2.2.slice避坑指南

2.2.1. 母子切片共享

2.2.2. 切片导致内存泄露

2.2.3. 遍历slice时修改slice

3. Map

3.1. 底层结构 

3.2.各种操作底层实现

3.2.1. 创建map

3.2.2. 查找key

3.2.3. 插入/更新key

3.2.4. 删除key

3.2.5. 迭代遍历

3.3. 扩容策略

3.4. 时间/空间复杂度

3.4.1. 时间复杂度

3.4.2.空间复杂度

4.sync.Map

4.1.介绍

4.2. 对比

4.3. 设计思想

4.3.1.空间换时间

4.3.2.读写分离

4.3.3.双检查机制

4.3.4.延迟删除

4.3.5.read优先

4.3.6.状态机机制

4.4.实现原理

4.4.1.读操作

4.4.2.写操作

4.4.3.删除操作

4.4.4.遍历操作

5. for和range几个“神奇”的问题

5.1. 循环永动机

5.2. 神奇的指针

5.3. Map遍历的值是随机的

5.4. arr / slice / map能否比较

6. defer

7. channel

7.1. 底层实现原理(数据结构)

7.2. 案例分析

7.3. 总结

7.3.1.向channel发送数据

7.3.2.从channel接收数据

7.4. 注意事项

7.5. 使用案例

8. 多路Select

8.1.概述

8.2.实现原理

9. 闭包

10. context

10.1. 一个接口

10.2. 四种实现 + 六个函数

10.2.1. emptyCtx

10.2.2. cancelCtx — 可取消的context

10.2.3. timerCtx — 超时取消的context

10.2.4. valueCtx — 支持键值对打包        

11. 内存逃逸

11.1. 逃逸机制

11.2. 内存逃逸场景

11. Go面试题

11.1. new和make的区别

11.2. Golang的内存管理

11.3. 调用函数传入结构体时,应该传值还是指针?为什么?

11.4. Goroutine什么时候会发生阻塞?阻塞的话调度器会怎么做?

11.4.1. 协程阻塞的场景:协程无法释放的场景

11.4.2. 阻塞的话,调度器会怎么做?

11.4.3. 如果Goroutine一直占用资源怎么办,GMP模型怎么处理这个问题?

11.4.4. Goroutine的锁Mutex机制了解过吗?Mutex有哪几种模式?Mutex锁底层如何实现?

11.5. 在GMP模型中Goroutine有几种状态,线程几种状态

11.6. 若干线程中,有个线程OOM会怎么样?Goroutine发生OOM呢?怎么排查呢?

11.7. defer可以捕获到子Goroutine的panic吗?

11.8. 开发用过gin框架么?参数校验怎么做的?中间件middlewares怎么使用的?

11.8.1. 参数校验怎么做的

11.8.2. 中间件middlewares怎么使用的?

11.8.3. gin的route实现原理

11.9. 优雅退出

11.10. 怎么做的链接复用,怎么支持的并发请求的,go的netpoll是怎么实现的?像阻塞read一样去使用底层的非阻塞read

11.11. 父goroutine退出,如何使得子goroutine也退出

11.12. 热重启

11.13. 服务能开多少个m由什么决定?开多少个P有什么界定

12. 重要知识

12.1. Golang中的GC

12.2. GMP模型

12.3. Go内存管理

12.4. 同步原语和锁

12.4.1.互斥锁


1. string字符串

1.1. 结构

type StringHeader struct {
	Data uintptr
	Len  int
}

        字符串是由字符组成的数组,C 语言中的字符串使用字符数组 char[] 表示。数组会占用一片连续的内存空间,而内存空间存储的字节共同组成了字符串,Go 语言中的字符串只是一个只读(只读只意味着字符串会分配到只读的内存空间)的字节数组,下图展示了 "hello" 字符串在内存中的存储方式:

        知识体系之Golang_第1张图片

        编译报错:

                

1.2. string和[]byte转换

        1. 字符串和 []byte 中的内容虽然一样,但是字符串的内容是只读的,我们不能通过下标或者其他形式改变其中的数据,而 []byte 中的内容是可以读写的

        2. 不过无论从哪种类型转换到另一种都需要拷贝数据,而内存拷贝的性能损耗会随着字符串和 []byte 长度的增长而增长

2. Slice切片

2.1.原理

type SliceHeader struct {
	Data uintptr  // 指向数组的指针
	Len  int      // 当前切片的长度  
	Cap  int      // 当前切片的容量
}

扩容策略:在分配内存空间之前需要先确定新的切片容量,运行时根据切片的当前容量选择不同的策略进行扩容

  1. 如果期望容量大于当前容量的两倍就会使用期望容量;
  2. 如果当前切片的长度小于 1024 就会将容量翻倍;
  3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量;
func f1(){
    s:=[]int{1,2,3}  
    f2(s)  
    f3(s)  
    fmt.Println(s)  
}  
func f2(s []int){  // 相当于C++里面传入了*int,指向arr的指针
    s[0]=100  
}  
func f3(s []int){  // 相当于C++传入了vector,不能修改,若修改,要传入vector&
    s=append(s,200)// 此时的s,不是实参的s  
}  
func main() {
    f1()
}
// 以上代码输出
// 100 2 3

2.2.slice避坑指南

2.2.1. 母子切片共享

parent := make([]int, 3, 5) //len=3, cap=5
child := parent[1:3] //len=2, cap=4	
  1. 刚开始,子切片和母切片共享底层的内存空间
  2. 修改子切片会反映到母切片上
  3. 在子切片上执行append会把新元素放到母切片预留的内存空间上;当子切片不断执行append,耗完了母切片预留的内存空间,子切片跟母切片就会发生内存分离,此后两个切片没有任何关系

2.2.2. 切片导致内存泄露

func returnSubSlice() []int {
  parent := make([]int, TOTAL)
  child := parent[begin:end]
  return child
}

        如上代码,假设母切片占用内存8M,child切片是在parent基础上构造的占用内存1M,子切片和母切片共享同一块内存,当函数返回后,母切片已经不在使用,本该进行释放,但是由于和子切片公用一块内存未释放母切片,造成了7M的内存泄漏

        正确做法应该是给子切片开辟空间,然后for循环将需要的数据从母切片拷贝到子切片上,然后返回子切片

2.2.3. 遍历slice时修改slice

sli := []int{1, 2, 3}
//方法一: 修改失败,v是slice元素的拷贝,并不会影响到元素v本身
for _, v := range sli {
	v = v + 1
}
//方法二: 修改成功
for i, v := range sli {
	sli[i] = v + 1
}

3. Map

golang 哪些类型可以作为map key?

先说结论:可以用于比较的字段可以作为key

  1. 可以用于比较的类型:bool、数字、string、point指针、channel、interface接口、struct、array
  2. 不能用于比较的类型:slice、map、func

3.1. 底层结构 

hmap结构

  • go map底层实现方式是hash表,数据结构是hmap,包含 「1个桶数组hmap.buckets + 溢出桶链表」。
  • hmap.buckets 指向桶组成的数组,每个桶元素都是 bmap,bmap存放了 8 个key、 8个 value、1 个溢出桶指针。
  • 当需要分配一个溢出桶时,会优先从预留的溢出桶数组里取一个出来链接到链表后面,这时不需要再次申请内存。但当预留的溢出桶被用完了,则需要申请新的溢出桶。

知识体系之Golang_第2张图片

type hmap struct{
    count int     // 键值对的个数\元素个数,调用 len(map) 时,直接返回此值
    flags uint8   // 状态标记位。如是否被多线程读写、迭代器在使用新桶、迭代器在使用旧桶等
    B     uint8   // buckets 数组的长度就是 2^B,即bmap的个数
    hash0 uint32  // 计算 key 的哈希的时候会传入哈希函数

    noverflow uint32 // 记录已经使用的溢出桶的数量

    bucket     unsafe.Pointer // 指向 buckets 数组,大小为 2^B
    oldbucket  unsafe.Pointer // 扩容的时候,buckets 长度会是 oldbuckets 的两倍
    nevacuate  uintptr        // 记录渐进式扩容阶段下一个要迁移的旧桶编号

    extra      *mapextra  // 指向溢出桶
}

bmap桶结构

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

知识体系之Golang_第3张图片

 extra字段

         hmap结构体最后还有一个extra字段,指向一个mapextra结构体,里面记录的都是溢出桶相关的信息,其中,

  1. nextoverflow指向下一个空闲溢出桶
  2. overflow指向已经使用的溢出桶

                知识体系之Golang_第4张图片

3.2.各种操作底层实现

3.2.1. 创建map

一般情况下,调用makemap()创建hash数组,一次性内存分配,既分配了用户预期大小的hash数组,又追加了预留的溢出桶

3.2.2. 查找key

  1. 将key采用哈希函数计算出 hash_code(共 64 个 bit 位)
  2. 用 hash_code 的低B位,与桶数量相与,定位key所在的桶bmap
  3. 用 hash_doce 的高8位,与桶bmap的tophash[i]对比,相同则进一步对比key的值,
    1. 若匹配到,通过tophash就能找到(key,val)
    2. 若未匹配到,并且溢出桶不为空,还要继续去溢出桶中寻找(直到找到或是所有的 key 槽位都找遍了,包括所有的溢出桶)

知识体系之Golang_第5张图片

3.2.3. 插入/更新key

  1. 先执行3.1.3查找key的步骤
    1. 若查询到,直接更新
    2. 若未查询到,执行插入过程
      1. 判断map是否需要扩容
        1. 如果需要扩容(后面讲解)
        2. 如果不需要扩容
          1. 若在bmap上找到了key插入位置,则直接插入
          2. 若未在bmap上找到key插入位置(说明桶都满了),则需要链接一个新的溢出桶进来

3.2.4. 删除key

  • delete 操作只置删除标志位(emptyOne)且不能被使用,是标记删除,而不是真正的删除,防止被删除的元素再次插入时出现移动

如何清空整个map?

Q1: 下面代码能清空整个map么?

for k, _ := range m {
    delete(m, k)
}
  1. map内容被清空,执行完,调用len函数,结果是0
  2. 内存没释放:清空只是修改了标记,底层内存还是被占用了

Q2: 如何真正的释放内存?

A2: map = nil,之后坐等GC回收就好了

3.2.5. 迭代遍历

结论:迭代遍历过程是随机的

hash 表中数据每次插入的位置是变化的,这是因为实现的原因

  1. 一方面 hash 种子是随机的,这导致相同的数据在不同的 map 变量内的 hash 值不同
  2. 另一方面即使同一个 map 变量内,数据删除再添加的位置也有可能变化,因为在同一个桶及溢出链表中数据的位置不分先后

所以为了防止用户错误的依赖于每次迭代的顺序,map 作者干脆让相同的 map 每次迭代的顺序也是随机的

3.3. 扩容策略

        使用哈希表的目的就是要快速查找到目标 key,然而,随着向 map 中添加的 key 越来越多,key 发生碰撞的概率也越来越大。bucket 中的 8 个 cell 会被逐渐塞满,查找、插入、删除 key 的效率也会越来越低。最理想的情况是一个 bucket 只装一个 key,这样,就能达到 O(1) 的效率,但这样空间消耗太大,用空间换时间的代价太高。

        Go 语言采用一个 bucket 里装载 8 个 key,定位到某个 bucket 后,还需要再定位到具体的 key,这实际上又用了时间换空间。当然,这样做,要有一个度,不然所有的 key 都落在了同一个 bucket 里,直接退化成了链表,各种操作的效率直接降为 O(n),是不行的。因此,需要有一个指标来衡量前面描述的情况,这就是装载因子。Go 源码里这样定义 装载因子:loadFactor := count / (2^B)

case1:负载因子>6.5(有效元素很多),就会发生“翻倍扩容”,分配新桶的数目是旧桶的2倍(真扩容,扩到 hash 桶数量为原来的两倍)

解释:每个 bucket 有 8 个空位,在没有溢出,且所有的桶都装满了的情况下,装载因子算出来的结果是 8。因此当装载因子超过 6.5 时,表明很多 bucket 都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。

“翻倍扩容”:元素太多,而 bucket 数量太少,很简单:将 B 加 1,bucket 最大数量(2^B)直接变成原来 bucket 数量的 2 倍。于是,就有新老 bucket 了。注意,这时候元素都在老 bucket 里,还没迁移到新的 bucket 来。而且,新 bucket 只是最大数量变为原来最大数量(2^B)的 2 倍(2^B * 2)

        buckets指向新桶,oldbuckets指向旧桶,nevacuate=0(表示接下来要迁移编号为0的旧桶),每个旧桶的键值对都会分流到两个新桶中。

        知识体系之Golang_第6张图片

case2:负载因子<=6.5(有效元素很少)溢出桶较多(当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15)会发生“等量扩容”(假扩容,hash 桶数量不变,只是把元素搬迁到新的 map)

解释:删除元素降低元素总数量,再插入很多元素,导致创建很多的溢出桶(溢出桶数量太多,导致 key 会很分散,查找插入效率低得吓人。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难)

“等量扩容”:其实元素没那么多,但是 overflow bucket 数特别多,说明很多 bucket 都没装满。解决办法就是开辟一个新 bucket 空间,将老 bucket 中的元素移动到新 bucket,使得同一个 bucket 中的 key 排列地更紧密。这样,原来,在 overflow bucket 中的 key 可以移动到 bucket 中来。结果是节省空间,提高 bucket 利用率,map 的查找和插入效率自然就会提升。

3.4. 时间/空间复杂度

3.4.1. 时间复杂度

        go map 是 hash 实现,我们先不管具体原理,江湖人人皆知基于 hash 实现的算法其时间复杂度均为 O(1)。

  • 正常情况,且不考虑扩容状态,复杂度O(1):通过hash值定位桶是O(1),一个桶最多8个元素,合理的hash算法应该能把元素相对均匀散列,所以溢出链表(如果有)也不会太长,所以虽然在桶和溢出链表上定位key是遍历,考虑到数量小也可以认为是O(1)。
  • 正常情况,处于扩容状态时,复杂度也是O(1):相比于上一种状态,扩容会增加搬迁最多 2 个桶和溢出链表的时间消耗,当溢出链表不太长时,复杂度也可以认为是 O(1)。
  • 极端情况,散列极不均匀,大部分数据被集中在一条散列链表上,复杂度退化为O(n)。

Go 采用的 hash 算法是很成熟的算法,极端情况暂不考虑。所以综合情况下 Go map 的时间复杂度为 O(1)。

3.4.2.空间复杂度

  • 首先我们不考虑因删除大量元素导致的空间浪费情况,因为删除只是值 key 的标志为 emptyOne,这种情况现在 Go 是留给程序员自己解决,所以这里只考虑一个持续增长状态的 map 的一个空间使用率: 由于溢出桶数量超过 hash 桶数量时会触发假扩容,所以最坏的情况是数据被集中在一条链上,hash表基本是空的,这时空间浪费 O(n)。
  • 最好的情况下,数据均匀散列在 hash 表上,没有元素溢出,这时最好的空间复杂度就是负载因子决定了,当前 Go 的负载因子由全局变量决定,即 loadFactorNum/loadFactorDen = 6.5。即平均每个hash 桶被分配到 6.5 个元素以上时,开始扩容。所以最小的空间浪费是(8-6.5)/8 = 0.1875,即O(0.1875n)

4.sync.Map

总结

  • 采用空间换时间,存在两个map,一个read,一个dirty。所有的操作都优先读取read,当在read中读不到时,会加锁,加锁后还会先再次读取read(double check),如果还读取不到,就会去操作dirty
  • dirty中存储了最新的数据(dirty的数据比read多),read和dirty之间要进行数据同步:当missed_cnt超过阈值时,会将dirty中的数据上升为read

4.1.介绍

  1. sync.Map是并发版本的map,golang自带的map在并发操作下会触发panic
  2. sync.Map支持以下操作
    1. Load:读
    2. Store:写
    3. LoadOrStore
    4. Range:遍历
    5. Delete:删除
  3. 其中,Range支持在多goroutine下运作,能确保每个key最多被处理一次,但是无法保证遍历过程中实时同步其他goroutine的增删操作

4.2. 对比

1. mutex + map
        最简单的方案就是在map上加个锁,针对map的所有操作都要提前加锁,其存在问题也很明显,锁竞争会非常频繁

2. rwmutex + map:优化读读阻塞
        优化一点,依据场景,如果是读操作多于写操作,可以把mutex换成rwmutex,相比方案一,有一定优化、至少读读之间不会存在互斥,不过,读写之间还会存在阻塞

3. sync.Map:优化读写阻塞
        根据锁map的优化迭代方案可知,在读读场景下,rwmutex + map可以并发、不存在阻塞,但是,读写还是存在阻塞,而sync.map要做的事情就是能进一步优化:对于map的各种操作,尽可能不阻塞

        为此,sync.map采用了用空间换时间,即两个map:分别是read、dirty

4.3. 设计思想

4.3.1.空间换时间

        sync.map中冗余的数据结构就是dirty和read,二者存放的都是key-entry,entry其实是一个指针,指向value,read和dirty各自维护一套key,key指向的都是同一个value,也就是说,只要修改了这个entry,对read和dirty都是可见的。

        那空间换时间策略在sync.map中到底是如何体现的呢?到底在哪些地方减少了耗时?

  • 遍历操作:只需遍历read即可,而read是并发读安全的,没有锁,相比于加锁方案,性能大为提升
  • 查找操作:先在read中查找,read中找不到再加锁去dirty中找

        核心思想就是一切操作先去read中执行,因为read是并发读安全的,无需锁,实在在read中找不到,再去dirty中。read在sycn.map中是一种冗余的数据结构,因为read和dirty中数据有很大一部分是重复的,而且二者还会进行数据同步

type Map struct {
	mu Mutex // 锁,保护read和dirty字段
	read atomic.Value // 存仅读数据,原子操作,并发读安全,实际存储readOnly类型的数据
	dirty map[interface{}]*entry // 存最新写入的数据
	misses int // 计数器,每次在read字段中没找所需数据时,+1: 当此值到达一定阈值时,将dirty字段赋值给read
}

// readOnly 存储map中仅读数据的结构体
type readOnly struct {
	m       map[interface{}]*entry // 其底层依然是个最简单的map
	amended bool // 标志位,标识m.dirty中存储的数据是否和m.read中的不一样:flase相同,true不相同
}
  1. read在进行非读操作时,需要锁mutex进行保护
  2. 写入的数据,都是直接写入到dirty,后面根据read miss次数达到阈值,会进行read和dirty数据的同步
  3. readOnly中专门有一个标志位amended,用来标注read和dirty是否一致

4.3.2.读写分离

        sync.map中有专门用于读的数据结构:read,将其和写操作分离开来,可以避免读写冲突。而采用读写分离策略的代价就是冗余的数据结构,其实还是空间换时间的思想。

4.3.3.双检查机制

通过额外的一次检查操作,来避免在第一次检查操作完成后,其他的操作使得检查条件产生突然符合要求的可能。

        在sync.map中,每次当read不符合要求,要去操作dirty前,都会上锁,上锁后先再次double-check判断是否符合要求,因为read有可能在上锁期间,产生了变化,突然又符合要求了,read符合要求了,尽量还是在read中操作,因为read并发读安全。

4.3.4.延迟删除

        在删除操作中,删除kv,仅仅只是先将需要删除的kv打一个标记,这样可以尽快的让delete操作先返回,减少耗时,在后面提升dirty时,再一次性的删除需要删除的kv

4.3.5.read优先

        需要进行读取,删除,更新操作时,优先操作read,因为read无锁的,更快。当在read中得不到结果,再去dirty中

read的修改操作需要加锁,read只是并发读安全,并发写并不安全

4.3.6.状态机机制

entry的指针p,是有状态的,分为3种状态:nil、expunged(指向被删除的元素)、正常

4.4.实现原理

4.4.1.读操作

  1. 先去read中查询是否有key
    1. 未查询到&&read和dirty不一致:!ok && read.amended
      1. Lock
        1. double-check机制:再复查read中查找是否有key(复查是因为存在二级缓存向一级缓存同步数据的情况)
          1. 未查询到&&read和dirty不一致:!ok && read.amended
            1. 去dirty中查找
            2. 不命中次数+1
      2. UnLock
  2. 判断上面的ok情况
    1. false:查询不到,说明key在read和dirty中都不存在,返回nil
    2. true:查询到,返回val
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    // 1.先在read中查找key
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]
    
    // 2.在read中没有找到,并且read和dirty数据不一样(dirty中有read中不存在的数据,因为写数据是直接往dirty中写的)
	if !ok && read.amended {
		m.mu.Lock() // 加锁,因为要操作dirty中的数据
		read, _ = m.read.Load().(readOnly) // 3.双检查机制,再次在read中查找key,因为有可能read从dirty中更新了数据
		e, ok = read.m[key]
        // 4.在read中还是没有找到,并且read和dirty数据仍然不一致
		if !ok && read.amended {
			e, ok = m.dirty[key] // 直接从dirty获取数据
			m.missLocked() // read不命中次数+1,到达阈值后,为了避免read命中率太低,会从dirty中更新read数据

		}
		m.mu.Unlock() // 解锁,后续不再操作dirty数据
	}

    //5.最后仍然没有找到key,说明key在map中确实不存在,返回nil
	if !ok { 
		return nil, false
	}
    // 6.找到key了,返回value
	return e.load() 
}

        通过对源码的分析,可以在宏观上总结一下搜索的流程:先在read中搜,搜不到再去dirty中搜,但是这个太宏观了,有些东西没有讨论到,比如:

  1. 双检查机制
  2. read miss次数达到阈值,刷新read数据

        上面两项操作,其实归根结底都是为了提升搜索的效率,比如read miss的统计和read数据的刷新,都是为了让直接可以在read中找到key,尽可能不去dirty中找,因为read并发读是安全的,性能很高,而去dirty中找,则需要加锁,耗时就增加了

调用Load或LoadOrStore函数时,如果在read中没有找到key,则会将miss值原子增加1,当miss值增加到和dirty长度相等时,会将dirty提升为read,以期望减少 "读 miss"。

// missLocked readmiss次数+1 ,并且判断dirty是否需要晋升(dirty置给read)
func (m *Map) missLocked() {
	m.misses++ // read 没命中次数统计+1
	if m.misses < len(m.dirty) { // 当misses个数 < dirty个数时,不做操作
		return
	}
	// swap操作: dirty置给read,因为read没有命中的次数太多了,原子操作
	m.read.Store(readOnly{m: m.dirty})  // read = dirty --- dirty上升成read
	m.dirty = nil                       // dirty = nil
	m.misses = 0                        // misses个数 = 0
}

4.4.2.写操作

sync.map中添加/修改(key, val)

  1. 查询到,就更新(因为 dirty[key] 和 read[key] 对应的 value 是指针,所以,要更新就一起更新了)
  2. 查询不到,就新增(新增是在dirty中新增,read暂时会没有新增元素,当读取的时,midded_cnt超过阈值时,会将dirty上升为read)
// Store 添加/修改 key-value
func (m *Map) Store(key, value interface{}) {
	// 1. 在read中查找key,找到了,则尝试更新value
	read, _ := m.read.Load().(readOnly)
	if e, ok := read.m[key]; ok && e.tryStore(&value) {
		return
	}
 
	m.mu.Lock() // 操作dirty,锁住先
 
	// 2. 双检查机制,再次在read中查找key
	read, _ = m.read.Load().(readOnly)
 
	// 3. key在read中存在
	if e, ok := read.m[key]; ok { 
		if e.unexpungeLocked() { // 存在 && key被标记为已删除,则将k/v加入dirty中
			m.dirty[key] = e
		}
		e.storeLocked(&value) // 无论key是否为已删除状态,都要更新key的value值
	} else if e, ok := m.dirty[key]; ok {
		// 4. key在dirty中存在,则直接在dirty中更新value值
		e.storeLocked(&value)
	} else {
		// 5. key在read和dirty中都不存在,则走[新增]逻辑
		// read和dirty中数据相同,则从read中刷新dirty的数据(因为dirty为nil,有可能是初始化或dirty之前提升过了),并将amended标识为read和dirty不相同,因为后面即将走新增逻辑
		if !read.amended { 		
			m.dirtyLocked()
			m.read.Store(readOnly{m: read.m, amended: true})
		}
		m.dirty[key] = newEntry(value) // 新增逻辑,直接在dirty中加入kv键值对
	}
	m.mu.Unlock() // 不再操作dirty数据,解锁啦
}

// tryStore 尝试更新value: 采用CAS更新处于未删除状态的元素
func (e *entry) tryStore(i *interface{}) bool {
	for {
        // 取出e.p指针指向的内容
		p := atomic.LoadPointer(&e.p)
        // 如果: e.p是被删除状态 ==> 无法更新
		if p == expunged { 
			return false
		}
        // e.p存在&&不是被删除状态 ==> CAS机制,用i给e.p赋值(赋值成功,返回true)
        // 因为entry是指针,所以read和dirty的内容都会一起被修改
		if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
			return true
		}
	}
}

// unexpungeLocked 判断是否指向expunged,如果指向expunged则修改为指向nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
	// 之所以需要将指向expunged的修改为指向nil ,是因为后续会将k/v加入dirty中,都已经加入dirty中,并且不是未删除状态,当然需要指向nil啦
	// 此value在read中暂时指向nil,但后续会更新value值,这样read中和dirty中都是指向同一个value的 ( Store中第四步,更新value值)
	return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}
 
// storeLocked 更新指向的value值
func (e *entry) storeLocked(i *interface{}) {
	atomic.StorePointer(&e.p, unsafe.Pointer(i))
}
 
// dirtyLocked 刷新dirty数据逻辑,将read中未删除的数据加入到dirty中
func (m *Map) dirtyLocked() {
	// 此函数仅在以下情况会执行: read和dirty相同时,比如初始化或dirty刚提升到read,dirty肯定是nil
 
	// dirty 非nil,则没必要走刷新dirty数据逻辑
	if m.dirty != nil {
		return
	}
 
	read, _ := m.read.Load().(readOnly)
	m.dirty = make(map[interface{}]*entry, len(read.m)) // dirty 申请内存空间
	// 1.遍历read,将read中未删除元素加入dirty中(加入的其实不是真正的底层数据副本,而是指向底层数据的指针)
	for k, e := range read.m {
		if !e.tryExpungeLocked() { // 保证加入dirty中都是read中未删除的元素,read中被删除状态的元素则没必要加入dirty
			m.dirty[k] = e
		}
	}
}

大致总结一下上述流程:

  1. 在read中查找key
    1. 找到了:则通过原子操作,尝试更新value
    2. 未找到
      1. LOCK
      2. 在read中再次查找key
        1. 找到了
          1. 若key被标记为删除:则(key, value) 加入dirty
          2. 更新value
        2. 未找到
          1. 在dirty查询key
            1. 找到了:直接在dirty更新value值
            2. 未找到:(在read和dirty中都不存在,走新增逻辑)
              1. 直接在dirty中新增(key, value)
      3. UNLOCK

4.4.3.删除操作

  • 查询到,就删除(因为 dirty[key] 和 read[key] 对应的 value 是指针,删除操作是将value的指针设置为nil,因此,dirty[key]和read[key]会一起被删除)
// Delete 删除元素
func (m *Map) Delete(key interface{}) {
 
	// 1.先在read中查找key
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]
 
	// 2.在read中没有找到key,并且read和dirty中数据不相同(即dirty中有read中没有的数据,因为插入数据都是直接插入到dirty中的,read还来不及根据dirty数据进行刷新)
	if !ok && read.amended {
		m.mu.Lock() // 操作dirty,锁住先
 
		// 3.双检查机制,继续在read中查找key
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]
 
		// 4. 在read中没有找到key,并且read和dirty中数据不相同,则在dirty中删除key
		if !ok && read.amended {
			delete(m.dirty, key)
		}

		m.mu.Unlock() // 解锁,不再操作dirty
	}
	// 5. 通过key,找到了value,则删除value
	if ok {
		e.delete()
	}
}

// delete 删除value
func (e *entry) delete() (hadValue bool) {
	for {
		p := atomic.LoadPointer(&e.p)  // 原子操作方式加载指针
		if p == nil || p == expunged { // p 指向nil或已删除元素,删除失败
			return false
		}
		// 删除元素:将p指向nil
		if atomic.CompareAndSwapPointer(&e.p, p, nil) {
			return true
		}
	}
}

大致总结一下删除操作的流程:

  1. 在read中查key
    1. 找到了:则直接删除value (修改entry的指针p,改为指向nil,因为是指针,所以在read和dirty中都是可见的)
    2. 未找到
      1. 若read数据和dirty数据有不同,则
        1. LOCK
        2. 去dirty中直接删除key (不管dirty中有无key,都是直接删除,不会返回任何响应),最后也是entry的delete直接删除value
        3. UNLOCK

4.4.4.遍历操作

  • 遍历之前,会先判断read和dirty是否一致,若不一致,会先将dirty上升为read,之后再遍历
// Range 回调方式遍历map
func (m *Map) Range(f func(key, value interface{}) bool) {
 
	read, _ := m.read.Load().(readOnly)
 
	// 1.dirty中有新数据,则提升dirty,然后再遍历
	if read.amended {
		m.mu.Lock() //操作dirty,锁住
		read, _ = m.read.Load().(readOnly)
		if read.amended { // 双检查机制,再次检测dirty中是否有新数据
			read = readOnly{m: m.dirty} // 提升dirty为read,重置dirty和miss计数器
			m.read.Store(read)
			m.dirty = nil
			m.misses = 0
		}
		m.mu.Unlock()
	}
 
	// 到这就代表,read中的数据和dirty中数据是一致的,直接遍历read即可
 
	// 2.回调的方式遍历read
	for k, e := range read.m {
		v, ok := e.load()
		if !ok {
			continue
		}
		if !f(k, v) {
			break
		}
	}
}

5. for和range几个“神奇”的问题

5.1. 循环永动机

func main() {
	arr := []int{1, 2, 3}
	for _, v := range arr { 
		arr = append(arr, v) // 遍历arr的时候,向arr增加元素
	}
	fmt.Println(arr)
}

$ go run main.go
1 2 3 1 2 3

        上述代码的输出意味着循环只遍历了原始切片中的3个元素,我们在遍历切片时追加的元素不会增加循环的执行次数,所以循环最终还是停了下来

        遍历的底层原理:对于所有的 range 循环,Go 语言都会在编译期将原切片或者数组赋值给一个新变量 ha,在赋值的过程中就发生了拷贝,而我们又通过 len 关键字预先获取了切片的长度,所以在循环中追加新的元素也不会改变循环执行的次数,这也就解释了循环永动机一节提到的现象

5.2. 神奇的指针

func main() {
	arr := []int{1, 2, 3}
	newArr := []*int{}
	for _, v := range arr {
		newArr = append(newArr, &v)
	}
	for _, v := range newArr {
		fmt.Println(*v)
	}
}

$ go run main.go
3 3 3

        说明:一些有经验的开发者不经意也会犯这种错误,正确的做法应该是使用 &arr[i] 替代 &v。

        而遇到这种同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝

        因为在循环中获取返回变量的地址都完全相同,所以会发生神奇的指针一节中的现象。因此当我们想要访问数组中元素所在的地址时,不应该直接获取 range 返回的变量地址 &v2,而应该使用 &a[index] 这种形式

5.3. Map遍历的值是随机的

func main() {
	hash := map[string]int{
		"1": 1,
		"2": 2,
		"3": 3,
	}
	for k, v := range hash {
		println(k, v)
	}
}
$ go run main.go
2 2
3 3
1 1

$ go run main.go
1 1
2 2
3 3

         两次运行上述代码可能会得到不同的结果,第一次会打印 2 3 1,第二次会打印 1 2 3,如果我们运行的次数足够多,最后会得到几种不同的遍历顺序。

原理

1. 首先生成一个随机数帮助我们随机选择一个遍历桶的起始位置。Go 团队在设计哈希表的遍历时就不想让使用者依赖固定的遍历顺序,所以引入了随机数保证遍历的随机性。

2. 选择桶

        首先会选出一个绿色的正常桶开始遍历,随后遍历所有黄色的溢出桶,最后依次按照索引顺序遍历哈希表中其他的桶,直到所有的桶都被遍历完成

        map 循环是有序的还是无序的?答:无序的,map 因扩张⽽重新哈希时,各键值项存储位置都可能会发生改变,顺序自然也没法保证了,所以官方避免大家依赖顺序,直接打乱处理。就是 for range map 在开始处理循环逻辑的时候,就做了随机播种

5.4. arr / slice / map能否比较


func fun() {
	/* 数组是否可以比较? 答:可以比较 */
	s1 := [3]int{1, 2, 3}
	s2 := [3]int{1, 2, 3}
	b := (s1 == s2) // 能不能执行成功,如果能结果是什么? 答:可以比较,返回yrue,原因数组的长度固定,可以每个元素比较
	fmt.Print(b)

	/* slice是否可以比较? 答:不可以比较 */
	ss1 := []int{1, 2, 3}
	ss2 := []int{1, 2, 3}
	b = (ss1 == ss2) // 能不能执行成功,如果能结果是什么? 答:不可以比较,原因切片长度不固定,无法比较
	fmt.Print(b)

	/* map是否可以比较? 答:不可以比较 */
	var m1 map[string]int
	m1["one"] = 1 // 会panic,map使用之前必须使用make分配内存
	m2 := make(map[string]int)
	m2["one"] = 2
	b = (m1 == m2) // 能不能执行成功,如果能结果是什么? 答:map不能比较
	fmt.Print(b)

	/* 下面执行结果是一样的么? 答:不一定,因为map遍历是随机选择一个桶,结果是位置的,map的遍历是无需的 */
	m3 := make(map[string]int)
	m3["one"] = 1
	m3["two"] = 2
	m3["three"] = 3
	for key, _ := range m3 {
		fmt.Print(key) // 输出结果是什么
	}
	fmt.Println()
	for key, _ := range m3 {
		fmt.Print(key) // 结果会跟上面一样吗
	}
	fmt.Println()
	for key, _ := range m3 {
		fmt.Print(key) // 结果会跟上面一样吗
	}
}

6. defer

面试题:defer 在什么时机会修改返回值;多个 defer 的顺序

作用:延迟函数,释放资源、收尾工作

1. 调用顺序:后进先出(栈)

2. 执行顺序:defer、return,return value(函数返回值)

func b() (i int) {
	defer func() {
		i++
		fmt.Println("defer2:", i)
	}()
	defer func() {
		i++
		fmt.Println("defer1:", i)
	}()
	return i //或者直接写成return
}
func main() {
	fmt.Println("return:", b())
}

// defer1: 1
// defer2: 2
// return: 2

3. defer底层的数据结构

        1. 每个defer都对应一个_defer实例,多个实例通过指针串联成一个单链表,保存在gotoutine数据结构中

        2. 每次插入_defer实例,均头插;函数结束也是从链表头部去除开始

        知识体系之Golang_第7张图片

7. channel

7.1. 底层实现原理(数据结构)

 知识体系之Golang_第8张图片

环形数组

  1. buf:buf是有缓冲的channel所特有的结构,用来存储缓存数据
  2. sendx、recvx:读写下标

goroutine队列:sudog结构体

  1. sendq:接收(<-channel),等待写消息的 goroutine 队列
  2. recvq:发送(channel <- xxx),等待读消息的 goroutine 队列

互斥锁:lock

7.2. 案例分析

1. 创建一个具有5个缓冲区的channel

2. 协程G1向channel写入1,2,3,4,5,6(保存在buf中),写入1~5时,不会发生阻塞,缓冲区被写满

知识体系之Golang_第9张图片

3. 当写入6时,因为缓冲区满了,所以6无处可放,此时,协程G1就会加入到channel的sendq中。sudog是协程队列中的元素结构体,g保存了协程、elem保存了等待发送的数据6、c保存了阻塞在哪个chan

知识体系之Golang_第10张图片

4. 当开了协程G2去channel读取数据,recvx下标向前移动

        知识体系之Golang_第11张图片

5. 此时,因为缓冲区buf中存在空闲位置了,所以会去唤醒sendq中的发送协程G1,G1会将数据发送给缓冲区buf

        知识体系之Golang_第12张图片

6. 缓冲区buf再次满了,G1继续挂起,保存在recvq中,循环执行上面步骤... ...

7.3. 总结

7.3.1.向channel发送数据

优先级:如果存在消费者协程,则优先将数据拷贝给消费者,然后,把多余的数据写入buf

是否阻塞取决于2个条件:① recvq中是否有消费者 ②buf是否为空

        如果channel的recvq存在接收者goroutine:将数据直接发送给第一个等待的goroutine,唤醒接收的goroutine

        如果channel的recvq不存在接收者goroutine:

                1. 如果循环数组buf未满,那么将会把数据发送到buf的队尾

                2. 如果循环数组buf已满,此时就会走阻塞发送的流程,将当前发送数据的goroutine加入sendq(不让其发送数据),并挂起等待唤醒

7.3.2.从channel接收数据

优先级:如果buf中有数据,则先读取buf中的数据,然后唤醒sendq中的生产者

详细介绍:

        如果channel的sendq存在发送者goroutine

                1. 如果是无缓冲channel,直接从第一个发送者goroutine哪里把数据拷贝给接收变量,唤醒发送的goroutine

                2. 如果是有缓冲channel(已满),将循环数组buf的队首元素拷贝给接收变量,将第一个发送者goroutine的数据拷贝到buf队尾,唤醒发送的goroutine

        如果channel的sendq不存在发送者goroutine

                1. 如果buf非空,将循环数组buf的队首元素拷贝给接收变量

                2. 如果buf为,这个时候就会走阻塞接收的流程,将当前goroutine加入readq,并挂起等待唤醒

7.4. 注意事项

知识体系之Golang_第13张图片

2种异常场景:chan的状态分别为「未初始化、关闭」

3种动作:close、send、recv

func main() {

	// 操作未初始化的chan
	{
		// 「写入」关闭的chan
		{
			var ch chan int
			ch <- 1 // fatal error: all goroutines are asleep - deadlock!
		}

		// 「读取」关闭的chan
		{
			var ch chan int
			v := <-ch // fatal error: all goroutines are asleep - deadlock!
			fmt.Println(v)
		}

		// 「close」关闭的chan: panic
		{
			var ch chan int
			close(ch) // panic: close of nil channel
		}
	}

	// 操作关闭的chan
	{
		// 「读取」关闭的chan
		{
			// 1. 有数据可以正常读
			{
				ch := make(chan int, 3)
				ch <- 1
				ch <- 2
				ch <- 3
				close(ch)

				for c := range ch {
					fmt.Println(c) // 1 2 3
				}
			}

			// 2. 无数据返回零值
			{
				ch := make(chan int, 3)
				close(ch)

				v := <-ch
				fmt.Println(v) // 零值
			}
		}

		// 「写入」关闭的chan: panic
		{
			ch := make(chan int, 3)
			close(ch)

			ch <- 1 // panic: send on closed channel
		}

		// 「close」关闭的chan: panic
		{
			ch := make(chan int, 3)
			close(ch)

			close(ch) // panic: close of closed channel
		}
	}
}

7.5. 使用案例

channel有2种类型:无缓冲、有缓冲

channel有3种模式:写操作模式(单向通道)、读操作模式(单向通道)、读写模式(双向通道)

创建
make(chan <-int) // 写操作模式
make(<-chan int) // 读操作模式
make(chan int)   // 读写操作模式

 唤醒机制

  1. 若多个goroutine都监听同一个channel,那么channel上的数据都可能随机被某一个goroutine取走进行消费
  2. 若多个goroutine监听同一个channel,如果这个channel被关闭,则所有goroutine都能收到退出信号

8. 多路Select

8.1.概述

与Linux内核的select类似,go的select也是监听“与channel有关的多个IO操作”

        select结构组成主要是由case语句和执行的函数组成的,select的case和switch不同,只能处理channel

  1. select 仅支持channel
  2. select 出现读写nil的 channel,该分支会被忽略
  3. 每个case只能处理一个channel,要么读要么写
  4. select 在遇到多个 channel 同时响应时,会随机执行一种情况
  5. 存在default语句,select将不会阻塞

8.2.实现原理

func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)

参数

  • cas0 :scase数组
  • order0为一个两倍cas0数组长度的buffer,保存scase随机序列pollorder、scase中channel地址序列lockorder
  • nncases表示scase数组的长度

返回值

  • selectgo返回所选scase的索引(该索引与其各自的select {recv,send,default}调用的序号位置相匹配)。此外,如果选择的scase是接收操作(recv),则返回是否接收到值。(简单的理解:函数参数传入的是case语句,返回值返回被选中的case语句)

scase结构体

type scase struct {
    c           *hchan         // 是一个channel指针,channel里面有等待队列
    elem        unsafe.Pointer // 读或者写的缓冲区地址
    kind        uint16   //case语句的类型,是default、传值写数据(channel <-) 还是取值读数据(<- channel)
    pc          uintptr  // race pc (for race detector / msan)
    releasetime int64
}

 selectgo执行过程详解

  1. 打乱传入的scase顺序
  2. 锁住scase中的channel
  3. 遍历所有的channel,查看其是否可读/可写
    1. 如果某个channel可读/可写,则解锁所有的channel,并返回对应的channel数据
    2. 假如没有channel可读或者可写,但是有default语句,则同上:返回default语句对应的scase并解锁所有的channel
    3. 假如既没有channel可读或者可写,也没有default语句,则:①将当前运行的groutine阻塞,并加入到当前所有「scase的channel」的等待队列中去 ②然后,解锁所有channel,等待被唤醒
      1. 此时,如果有个channel可读或者可写ready了,则
        1. 唤醒该channel下挂起的groutine去执行
        2. 并再次加锁所有channel,将该groutine从所有channel的等待队列中移除,解锁

9. 闭包

        有人形象的概括闭包就是:函数 + 引用环境 = 闭包,要搞清楚闭包的关键就是分析出返回的函数它引用到哪些变量

说到Go语言的闭包,不得不说说全局变量和局部变量

        全局变量的特点:1.常驻内存  2. 污染全局
        局部变量的特点:1.不常驻内存  2.不污染全局

而Go语言的闭包可以做到:1.可以让变量常驻内存  2.可以让变量不污染全局 ===> 所以闭包主要是为了避免全局变量的滥用

闭包:
        1.闭包是指有权访问另一个函数作用域中的变量的函数==>包括自由变量(在函数外部定义但在函数内被引用)
        2.创建闭包的常见方式就是在一个函数内部创建另一个函数, 内函数可以访问外函数的变量 ==> 即使脱离了捕捉时的上下文,它也能照样执行

注意:
      闭包里作用域返回的局部变量不会被立刻销毁回收,但过度使用闭包可能会占用更多内存,导致性能下降

例子1

// 函数create()的返回值是一个函数func() int
func create() func() int {
	c := 2              // 通常称这个变量为"捕获变量"
	return func() int { // 该函数使用了外部定义的变量c
		return c
	}
}
func main() {
	// 即使create()结束,通过f1和f2依然能够正常调用这个闭包函数,并使用在create()函数内部定义的局部变量c
	f1 := create()
	f2 := create()
	fmt.Println(f1(), f2()) // 2 2
}

例子2

1. addTool是一个函数,返回的数据类型是func(int)int
2. 这个匿名函数就和变量n形成一个整体,构成闭包。大家可以这样理解:
闭包是类,函数是操作,n是字段,函数和它使用的变量构成闭包
3. 当我们反复调用f函数时,因为n是初始化一次,因此每调用一次就进行累计

func addTool() func(int) int {
	var n = 10
	return func(x int) int { // 闭包
		n = n + x
		return n
	}
}
func main() {
	f := addTool()
	fmt.Println(f(1)) // 11
	fmt.Println(f(2)) // 13
	fmt.Println(f(3)) // 16
}

10. context

作用:在不同的协程之间,同步请求特定数据、取消信号、处理请求的截止日期

        知识体系之Golang_第14张图片

context概括为:1个接口,4种实现,6个函数  

        知识体系之Golang_第15张图片

10.1. 一个接口

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}
  1. Deadline — 返回 context.Context 被取消的时间,也就是完成工作的截止日期
  2. Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel
  3. Err — 返回 context.Context  结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值
    1. 如果 context.Context  被取消,会返回 Canceled 错误
    2. 如果 context.Context  超时,会返回 DeadlineExceeded 错误
  4. Value — 从 context.Context  中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;

10.2. 四种实现 + 六个函数

10.2.1. emptyCtx

        emptyCtx对context的实现,只是简单的返回nil、false,实际上什么也没做

        知识体系之Golang_第16张图片

10.2.2. cancelCtx — 可取消的context

type cancelCtx struct {
	Context
	mu       sync.Mutex            
	done     chan struct{}         // 用于获取该context的取消通知
	children map[canceler]struct{} // 用于存储以当前节点为根节点的所有可取消的context
	err      error                 // 存储取消时指定的错误信息
}

cancelCtx中context的实现

// 返回通道
func (c *cancelCtx) Done() <-chan struct{} {
	c.mu.Lock()
	if c.done == nil { // 懒创建
		c.done = make(chan struct{})
	}
	d := c.done  // 返回done
	c.mu.Unlock()
	return d
}

// 返回err
func (c *cancelCtx) Err() error {
	c.mu.Lock()
	err := c.err
	c.mu.Unlock()
	return err
}

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) 

总结:WithCancel的实现(初始化)

  1. 将child加入到parent.children列表中
  2. 创建done(channel类型)
  3. 返回值:cancelCtx、cancel取消函数

作用:将context包装成cancelCtx,并提供一个取消函数cancel,调用它可以cancel对应的context

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

// 初始化:扩散取消(将child加入到parent.children列表中)
func propagateCancel(parent Context, child canceler) {
	done := parent.Done() // 获取parent的channel
	if done == nil {
		return // 父上下文不会触发取消信号
	}

    // 先判断下父上下文是否已经被取消
	select {
	case <-done:
		child.cancel(false, parent.Err()) // 父上下文已经被取消,则立即取消child
		return
	default:
	}

	if p, ok := parentCancelCtx(parent); ok { // 系统定义类型
		p.mu.Lock()
		if p.err != nil { // 发生了错误,直接取消
			child.cancel(false, p.err)
		} else { // 将child加入到children列表中
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else { // 自定义类型
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

// 判断parent是系统自定义类型?自定义类型?
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
	for {
		switch c := parent.(type) {
		case *cancelCtx:
			return c, true
		case *timerCtx:
			return c.cancelCtx, true
		case *valueCtx:
			parent = c.Context
		default:
			return nil, false
		}
	}
}

propagateCancel函数总共与父上下文相关的3种不同的情况:

  1. 当 parent.Done() == nil,也就是 parent 不会触发取消事件时,当前函数会直接返回;
  2. 当 child 的继承链包含可以取消的上下文时,会判断 parent 是否已经触发了取消信号;
    • 如果已经被取消,child 会立刻被取消;
    • 如果没有被取消,child 会被加入 parent 的 children 列表中,等待 parent 释放取消信号;
  3. 当父上下文是:
    • 自定义类型
      • 将child加入到parent.children列表中
    • 开发者自定义的类型
      • 运行一个新的 Goroutine 同时监听 parent.Done() 和 child.Done() 两个 Channel;
      • 在 parent.Done() 关闭时调用 child.cancel 取消子上下文;

cancel

总结:cancel的实现

  1. 关闭done通道,使得监听done通道的select都返回:close(done)
  2. 清除parent.children列表
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return
	}
	c.err = err
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	for child := range c.children {
		child.cancel(false, err)
	}
	c.children = nil
	c.mu.Unlock()

	if removeFromParent {
		removeChild(c.Context, c)
	}
}

func removeChild(parent Context, child canceler) {
	p, ok := parentCancelCtx(parent)
	if !ok {
		return
	}
	p.mu.Lock()
	if p.children != nil {
		delete(p.children, child)
	}
	p.mu.Unlock()
}

案例代码

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done(): // 当调用cancel()函数,该chan可读,该case触发
				fmt.Println("监控退出,停止了...")
				return
			default:
				fmt.Println("goroutine监控中...")
				time.Sleep(2 * time.Second)
			}
		}
	}(ctx)

	time.Sleep(10 * time.Second)
	cancel()
	//为了检测监控过是否停止,如果没有监控输出,就表示停止了
	time.Sleep(5 * time.Second)
}

10.2.3. timerCtx — 超时取消的context

        timerCtx是在cancelCtx基础上,增加了定时器/截止时间功能,这样,①既可以根据需要主动取消,②也可以到达deadline时,通过timer来调用cancelCtx的取消函数

type timerCtx struct {
	*cancelCtx
	timer *time.Timer  // 定时器
	deadline time.Time // 截止时间
}

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

总结:WithTimeout的实现(初始化)

  1. 将child加入到parent.children列表中
  2. 创建done(channel类型)
  3. 创建&启动一个定时器timer
  4. 返回值:cancelCtx、cancel取消函数
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    // 获取截止时间cur,判断截止时间是否已经到达
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 { // 已经过了截止日期
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() { // 创建 & 启动 定时器
			c.cancel(true, DeadlineExceeded) // 当定时器触发时,取消ctx
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
	return c.deadline, true
}

cancel

总结:cancel的实现

  1. 关闭done通道,使得监听done通道的select都返回:close(done)
  2. 清除parent.children列表
  3. 停止&&清除定时器
func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// Remove this timerCtx from its parent cancelCtx's children.
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

代码案例

作用:创建timerCtx对象,指定时间点

func sub(ctx context.Context) {
	select {
	case <-ctx.Done(): // 被上层的defer cancel()取消掉
		fmt.Println("sub cancel")
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	go func(ctx context.Context) {
		sub(ctx)
	}(ctx)
	select {
	case <-ctx.Done(): // 5s到,该读chan触发
		fmt.Println("main cancel")
	}
}

执行结果
sub cancel
main cancel
func sub(ctx context.Context) {
	select {
	case <-ctx.Done(): // 未打印该行(因为上层ctx每调用defer cancel(),导致泄露)
		fmt.Println("sub cancel")
	}
}

func main() {
	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
	// defer cancel()
	go func(ctx context.Context) {
		sub(ctx)
	}(ctx)
	select {
	case <-ctx.Done():
		fmt.Println("main cancel")
	}
}

执行结果
main cancel
func sub(ctx context.Context) {
	select {
	case <-ctx.Done():
		fmt.Println("sub cancel")
	}
}
func test() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel() // 2. 函数return之前,取消ctx
	go func(ctx context.Context) {
		sub(ctx)
	}(ctx)
	select {
	case <-ctx.Done():
		fmt.Println("main cancel")
	case <-time.After(1 * time.Second): // 1. 超时,先执行
		fmt.Println("main timeout cancel")
	}
}
func main() {
	test()
}

10.2.4. valueCtx — 支持键值对打包        

type valueCtx struct {
	Context
	key, val interface{}
}

11. 内存逃逸

概念:从栈上逃逸到堆上的现象称为内存逃逸

11.1. 逃逸机制

        1. 如果函数外部没有引用,则优先放到栈中

        2. 如果函数外部存在引用,则必定放到堆中

        3. 如果栈上放不下,则必定放到堆上

        通过编译参数-gcflag=-m可以查看编译过程中的逃逸分析

11.2. 内存逃逸场景

1. 函数返回值为局部变量的指针,函数虽然退出了,但是因为指针的存在,指向的内存不能随着函数结束而回收,因此只能分配在堆上

2. 栈空间不足

3. 变量大小不确定

        编译期间无法确定slice的长度,这种情况是为了保证内存的安全,编译器也会触发逃逸,在堆上分配内存。

4. 动态类型

        动态类型就是在编译期间不能确定参数的类型,参数的长度也不确定的情况下会发生逃逸,如:空接口interface{}可以表示任意的类型,函数参数为interface{}会发生逃逸

5. 闭包引用对象


11. Go面试题

中高级 Golang 面试

1. 自我介绍
2. 代码效率分析,考察局部性原理
3. 多核CPU场景下,cache如何保持一致、不冲突?答:MESI协议
4. uint类型溢出
5. 介绍rune类型
6. 编程题:3个函数分别打印cat、dog、fish,要求每个函数都要起一个goroutine,按照cat、dog、fish顺序打印在屏幕上100次。
7. 介绍一下channel,无缓冲和有缓冲区别
8. 是否了解channel底层实现,比如实现channel的数据结构是什么?
9. channel是否线程安全?答:是,包含Mutex
10. Mutex是悲观锁还是乐观锁?悲观锁、乐观锁是什么?
11. Mutex几种模式?答:普通模式、饥饿模式
12. Mutex可以做自旋锁吗?
13. 介绍一下RWMutex。
14. 项目中用过的锁?
15. 介绍一下线程安全的共享内存方式
16. 介绍一下goroutine
17. goroutine自旋占用cpu如何解决(go调用、gmp)
18. 介绍linux系统信号
19. goroutine抢占时机(gc 栈扫描)
20. Gc触发时机
21. 是否了解其他gc机制
22. Go内存管理方式
23. Channel分配在栈上还是堆上?哪些对象分配在堆上,哪些对象分配在栈上?

答:Channel 被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以golang 直接将其分配在堆上
24. 介绍一下大对象小对象,为什么小对象多了会造成gc压力?

答:通常小对象过多会导致GC三色法消耗过多的CPU。优化思路是,减少对象分配

25. 项目中遇到的oom情况?
26. 项目中使用go遇到的坑?
27. 工作遇到的难题、有挑战的事情,如何解决?
28. 如何指定指令执行顺序?

11.1. new和make的区别

make:分配内存、只能用于chan、map、slice、返回的是引用类型本身

new:分配内存(内存清零)、可以用于任意类型、返回的是指向类型的指针

// new
	{
		var sum *int
		sum = new(int) // 分配空间
		*sum = 98
		fmt.Println(*sum) // 98
	}
	{
		type Student struct {
			name string
			age  int
		}

		var s *Student
		s = new(Student) // 分配空间
		s.name = "dequan"

		fmt.Println(s) // &{dequan 0}
	}

11.2. Golang的内存管理

11.3. 调用函数传入结构体时,应该传值还是指针?为什么?

效率对比

        1. 在不发生内存逃逸的情况下,传递指针不会发生内存拷贝,效率比传递值更高

        2. 发生内存逃逸,传递指针的效率要比传递值更慢

        发生内存逃逸的本质是因为该变量的作用于被扩大。

是否能修改传入参数的值

        指针可以,值不可以

11.4. Goroutine什么时候会发生阻塞?阻塞的话调度器会怎么做?

11.4.1. 协程阻塞的场景:协程无法释放的场景

        1. 读nil的channel

        2. 协程中出现死循环

        3. 协程中出现死锁

        4. 执行系统调用等待结果返回

        5. 数据操作的IO、等待网络请求的返回

11.4.2. 阻塞的话,调度器会怎么做?

        协程G与线程M绑定,为了不阻塞线程M绑定的本地队列里面的P,需要给队列P找一个新的M绑定:若空闲队列中存在空闲的M;若不存在,则判断”普通线程+自选线程”是否小于GOMAXPROC,如果小于创建新的M接管P;如果大于,将P放入空闲P队列中

11.4.3. 如果Goroutine一直占用资源怎么办,GMP模型怎么处理这个问题?

        答:如果有一个Goroutine一直占用资源的话,GMP模型会从正常模式转为饥饿模式,通过信号协作强制处理在前面的Goroutine去分配使用 ==> 引出Goroutine的Mutex锁机制

11.4.4. Goroutine的锁Mutex机制了解过吗?Mutex有哪几种模式?Mutex锁底层如何实现?

互斥锁的结构体成员有2个,分别是state、sema

1. 加锁和解锁:通过atomic包提供的原子性操作state字段

2. sema是信号量,主要用于等待队列

        知识体系之Golang_第17张图片

 mutex有2种模式,饥饿模式是1.9版本引出的

1. 正常模式

        1. 加锁过程

                一个尝试加锁的Goroutine会先自旋几次,尝试通过原子操作获得锁(假设G获取到锁,那么state=1)

                若几次自旋之后,仍不能获得锁,则通过信号量排队等待;所有的等待着会按照FIFO的顺序在等待队列中排队。(此时G1、G2、G3...获取不到锁,会在信号量的等待队列中)

                知识体系之Golang_第18张图片

        2. 释放锁+抢锁过程

                当锁锁被G释放(state=0)后,第一个等待者被唤醒后,并不会直接拥有锁,而是需要和 后来者(处于自旋状态&&尚未进入等待队列的Goroutine)进行竞争。

                这种情况下,后来者更有优势(获取锁的概率更大),原因是 ①一方面,后来者Goroutine是处于自旋状态,正在CPU上运行,自然比刚唤醒的Goroutine更有优势 ②另一方面,处于自旋状态的Goroutine有很多,而被唤醒的Goroutine只有一个

                知识体系之Golang_第19张图片

                没有抢到锁的Goroutine,会重新被插入到信号量等待队列的头部。

        3. 加锁等待的时间超过1ms

                知识体系之Golang_第20张图片

                当一个Goroutine为了获取锁等待的时间超过了1ms后,会把当前mutex从正常模式切换到饥饿模式

2. 饥饿模式

         在饥饿模式下,

                1. mutex的所有权从执行unlock的Goroutine,直接传递给等待队列头部的Goroutine

                2. 后来者不会自旋,也不会获得锁(即使mutex处于unlock状态),它们会直接排队到等待队列的尾部

        知识体系之Golang_第21张图片

        当一个等待这个获得锁的Goroutine之后,当发生以下2种情况时,将会从饥饿模式切回正常模式:

                ① 获得锁的Goroutine的等待时间<1ms

                ② 获得锁的Goroutine是等待队列中的最后一个等待者,此时等待队列为空

分析总结:

1. 正常模式

        优点:在正常模式下,自旋和排队是同时存在的,Goroutine在尝试加锁时,会自旋,尝试过几次后如果还没有获取到锁,会进入排队状态。这种在排队之前先让大家来抢的模式,能够有更高的吞吐量(因为频繁的挂起、唤醒Goroutine会带来较多的开销)。

        缺点:可能会出现等待队列尾端Goroutine迟迟抢不到锁的情况,即尾端延迟

 2. 饥饿模式

        饥饿模式下,为了解决尾端延迟问题,不再自旋尝试,所有Goroutine都要排队,严格的FIFO

11.5. 在GMP模型中Goroutine有几种状态,线程几种状态

线程状态:2种,即去抢占G的时候,会有一个自旋和非自旋的状态

Goroutine状态:

        

        1. idle:空闲状态,刚刚被分配并且还没有被初始化

        2. runnable:没有执行代码,没有栈的所有权,存储在运行队列中(等待被调度)

        3. running:正在运行。可以执行代码,拥有栈的所有权,被赋予了内核线程M和处理器

        4. syscall:正在执行系统调用。拥有栈的所有权,没有执行用户代码,被赋予了内核线程M,但是不在运行队列上

        5. waiting:运行时被阻塞。没有执行用户代码并且不在运行队列上,但是可能存在于channel的等待队列上

        6. dead:没有被使用,没有执行代码,可能有分配的栈

        7. copystack:栈正在被拷贝,没有执行代码,不在运行队列上

        8. preempted:由于抢占而被阻塞,没有执行用户代码并且不在运行队列,等待被唤醒

        9. scan:GC正在扫描栈空间,没有执行代码,可以与其他状态同时存在

11.6. 若干线程中,有个线程OOM会怎么样?Goroutine发生OOM呢?怎么排查呢?

1. 线程发生OOM,也就是内存溢出,发生OOM的线程会被kill掉,其他线程不会受到影响

2. go中的内存泄露一般都是Goroutine泄露,就是Goroutine没有被关闭或者没有添加超时控制,让Goroutine一直处于阻塞状态,不能被GC

Go发生内存泄露的场景: 在Go中内存泄露分为暂时性内存泄露和永久性内存泄露

1. 暂时性内存泄露

        1. 获取长字符串中一段导致长字符串未释放

        2. 获取长slice中一段导致长slice未释放

        3. 在长slice新建slice导致泄露

解释:string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏

2. 永久性内存泄露

        1. goroutine永久阻塞而导致泄漏

        2. time.Ticker未关闭导致泄漏

        3.不正确使用Finalizer导致泄漏

怎么排查内存泄露?pprof

11.7. defer可以捕获到子Goroutine的panic吗?

        defer只能捕获本层的panic,不能捕获子Goroutine的panic

11.8. 开发用过gin框架么?参数校验怎么做的?中间件middlewares怎么使用的?

11.8.1. 参数校验怎么做的

        gin框架使用http://github.com/go-playground/validator进行参数校验 在 struct 结构体添加 binding tag,然后调用 ShouldBing 方法,下面是一个示例

type SignUpParam struct {
    Age        uint8  `json:"age" binding:"gte=1,lte=130"`
    Name       string `json:"name" binding:"required"`
    Email      string `json:"email" binding:"required,email"`
    Password   string `json:"password" binding:"required"`
    RePassword string `json:"re_password" binding:"required,eqfield=Password"`
}

func main() {
    r := gin.Default()

    r.POST("/signup", func(c *gin.Context) {
        var u SignUpParam
        if err := c.ShouldBind(&u); err != nil {
            c.JSON(http.StatusOK, gin.H{
                "msg": err.Error(),
            })
            return
        }
        // 保存入库等业务逻辑代码...

        c.JSON(http.StatusOK, "success")
    })

    _ = r.Run(":8999")
}

11.8.2. 中间件middlewares怎么使用的?

        中间件middlewares使用use方法,gin的中间件其实就是一个HandlerFunc,只要我们实现一个HandlerFunc,然后作为参数传递进去

func costTime() gin.HandlerFunc {
    return func(c *gin.Context) {
        //请求前获取当前时间
        nowTime := time.Now()
        //请求处理
        c.Next()
        //处理后获取消耗时间
        costTime := time.Since(nowTime)
        url := c.Request.URL.String()
        fmt.Printf("the request URL %s cost %v\n", url, costTime)
    }
}

11.8.3. gin的route实现原理

1. gin 的每种方法(POST, GET ...)都有自己的一颗树,当然这个是根据你注册路由来的,并不是一上来把每种方式都注册一遍

2. 当 gin 收到客户端的请求时, 第一件事就是去路由树里面去匹配对应的 URL,找到相关的路由, 拿到相关的处理函数(找到对应的 handler)

11.9. 优雅退出

- 注册一个信号函数,监听信号,在信号触发时,执行优雅退出的函数gracefullyQuit

package main

import (
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func gracefullyQuit() {
	fmt.Println("执行优雅退出的程序")
}

func main() {
	fmt.Println("main start")

	defer func() {
		fmt.Println("byte")
		gracefullyQuit()
	}()

	// 注册一个信号函数,监听信号,在信号触发时,执行优雅退出的函数gracefullyQuit
	sig := make(chan os.Signal)
	signal.Notify(sig, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP)
	go func() {
		for s := range sig {
			switch s {
			case syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP:
				gracefullyQuit()
				if i, ok := s.(syscall.Signal); ok {
					os.Exit(int(i))
				} else {
					os.Exit(0)
				}
			}
		}
	}()

	time.Sleep(10 * time.Second)
}

11.10. 怎么做的链接复用,怎么支持的并发请求的,go的netpoll是怎么实现的?像阻塞read一样去使用底层的非阻塞read

        答:go的netpoll底层就是对IO多路复用的封装,底层实现其实和libco的协程框架一样,就是一个调度器、触发机制(超时触发/事件触发)等等

        调用read等函数时,实际上会发生协程切换

11.11. 父goroutine退出,如何使得子goroutine也退出

说明:

        1. 父goroutine退出,子goroutine实际上是不会结束的(子goroutine仍然在执行)

        2. goroutine虽然不能强制结束另外一个goroutine,但是它可以通过channel通知另外一个goroutine,让其结束

父goroutine关闭子goroutine

方式1. 通过channel通知

package main

import (
	"fmt"
	"time"
)

func cancelByChannel(quit <-chan time.Time) {
	for {
		select {
		case <-quit:
			fmt.Println("cancel goroutine by channel!")
			return
		default:
			fmt.Println("Im alive")
			time.Sleep(1 * time.Second)
		}
	}
}

func main() {
	quit := time.After(5 * time.Second)
	go cancelByChannel(quit)
	time.Sleep(10 * time.Second)
	fmt.Println("Im done")
}

// Im alive
// Im alive
// Im alive
// Im alive
// Im alive
// cancel goroutine by channel!
// Im done

方式2. 通过cancelCtx

        通过channel通知goroutine退出还有一个更好的方法就是使用context。没错,就是我们在日常开发中接口通用的第一个参数context。它本质还是接收一个channel数据,只是是通过ctx.Done()获取

package main

import (
	"context"
	"fmt"
	"time"
)

func cancelByContext(ctx context.Context) {
	for {
		select {
		case <-ctx.Done(): // Done()是监听cancel()、超时操作
			fmt.Println("cancel goroutine by context!")
			return
		default:
			fmt.Println("Im alive")
			time.Sleep(1 * time.Second)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go cancelByContext(ctx)
	time.Sleep(10 * time.Second)
	cancel()
	time.Sleep(5 * time.Second)
}

11.12. 热重启

1. 功能:保证服务可用性的手段。

2. 过程:它允许服务重启期间,①不中断已经建立的连接,②老服务进程不再接收新连接请求,③新连接请求将在新服务进程中受理,④原服务进程中已经建立的连接,也可以将其设置为读关闭,等待平滑处理完连接上的请求及连接空闲后再退出。

用人话解释,

  • 旧版本为退出之前,需要先启动新版本;
  • 旧版本继续处理完已经接受的请求,并且不再接受新请求;
  • 新版本接受并处理新请求的方式;

3. 优点:通过这种手段,可以保证已经建立连接不中断、连接上的事务可以正常完成、新服务进程也可以正常接收连接、处理连接上的请求

4. 原理:信号+fork

  • 父进程监听重启信号
  • 在收到重启信号后,父进程调用 fork ,同时传递 socket 描述符给子进程
  • 子进程接收并监听父进程传递的 socket 描述符(新的连接都请求到子进程)
  • 在子进程启动成功之后,父进程停止接收新连接,同时等待旧连接处理完成或超时
  • 父进程退出,热重启完成(子进程接替父进程,继续工作)

11.13. 服务能开多少个m由什么决定?开多少个P有什么界定

        1. m和g开多少由内存决定,一个m=2M,一个g=2k

        2. m的个数 > g的个数

        3. p的个数由GOMAXPROC决定,可以设置

12. 重要知识

12.1. Golang中的GC

Golang中的GC回收机制:三色标记与混合写屏障_go混合写屏障

12.2. GMP模型

GMP模型

12.3. Go内存管理

Golang内存管理

12.4. 同步原语和锁

https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/

12.4.1.互斥锁

type Mutex struct {
	state int32    // 互斥锁的状态
	sema  uint32   // 信号量,用作等待队列
}

加锁和解锁是通过「原子操作」实现的(如下图所示)

知识体系之Golang_第22张图片

 1. 在「正常模式」下,一个Goroutine尝试加锁时,若当前 Goroutine 所挂载的 P 下,本地待运行队列为空,则Goroutine先自旋几次(次数

Mutex进入自旋的条件

1.锁已被占用,并且锁不处于饥饿模式
2.积累的自旋次数小于最大自旋次数(active_spin=4)
3.CPU 核数大于 1
4.有空闲的 P
5.当前 Goroutine 所挂载的 P 下,本地待运行队列为空

知识体系之Golang_第23张图片

2. 当锁被释放时,第一个等待者Goroutine被唤醒后,不会直接获取锁,而是先要和「处于自旋阶段的Goroutine」竞争

说明:刚被唤醒的Goroutine竞争不过处于自选状态的Goroutine,导致产生“饥饿”。

分析原因:

  1. 处于自选状态的Goroutine处于运行中状态,更快
  2. 处于自旋状态的Goroutine有多个

知识体系之Golang_第24张图片

当竞争失败后,Goroutine会被重新插入队列的头部

4. 当一个Goroutine加锁等待的时间超过1ms后,当前的mutex会从正常模式切换到饥饿模式

知识体系之Golang_第25张图片

5. 在饥饿模式下,

  1. mutex的所有权从执行UNLOCK的Goroutine,直接传递给等待队列头部的Goroutine
  2. 后来者不会自旋,也不会尝试获取锁,他们会直接插入到队列的尾部排队等待

6. 当一个等待者获取到锁之后,他会在以下2种情况时,将Mutex由饥饿模式切换回正常模式

  1. 该等待者获取锁的等待时间小于1ms 
  2. 该等待者是等待队列中的最后一个(即等待队列中只有一个元素) 

综上所述

  1. 正常模式
    1. 优点:自旋和排队是同时存在的,执行Lock的Goroutine会先自旋,尝试几次后如果还未获取到锁就会进入等待队列。这种在排队之前先让大家先来抢的模式,吞吐量更高。因为频繁的挂起、唤醒Goroutine会带来较多的开销
    2. 缺点:但是,可能会出现队列尾端Goroutine迟迟抢不到锁的情况(尾部延迟)
  2. 饥饿模式
    1. 优点:不再尝试自旋,所有Goroutine都要排队,严格的先来后到,对于防止尾端延迟来讲特别重要

你可能感兴趣的:(Golang读书笔记,知识体系,数据结构,golang,java)