Golang

1. 数组与切片的区别

  1. 切片的底层数据是数组,是对数组的封装,数组是定长的,长度定义好之后,不能再更改。

  2. 底层数组是可以被多个切片同时指向的,因此对一个切片的元素进行操作是有可能影响到其他切片的。

2. 切片的容量是怎样增长的

在 1.18 之前

当原 slice 容量小于 1024 的时候,新 slice 容量变成原来的 2 倍;原 slice 容量超过 1024,新 slice 容量变成原来的 1.25 倍。

在 1.18 之后

当原 slice 容量 (oldcap) 小于 256 的时候,新 slice(newcap) 容量为原来的 2 倍;原 slice 容量超过 256,新 slice 容量 newcap = oldcap+(oldcap+3*256)/4

进行内存对齐之后,新 slice 的容量是要 大于等于 按照前半部分生成的 newcap

3. append 函数

append 函数执行完后,返回的是一个全新的 slice,并且对传入的 slice 并不影响。

4. 向一个 nil 的 slice 添加元素会发生什么?

其实 nil slice 或者 empty slice 都是可以通过调用 append 函数来获得底层数组的扩容。最终都是调用 mallocgc 来向 Go 的内存管理器申请到一块内存,然后再赋给原来的 nil sliceempty slice,然后摇身一变,成为“真正”的 slice 了。

5. 切片作为函数参数

当 slice 作为函数参数时,就是一个普通的结构体。其实很好理解:若直接传 slice,在调用者看来,实参 slice 并不会被函数中的操作改变;若传的是 slice 的指针,在调用者看来,是会被改变原 slice 的

不管传的是 slice 还是 slice 指针,如果改变了 slice 底层数组的数据,会反应到实参 slice 的底层数据。

6. map 的实现原理

Go 语言采用的是哈希查找表,并且使用链表解决哈希冲突。

结构

map 的结构体是 hmap,bmap 就是我们常说的“桶”,桶里面会最多装 8 个 key,这些 key 之所以会落入同一个桶,是因为它们经过哈希计算后,哈希结果是“一类”的。在桶内,又会根据 key 计算出来的 hash 值的高 8 位来决定 key 到底落入桶内的哪个位置(一个桶内最多有 8 个位置)。

当 map 的 key 和 value 都不是指针,并且 size 都小于 128 字节的情况下,会把 bmap 标记为不含指针,这样可以避免 gc 时扫描整个 hmap。

哈希函数

在程序启动时,会检测 cpu 是否支持 aes,如果支持,则使用 aes hash,否则使用 memhash。

key 定位

用最后的 B 个 bit 位,例如 01010,值为 10,也就是 10 号桶。这个操作实际上就是取余操作,但是取余开销太大,所以代码实现上用的位操作代替。

再用哈希值的高 8 位,找到此 key 在 bucket 中的位置,这是在寻找已有的 key。最开始桶内还没有 key,新加入的 key 会找到第一个空位,放入。

buckets 编号就是桶编号,当两个不同的 key 落在同一个桶中,也就是发生了哈希冲突。冲突的解决手段是用链表法:在 bucket 中,从前往后找到第一个空位。这样,在查找某个 key 时,先找到对应的桶,再去遍历 bucket 中的 key。

如果在 bucket 中没找到,并且 overflow 不为空,还要继续去 overflow bucket 中寻找,直到找到或是所有的 key 槽位都找遍了,包括所有的 overflow bucket。

扩容条件

在向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容:

  1. 装载因子超过阈值,源码里定义的阈值是 6.5。

  2. overflow 的 bucket 数量过多:当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B;当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15。

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

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

扩容过程

由于 map 扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了一种称为“渐进式”地方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。

7. slice 和 map 分别作为函数参数时有什么区别?

makemap 和 makeslice 的区别,带来一个不同点:当 map 和 slice 作为函数参数时,在函数参数内部对 map 的操作会影响 map 自身;而对 slice 却不会(之前讲 slice 的文章里有讲过)。

主要原因:一个是指针(*hmap),一个是结构体(slice)。Go 语言中的函数传参都是值传递,在函数内部,参数会被 copy 到本地。*hmap 指针 copy 完之后,仍然指向同一个 map,因此函数内部对 map 的操作会影响实参。而 slice 被 copy 后,会成为一个新的 slice,对它进行的操作不会影响到实参。

8. 如何实现两种 get 操作

Go 语言中读取 map 有两种语法:带 comma 和 不带 comma。当要查询的 key 不在 map 里,带 comma 的用法会返回一个 bool 型变量提示 key 是否在 map 中;而不带 comma 的语句则会返回一个 key 对应 value 类型的零值。如果 value 是 int 型就会返回 0,如果 value 是 string 类型,就会返回空字符串。

源码里,函数命名不拘小节,直接带上后缀 1,2,mapaccess2 函数返回值多了一个 bool 型变量,两者的代码也是完全一样的,只是在返回值后面多加了一个 false 或者 true。

9. key 为什么是无序的

map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。

当我们在遍历 map 时,并不是固定地从 0 号 bucket 开始遍历,每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value 对了。

10. float 类型可以作为 map 的 key 吗

从语法上看,是可以的。Go 语言中只要是可比较的类型都可以作为 key。

除开 slice,map,functions 这几种类型,其他类型都是 OK 的。

当用 float64 作为 key 的时候,先要将其转成 uint64 类型,再插入 key 中。

最后说结论:float 型可以作为 key,但是由于精度的问题,会导致一些诡异的问题,慎用之。

11. 可以边遍历边删除吗

map 并不是一个线程安全的数据结构。同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。

上面说的是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。

一般而言,这可以通过读写锁来解决:sync.RWMutex。

读之前调用 RLock() 函数,读完之后调用 RUnlock() 函数解锁;写之前调用 Lock() 函数,写完之后,调用 Unlock() 解锁。

另外,sync.Map 是线程安全的 map,也可以使用。

12. 可以对 map 的元素取地址吗

无法对 map 的 key 或 value 进行取址。

如果通过其他 hack 的方式,例如 unsafe.Pointer 等获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。

13. 如何比较两个 map 相等

map 深度相等的条件

  1. 都为 nil

  2. 非空、长度相等,指向同一个 map 实体对象

  3. 相应的 key 指向的 value “深度”相等

直接将使用 map1 == map2 是错误的。这种写法只能比较 map 是否为 nil。

因此只能是遍历 map 的每个元素,比较元素是否都是深度相等。

14. map 是线程安全的吗

map 不是线程安全的。

在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于 1),则直接 panic。赋值和删除函数在检测完写标志是复位之后,先将写标志位置位,才会进行之后的操作。

15. Go 语言与鸭子类型的关系

鸭子类型是一种动态语言的风格,在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口,而是由它 " 当前方法和属性的集合 " 决定。Go 作为一种静态语言,通过接口实现了鸭子类型,实际上是 Go 的编译器在其中作了隐匿的转换工作。

16. 值接收者和指针接收者的区别

如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。

17. iface 和 eface 的区别是什么

iface 和 eface 都是 Go 中描述接口的底层结构体,区别在于 iface 描述的接口包含方法,而 eface 则是不包含任何方法的空接口:interface{}。

18. 接口的动态类型和动态值

接口值的零值是指动态类型和动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下,这个接口值就才会被认为 接口值 == nil。

19. 编译器自动检测类型是否实现接口

var _ io.Writer = (*myWriter)(nil)

编译器会由此检查 *myWriter 类型是否实现了 io.Writer 接口。

上述赋值语句会发生隐式地类型转换,在转换的过程中,编译器会检测等号右边的类型是否实现了等号左边接口所规定的函数。

20. 类型转换和断言的区别

类型转换、类型断言本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。

21. channel 底层的数据结构

buf 指向底层循环数组,只有缓冲型的 channel 才有。

sendx,recvx 均指向底层循环数组,表示当前可以发送和接收的元素位置索引值(相对于底层数组)。

sendq,recvq 分别表示被阻塞的 goroutine,这些 goroutine 由于尝试读取 channel 或向 channel 发送数据而被阻塞。

waitq 是 sudog 的一个双向链表,而 sudog 实际上是对 goroutine 的一个封装

lock 用来保证每个读 channel 或写 channel 的操作都是原子的。

从函数原型来看,创建的 chan 是一个指针。所以我们能在函数间直接传递 channel,而不用传递 channel 的指针。

22. 从一个关闭的 channel 仍然能读出数据吗

从一个有缓冲的 channel 里读数据,当 channel 被关闭,依然能读出有效值。只有当返回的 ok 为 false 时,读出的数据才是无效的。

23. 操作 channel 的情况总结

操作nil channelclosed channelnot nil, not closed channel

close

panic

panic

正常关闭

读 <- ch

阻塞

读到对应类型的零值

阻塞或正常读取数据。缓冲型 channel 为空或非缓冲型 channel 没有等待发送者时会阻塞

写 ch <-

阻塞

panic

阻塞或正常写入数据。非缓冲型 channel 没有等待接收者或缓冲型 channel buf 满时会被阻塞

总结一下,发生 panic 的情况有三种:向一个关闭的 channel 进行写操作;关闭一个 nil 的 channel;重复关闭一个 channel。

读、写一个 nil channel 都会被阻塞。

24. 如何优雅地关闭 channel

不要从一个 receiver 侧关闭 channel,也不要在有多个 sender 时,关闭 channel。

解决方案就是增加一个传递关闭信号的 channel,receiver 通过信号 channel 下达关闭数据 channel 指令。senders 监听到关闭信号后,停止发送数据。

和上面的情况不同,这里有 M 个 receiver,由 receiver 直接关闭 stopCh 的话,就会重复关闭一个 channel,导致 panic。因此需要增加一个中间人,M 个 receiver 都向它发送关闭 dataCh 的“请求”,中间人收到第一个请求后,就会直接下达关闭 dataCh 的指令(通过关闭 stopCh,这时就不会发生重复关闭的情况,因为 stopCh 的发送方只有中间人一个)。另外,这里的 N 个 sender 也可以向中间人发送关闭 dataCh 的请求。

25. channel 发送和接收元素的本质是什么

就是说 channel 的发送和接收操作本质上都是 “值的拷贝”

26. channel 在什么情况下会引起资源泄漏

goroutine 操作 channel 后,处于发送或接收阻塞状态,而 channel 处于满或空的状态,一直得不到改变。同时,垃圾回收器也不会回收此类资源,进而导致 gouroutine 会一直处于等待队列中,不见天日。

另外,程序运行过程中,对于一个 channel,如果没有任何 goroutine 引用了,gc 会对其进行回收操作,不会引起内存泄漏。

27. channel 的应用

  • 停止信号

  • 任务定时

  • 解耦生产方和消费方

  • 控制并发数

28. context 是什么

它是 goroutine 的上下文,包含 goroutine 的运行状态、环境、现场等信息。

context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。

29. context 有什么作用

context 用来解决 goroutine 之间退出通知、元数据传递的功能。

  • 传递共享的数据(RequestID)

  • 取消 goroutine

  • 防止 goroutine 泄漏

30. context.Value 的查找过程是怎样的

取值的过程,实际上是一个递归查找的过程

它会顺着链路一直往上找,比较当前节点的 key 是否是要找的 key,如果是,则直接返回 value。否则,一直顺着 context 往前,最终找到根节点(一般是 emptyCtx),直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。

因为查找方向是往上走的,所以,父节点没法获取子节点存储的值,子节点却可以获取父节点的值。

31. 什么情况下需要使用反射

  1. 不能明确接口调用哪个函数,需要根据传入的参数在运行时决定。

  2. 不能明确传入函数的参数类型,需要在运行时处理任意对象。

32. 不推荐使用反射的理由

  • 与反射相关的代码,经常是难以阅读的。在软件工程中,代码可读性也是一个非常重要的指标。

  • Go 语言作为一门静态语言,编码过程中,编译器能提前发现一些类型错误,但是对于反射代码是无能为力的。所以包含反射相关的代码,很可能会运行很久,才会出错,这时候经常是直接 panic,可能会造成严重的后果。

  • 反射对性能影响还是比较大的,比正常代码运行速度慢一到两个数量级。所以,对于一个项目中处于运行效率关键位置的代码,尽量避免使用反射特性。

33. 反射的应用

IDE 中的代码自动补全功能、对象序列化(encoding/json)、fmt 相关函数的实现、ORM(全称是:Object Relational Mapping,对象关系映射)

34. 如何比较两个对象完全相同

DeepEqual 函数的参数是两个 interface,实际上也就是可以输入任意类型,输出 true 或者 flase 表示输入的两个变量是否是“深度”相等。

35. Go 指针和 unsafe.Pointer 有什么区别

限制一:Go 的指针不能进行数学运算。

限制二:不同类型的指针不能相互转换。

限制三:不同类型的指针不能使用 == 或 != 比较。

限制四:不同类型的指针变量不能相互赋值。

unsafe 包提供了 2 点重要的能力:

  • 任何类型的指针和 unsafe.Pointer 可以相互转换。

  • uintptr 类型和 unsafe.Pointer 可以相互转换。

36. 如何利用 unsafe 获取 slice&map 的长度

我们可以通过 unsafe.Pointer 和 uintptr 进行转换,得到 slice 的字段值。

var Len = *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(8)))

// &s => pointer => uintptr => pointer => *int => int

和 slice 不同的是,makemap 函数返回的是 hmap 的指针

我们依然能通过 unsafe.Pointer 和 uintptr 进行转换,得到 hamp 字段的值,只不过,现在 count 变成二级指针了

count := **(**int)(unsafe.Pointer(&mp))

// &mp => pointer => **int => int

37. 如何实现字符串和 byte 切片的零拷贝转换

需要共享底层 Data 和 Len 就可以实现 zero-copy。

func string2bytes(s string) []byte {
    return *(*[]byte)(unsafe.Pointer(&s))
}
func bytes2string(b []byte) string{
    return *(*string)(unsafe.Pointer(&b))
}

原理上是利用指针的强转

38. goroutine 和线程的区别

  • 内存占用 创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。

  • 创建和销毀 Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。而 goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。

  • 切换 当 threads 切换时,需要保存各种寄存器,以便将来恢复,而 goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。Goroutine 的切换约为 200 ns,相当于 2400-3600 条指令。

39. 为什么要 scheduler

Go scheduler 可以说是 Go 运行时的一个最重要的部分了。Runtime 维护所有的 goroutines,并通过 scheduler 来进行调度。Goroutines 和 threads 是独立的,但是 goroutines 要依赖 threads 才能执行。

Go 程序执行的高效和 scheduler 的调度是分不开的。

40. scheduler 底层原理

有三个基础的结构体来实现 goroutines 的调度。g,m,p。

g 代表一个 goroutine,它包含:表示 goroutine 栈的一些字段,指示当前 goroutine 的状态,指示当前运行到的指令地址,也就是 PC 值。

m 表示内核线程,包含正在运行的 goroutine 等字段。

p 代表一个虚拟的 Processor,它维护一个处于 Runnable 状态的 g 队列,m 需要获得 p 才能运行 g。

当然还有一个核心的结构体:sched,它总览全局。

Runtime 起始时会启动一些 G:垃圾回收的 G,执行调度的 G,运行用户代码的 G;并且会创建一个 M 用来开始 G 的运行。随着时间的推移,更多的 G 会被创建出来,更多的 M 也会被创建出来。

当然,在 Go 的早期版本,并没有 p 这个结构体,m 必须从一个全局的队列里获取要运行的 g,因此需要获取一个全局的锁,当并发量大的时候,锁就成了瓶颈。后来在大神 Dmitry Vyokov 的实现里,加上了 p 结构体。每个 p 自己维护一个处于 Runnable 状态的 g 的队列,解决了原来的全局锁问题。

Go 程序启动后,会给每个逻辑核心分配一个 P(Logical Processor);同时,会给每个 P 分配一个 M(Machine,表示内核线程),这些内核线程仍然由 OS scheduler 来调度。

状态解释

Waiting

等待状态,goroutine 在等待某件事的发生。例如等待网络数据、硬盘;调用操作系统 API;等待内存同步访问条件 ready,如 atomic, mutexes

Runnable

就绪状态,只要给 M 我就可以运行

Executing

运行状态。goroutine 在 M 上执行指令,这是我们想要的

41. goroutine 的调度时机

情形说明

使用关键字 go

go 创建一个新的 goroutine,Go scheduler 会考虑调度

GC

由于进行 GC 的 goroutine 也需要在 M 上运行,因此肯定会发生调度。当然,Go scheduler 还会做很多其他的调度,例如调度不涉及堆访问的 goroutine 来运行。GC 不管栈上的内存,只会回收堆上的内存

系统调用

当 goroutine 进行系统调用时,会阻塞 M,所以它会被调度走,同时一个新的 goroutine 会被调度上来

内存同步访问

atomic,mutex,channel 操作等会使 goroutine 阻塞,因此会被调度走。等条件满足后(例如其他 goroutine 解锁了)还会被调度上来继续运行

42. 什么是 M:N 模型

Runtime 会在程序启动的时候,创建 M 个线程(CPU 执行调度的单位),之后创建的 N 个 goroutine 都会依附在这 M 个线程上执行。这就是 M:N 模型

在同一时刻,一个线程上只能跑一个 goroutine。当 goroutine 发生阻塞(例如上篇文章提到的向一个 channel 发送数据,被阻塞)时,runtime 会把当前 goroutine 调度走,让其他 goroutine 来执行。目的就是不让一个线程闲着,榨干 CPU 的每一滴油水。

43. 什么是工作窃取

当一个 P 发现自己的 LRQ 已经没有 G 时,会从其他 P “偷” 一些 G 来运行。看看这是什么精神!自己的工作做完了,为了全局的利益,主动为别人分担。这被称为 Work-stealing

44. GMP

G 取 goroutine 的首字母,主要保存 goroutine 的一些状态信息以及 CPU 的一些寄存器的值,例如 IP 寄存器,以便在轮到本 goroutine 执行时,CPU 知道要从哪一条指令处开始执行。

M 取 machine 的首字母,它代表一个工作线程,或者说系统线程。G 需要调度到 M 上才能运行,M 是真正工作的人。结构体 m 就是我们常说的 M,它保存了 M 自身使用的栈信息、当前正在 M 上执行的 G 信息、与之绑定的 P 信息。当 M 没有工作可做的时候,在它休眠前,会“自旋”地来找工作:检查全局队列,查看 network poller,试图执行 gc 任务,或者“偷”工作。

P 取 processor 的首字母,为 M 的执行提供“上下文”,保存 M 执行 G 时的一些资源,例如本地可运行 G 队列,memeory cache 等。一个 M 只有绑定 P 才能执行 goroutine,当 M 被阻塞时,整个 P 会被传递给其他 M ,或者说整个 P 被接管。

45. 什么是 GC,有什么作用?

当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。

46. 根对象到底是什么?

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  • 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。

  • 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。

  • 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

47. STW 是什么意思?

STW 在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。

在这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码造成的影响(例如延迟)就越大

48. Go 语言 GC(垃圾回收) 的工作原理

最常见的垃圾回收算法有标记清除 (Mark-Sweep) 和引用计数 (Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。

标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  • 标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;

  • 清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表。

标记清除算法的一大问题是在标记期间,需要暂停程序(Stop the world,STW),标记结束之后,用户程序才可以继续执行。为了能够异步执行,减少 STW 的时间,Go 语言采用了三色标记法。

三色标记算法将程序中的对象分成白色、黑色和灰色三类。

  • 白色:不确定对象。

  • 灰色:存活对象,子对象待处理。

  • 黑色:存活对象。

标记开始时,所有对象加入白色集合(这一步需 STW )。首先将根对象标记为灰色,加入灰色集合,垃圾搜集器取出一个灰色对象,将其标记为黑色,并将其指向的对象标记为灰色,加入灰色集合。重复这个过程,直到灰色集合为空为止,标记阶段结束。那么白色对象即可需要清理的对象,而黑色对象均为根可达的对象,不能被清理。

三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。

三色标记法并发执行仍存在一个问题,即在 GC 过程中,对象指针发生了改变。比如下面的例子:

A (黑) -> B (灰) -> C (白) -> D (白)

正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。
A (黑) -> B (灰) -> C (白)

D (白)

为了解决这个问题,Go 使用了内存屏障技术,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,类似于一个钩子。垃圾收集器使用了写屏障(Write Barrier)技术,当对象新增或更新时,会将其着色为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,垃圾收集器也能正确处理了。

一次完整的 GC 分为四个阶段:

  1. 标记准备 (Mark Setup,需 STW),打开写屏障 (Write Barrier)

  2. 使用三色标记法标记(Marking, 并发)

  3. 标记结束 (Mark Termination,需 STW),关闭写屏障。

  4. 清理 (Sweeping, 并发)

49. SingleFlight

一般情况下我们在写一写对外的服务的时候都会有一层 cache 作为缓存,用来减少底层数据库的压力,但是在遇到例如 redis 抖动或者其他情况可能会导致大量的 cache miss 出现。

这时候就可以使用 singleflight 库了,直译过来就是单飞,这个库的主要作用就是将一组相同的请求合并成一个请求,实际上只会去请求一次,然后对所有的请求返回相同的结果。

50. Hertz Handler 中两个上下文的原因

核心原因在于请求上下文(RequestContext)的生命周期无法优雅的按需延长, 最终在各种设计权衡下,我们在路由的处理函数签名中增加一个标准的上下文入参,通过分离出生命周期长短各异的两个上下文的方式,从根本上解决各种因为上下文生命周期不一致导致的异常问题

52. mutex 有几种模式?

正常模式

所有 goroutine 按照 FIFO 的顺序进行锁获取,被唤醒的 goroutine 和新请求锁的 goroutine 同时进行锁获取,通常新请求锁的 goroutine 更容易获取锁 (持续占有 cpu),被唤醒的 goroutine 则不容易获取到锁。公平性:否。

饥饿模式

所有尝试获取锁的 goroutine 进行等待排队,新请求锁的 goroutine 不会进行锁获取 (禁用自旋),而是加入队列尾部等待获取锁。公平性:是。

53. string

底层有一个指针指向 []byte , 还有一个长度

string 并不能被修改

54. sync.map 原理

底层是两个 map,一个 read map,一个 dirty map,一开始读 read map,没有数据则加锁穿透去读 dirty map,并且读 dirty map 会记录一个计数,计数满的时候 read map 用 dirty map 进行覆盖

55. gRPC 有几种通信方式

gRPC 有四种通信方式

  • 简单 RPC:客户端发送一个请求给服务端,服务端返回一个响应给客户端,就像一次普通的函数调用。

  • 服务端流式 RPC:客户端发送一个请求给服务端,服务端返回一个数据流给客户端,适用于服务端返回的数据量比较大的情况。

  • 客户端流式 RPC:客户端发送一个数据流给服务端,服务端返回一个响应给客户端,适用于客户端发送的数据量比较大的情况。

  • 双向流式 RPC:双方都可以发送一个数据流到对方,适用于需要在长时间内保持连接并交换大量数据的场景。

56. new 和 make 的区别

make 和 new 都可以用来分配内存,但是它们的使用场景不同。make 只能用来分配及初始化类型为 slice、map、chan 的数据;而 new 可以分配任意类型的数据。new 分配返回的是指针,即类型“*Type”;而 make 返回引用,即 Type。new 分配的空间会被清零;make 分配空间后,会进行初始化。

57. 如何终止一个运行中的协程

在 Go 中,可以使用 context 包来取消正在运行的 goroutine。context 包提供了一种在 goroutine 之间传递请求作用域的方法,包括取消信号。

58. slice 深度拷贝

在 Golang 中,slice 是一个引用类型,它的底层实现是一个结构体,包含了指向底层数组的指针、长度和容量。当你对一个 slice 进行拷贝时,你只是拷贝了这个结构体,而不是底层数组。因此,如果你修改了一个 slice 的元素,那么原始的 slice 和拷贝的 slice 都会受到影响。

如果你想要深度拷贝一个 slice,可以使用内置的 copy 函数。例如,如果你有两个 slice:sliceA 和 sliceB,你可以使用以下代码将 sliceB 深度拷贝到 sliceA:

copy(sliceA, sliceB)

这将创建一个新的底层数组,并将其复制到 sliceA 中。这样,如果你修改了 sliceA 中的元素,sliceB 不会受到影响。

59. 协程创建子协程,如果子协程发生 panic,协程会 panic 吗

在 Go 语言中,如果一个协程发生 panic,它会导致整个程序退出。如果在多协程并发环境下,一个协程发生 panic,其他协程不会因为这个协程的 panic 而挂掉,每个协程只能捕获到自己的 panic,不能捕获其他协程的 panic。因此,如果子协程发生 panic,父协程不会 panic。

60. defer 的执行顺序

在 Go 语言中,defer 语句会在当前函数返回之前执行 defer 注册的函数。defer 语句的执行顺序是后进先出,也就是说,先被 defer 的语句最后执行。如果有多个 defer 语句,它们会以 LIFO(后进先出)的顺序执行。

61. = 和 := 的区别?

=是赋值变量,:=是定义变量。

62. 如何判断一个变量在栈还是在堆

在 Go 语言中,编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析 (escape analysis),当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆。

如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢?

go build -gcflags '-m -m -l' xxx.go.

关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。

63. Tag 的应用场景

在 Go 语言中,标签(tag)是一个结构体字段的元信息,可以在运行时通过反射读取。标签可以是任何字符串,但通常用于存储结构体字段的元数据,例如验证规则、ORM 映射等。标签的长度不受规格的限制。

除此之外,Go 语言中的 tags 还可以通过 go build -tags 实现编译控制,例如:项目中有如下文件代表不同的运行环境,通过 tag 控制不同环境下要编译的文件。

  • json 序列化或反序列化时字段的名称

  • db: sqlx 模块中对应的数据库字段名

  • form: gin 框架中对应的前端的数据字段名

  • binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding 为 required 代表没找到返回错误给前端

64. 怎么样输出一个有序的 map

  • 创建一个 slice 来存储 map 中的 key。

  • 遍历 map 并将 key 添加到 slice 中。

  • 对 slice 进行排序。

  • 遍历排序后的 slice 并输出 map 中的值。

65. Gin 框架的路由是如何实现的

gin 框架的路由是基于 httprouter 实现的,采用类似字典树一样的数据结构来存储路由与 handle 方法的映射。这也是框架高性能的原因之一。

66. Go 内存模型

Go 语言内存模型是基于 happens-before 关系的,它定义了在并发编程中,对共享变量进行读写时,不同的 goroutine 之间会产生什么样的同步和可见性保证。happens-before 关系是指在一个 goroutine 中,按照程序顺序,前面的操作 happens-before 于后续的任意操作;在不同的 goroutine 中,如果两个操作没有 happens-before 关系,那么它们就可以并发执行。

67. 锁释放后,等待中的 goroutine 中哪一个会优先获取 Mutex 呢?

Mutex 的获取是公平的,即等待时间最长的 goroutine 会最先尝试获取锁。具体来说,当 Mutex 被释放时,等待队列中的第一个 goroutine 会被唤醒,并尝试重新获取锁。如果该 goroutine 未能成功获取锁,则会继续等待,直到下一次 Mutex 被释放。因此,等待时间最长的 goroutine 会最优先获取 Mutex。

68. Mutex 底层实现

初版的互斥锁

通过一个 flag 变量,标记当前的锁是否被某个 goroutine 持有。如果这个 flag 的值是 1,就代表锁已经被持有,那么,其它竞争的 goroutine 只能等待;如果这个 flag 的值是 0,就可以通过 CAS(compare-and-swap,或者 compare-and-set)将这个 flag 设置为 1,标识锁被当前的这个 goroutine 持有了。

Mutex 结构体包含两个字段:

  • 字段 key:是一个 flag,用来标识这个排外锁是否被某个 goroutine 所持有,如果 key 大于等于 1,说明这个排外锁已经被持有;

  • 字段 sema:是个信号量变量,用来控制等待 goroutine 的阻塞休眠和唤醒。

第二版互斥锁

虽然 Mutex 结构体还是包含两个字段,但是第一个字段已经改成了 state,它的含义也不一样了。

state 是一个复合型的字段,一个字段包含多个意义,这样可以通过尽可能少的内存来实现互斥锁。这个字段的第一位(最小的一位)来表示这个锁是否被持有,第二位代表是否有唤醒的 goroutine,剩余的位数代表的是等待此锁的 goroutine 数。所以,state 这一个字段被分成了三部分,代表三个数据。

相对于初版的设计,这次的改动主要就是,新来的 goroutine 也有机会先获取到锁,甚至一个 goroutine 可能连续获取到锁,打破了先来先得的逻辑。但是,代码复杂度也显而易见。

第三版互斥锁

如果新来的 goroutine 或者是被唤醒的 goroutine 首次获取不到锁,它们就会通过自旋(spin,通过循环不断尝试,spin 的逻辑是在 runtime 实现的)的方式,尝试检查锁是否被释放。在尝试一定的自旋次数后,再执行原来的逻辑。

对于临界区代码执行非常短的场景来说,这是一个非常好的优化。因为临界区的代码耗时很短,锁很快就能释放,而抢夺锁的 goroutine 不用通过休眠唤醒方式等待调度,直接 spin 几次,可能就获得了锁。

最新版本

通过加入饥饿模式,可以避免把机会全都留给新来的 goroutine,保证了请求锁的 goroutine 获取锁的公平性,对于我们使用锁的业务代码来说,不会有业务一直等待锁不被处理。

69. 等待一个 Mutex 的 goroutine 数最大是多少?

Go 语言中 Mutex 的 goroutine 数最大是 536870911,这个数字是由 Mutex 的 state 类型决定的,目前是 int32,由于 3 个字节代表了状态,还有:2^(32 – 3) – 1 等于 536870911。

70. 如何设计一个可重入锁

  • 方案一:goroutine id

  • 方案二:token

71. 对 select 和 case 的理解

在 Go 语言中,select 语句类似于 switch 语句,但是 case 语句是针对通信的,即通道上的发送或接收操作。每个 case 必须是一个通道操作,要么是发送要么是接收。select 语句会监听所有指定的通道上的操作,一旦其中一个通道准备好就会执行相应的代码块。如果多个通道都准备好,那么 select 语句会随机选择一个通道执行。

72. 如何分析锁竞争的激烈程度

使用 Grafana 等监控关键互斥锁上等待的 goroutine 的数量,是我们分析锁竞争的激烈程度的一个重要指标。

73. RWMutex 的使用场景

如果你遇到可以明确区分 reader 和 writer goroutine 的场景,且有大量的并发读、少量的并发写,并且有强烈的性能需求,你就可以考虑使用读写锁 RWMutex 替换 Mutex。

74. Go 中 RWMutex 的策略

Go 标准库中的 RWMutex 设计是 Write-preferring 方案。一个正在阻塞的 Lock 调用会排除新的 reader 请求到锁。

写优先的设计意味着,如果已经有一个 writer 在等待请求锁的话,它会阻止新来的请求锁的 reader 获取到锁,所以优先保障 writer。当然,如果有一些 reader 已经请求了锁的话,新请求的 writer 也会等待已经存在的 reader 都释放锁之后才能获取。所以,写优先级设计中的优先权是针对新来的请求而言的。这种设计主要避免了 writer 的饥饿问题。

75. RWMutex 的 3 个踩坑点

  1. 不可复制

  2. 重入导致死锁

  3. 释放未加锁的 RWMutex

76. 使用 WaitGroup 时的常见错误

  1. 计数器设置为负值

  2. 不期望的 Add 时机

  3. 前一个 Wait 还没结束就重用 WaitGroup

如何避免

  • 不重用 WaitGroup。新建一个 WaitGroup 不会带来多大的资源开销,重用反而更容易出错。

  • 保证所有的 Add 方法调用都在 Wait 之前。

  • 不传递负数给 Add 方法,只通过 Done 来给计数值减 1。

  • 不做多余的 Done 方法调用,保证 Add 的计数值和 Done 方法调用的数量是一样的。

  • 不遗漏 Done 方法的调用,否则会导致 Wait hang 住无法返回。

77. noCopy:辅助 vet 检查

noCopy 字段的类型是 noCopy,它只是一个辅助的、用来帮助 vet 检查用的类型:

type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

如果你想要自己定义的数据结构不被复制使用,或者说,不能通过 vet 工具检查出复制使用的报警,就可以通过嵌入 noCopy 这个数据类型来实现。

78. Cond 怎么用

  • Signal 方法,允许调用者 Caller 唤醒一个等待此 Cond 的 goroutine。如果此时没有等待的 goroutine,显然无需通知 waiter;如果 Cond 等待队列中有一个或者多个等待的 goroutine,则需要从等待队列中移除第一个 goroutine 并把它唤醒。在其他编程语言中,比如 Java 语言中,Signal 方法也被叫做 notify 方法。调用 Signal 方法时,不强求你一定要持有 c.L 的锁。

  • Broadcast 方法,允许调用者 Caller 唤醒所有等待此 Cond 的 goroutine。如果此时没有等待的 goroutine,显然无需通知 waiter;如果 Cond 等待队列中有一个或者多个等待的 goroutine,则清空所有等待的 goroutine,并全部唤醒。在其他编程语言中,比如 Java 语言中,Broadcast 方法也被叫做 notifyAll 方法。同样地,调用 Broadcast 方法时,也不强求你一定持有 c.L 的锁。

  • Wait 方法,会把调用者 Caller 放入 Cond 的等待队列中并阻塞,直到被 Signal 或者 Broadcast 的方法从等待队列中移除并唤醒。

Go 标准库提供 Cond 原语的目的是,为等待 / 通知场景下的并发问题提供支持。Cond 通常应用于等待某个条件的一组 goroutine,等条件变为 true 的时候,其中一个 goroutine 或者所有的 goroutine 都会被唤醒执行。

79. Cond 的常见错误

  • 调用 Wait 的时候没有加锁

    • 如果调用 Wait 之前不加锁的话,就有可能 Unlock 一个未加锁的 Locker。

  • 没有检查等待条件是否满足

    • waiter goroutine 被唤醒不等于等待条件被满足,只是有 goroutine 把它唤醒了而已。你也可以理解为,等待者被唤醒,只是得到了一次检查的机会而已。

80. Once 的使用场景

Once 常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。

81. 如何实现一个 Once

一个正确的 Once 实现要使用一个互斥锁,这样初始化的时候如果有并发的 goroutine,就会进入 doSlow 方法。互斥锁的机制保证只有一个 goroutine 进行初始化,同时利用双检查的机制(double-checking),再次判断 o.done 是否为 0,如果为 0,则是第一次执行,执行完毕后,就将 o.done 设置为 1,然后释放锁。

即使此时有多个 goroutine 同时进入了 doSlow 方法,因为双检查的机制,后续的 goroutine 会看到 o.done 的值为 1,也不会再次执行 f。

这样既保证了并发的 goroutine 会等待 f 完成,而且还不会多次执行 f。

type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}


func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    // 双检查
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

82. 使用 Once 可能出现的 2 种错误

  1. 死锁

如果 f 中再次调用这个 Once 的 Do 方法的话,就会导致死锁的情况出现。这还不是无限递归的情况,而是的的确确的 Lock 的递归调用导致的死锁。

  1. 未初始化

如果 f 方法执行的时候 panic,或者 f 执行初始化资源的时候失败了,这个时候,Once 还是会认为初次执行已经成功了,即使再次调用 Do 方法,也不会再次执行 f。

我们可以自己实现一个类似 Once 的并发原语,既可以返回当前调用 Do 方法是否正确完成,还可以在初始化失败后调用 Do 方法再次尝试初始化,直到初始化成功才不再初始化了。

83. 使用 struct 类型做 Map 的 key 有什么坑

如果 struct 的某个字段值修改了,查询 map 时无法获取它 add 进去的值。

如果要使用 struct 作为 key,我们要保证 struct 对象在逻辑上是不可变的,这样才会保证 map 的逻辑没有问题。

84. 使用 Map 的常见错误

  • 未初始化

  • 并发读写

85. 使用 sync.Map 的特殊场景

  • 只会增长的缓存系统中,一个 key 只写入一次而被读很多次;

  • 多个 goroutine 为不相交的键集读、写和重写键值对。

86. sync.Map 的实现

  • 空间换时间。通过冗余的两个数据结构(只读的 read 字段、可写的 dirty),来减少加锁对性能的影响。对只读字段(read)的操作不需要加锁。

  • 优先从 read 字段读取、更新、删除,因为对 read 字段的读取不需要锁。

  • 动态调整。miss 次数多了之后,将 dirty 数据提升为 read,避免总是从 dirty 中加锁读取。

  • double-checking。加锁之后先还要再检查 read 字段,确定真的不存在才操作 dirty 字段。

  • 延迟删除。删除一个键值只是打标记,只有在提升 dirty 字段为 read 字段的时候才清理删除的数据。

87. sync.Pool 的实现原理

每次垃圾回收的时候,Pool 会把 victim 中的对象移除,然后把 local 的数据给 victim,这样的话,local 就会被清空,而 victim 就像一个垃圾分拣站,里面的东西可能会被当做垃圾丢弃了,但是里面有用的东西也可能被捡回来重新使用。

victim 中的元素如果被 Get 取走,那么这个元素就很幸运,因为它又“活”过来了。但是,如果这个时候 Get 的并发不是很大,元素没有被 Get 取走,那么就会被移除掉,因为没有别人引用它的话,就会被垃圾回收掉。

88. sync.Pool 的坑

  • 内存泄漏

    • 在使用 sync.Pool 回收 buffer 的时候,一定要检查回收的对象的大小。如果 buffer 太大,就不要回收了,否则就太浪费了。

  • 内存浪费

89. Context 的 Done 方法返回什么

如果 Done 没有被 close,Err 方法返回 nil;如果 Done 被 close,Err 方法会返回 Done 被 close 的原因。

90. 对一个地址的赋值是原子操作吗?

对于现代的多处理多核的系统来说,由于 cache、指令重排,可见性等问题,我们对原子操作的意义有了更多的追求。

atomic 包提供的方法会提供内存屏障的功能,所以,atomic 不仅仅可以保证赋值的数据完整性,还能保证数据的可见性,一旦一个核更新了该地址的值,其它处理器总是能读取到它的最新值。

91. 使用信号量的常见错误

  • 请求了资源,但是忘记释放它;

  • 释放了从未请求的资源;

  • 长时间持有一个资源,即使不需要它;

  • 不持有一个资源,却直接使用它。

92. 使用信号量的场景

在批量获取资源的场景中,我建议你尝试使用官方扩展的信号量。

93. CyclicBarrier 的用处

CyclicBarrier 允许一组 goroutine 彼此等待,到达一个共同的执行点。同时,因为它可以被重复使用,所以叫循环栅栏。具体的机制是,大家都在栅栏前等待,等全部都到齐了,就抬起栅栏放行。

94. ErrGroup 的作用

ErrGroup 是 Go 官方提供的一个同步扩展库。我们经常会碰到需要将一个通用的父任务拆成几个小任务并发执行的场景,其实,将一个大的任务拆成几个小任务并发执行,可以有效地提高程序的并发度。

ErrGroup 就是用来应对这种场景的。它和 WaitGroup 有些类似,但是它提供功能更加丰富:

  • 和 Context 集成;

  • error 向上传播,可以把子任务的错误传递给 Wait 的调用者。

95. Gin 为什么使用前缀树作为路由数据结构

  1. 高效的路由匹配:前缀树可以实现高效的路由匹配。在 Gin 框架中,路由是通过比较 URL 路径和已注册的路由路径来进行匹配的。使用前缀树可以将 URL 路径分解为一系列的字符节点,并通过前缀匹配快速定位到对应的路由节点,减少了不必要的比较操作,提高了路由匹配的效率。

  2. 灵活的路由配置:前缀树允许在每个节点上存储额外的信息,例如 HTTP 方法(GET、POST 等)和处理函数。这样,Gin 框架可以根据前缀树节点上存储的信息,灵活地配置路由规则和处理逻辑。节点的子节点可以代表路径的不同部分,使得可以方便地构建出各种路由规则,支持动态路由和参数匹配。

  3. 节省空间:前缀树可以共享相同的前缀,从而节省存储空间。在 Gin 框架中,相同前缀的路由路径可以共享相同的节点,避免了冗余存储。这对于大规模的路由配置来说,可以显著减少内存占用。

96. Go 的多路复用模型与数据结构

在 Go 语言中,多路复用模型通常指的是使用 net 包中的 ListenAccept 函数实现的并发网络服务器。它允许服务器同时处理多个客户端连接,而无需为每个连接创建一个新的操作系统线程。

多路复用模型的核心数据结构是 net.Listenernet.Connnet.Listener 用于监听指定的网络地址,接受客户端的连接请求,而 net.Conn 代表一个客户端连接。

通常,多路复用模型使用 goroutinechannel 来实现并发处理多个连接。当有新的连接到达时,Accept 函数会返回一个新的 net.Conn 对象,然后可以将该对象传递给一个新的 goroutine 进行处理。这样,每个连接都可以在独立的 goroutine 中执行,实现并发处理。

97. 如果将 Listener 关闭,那么之前已经 Accept 的连接是否会关闭

在 Go 中,如果你关闭了一个 net.Listener,那么之前已经通过该监听器接受的连接不会自动关闭。关闭监听器只会停止接受新的连接请求,已经接受的连接将继续保持打开状态。

如果你希望在关闭监听器时同时关闭所有已经接受的连接,你需要在关闭监听器之前显式地关闭每个已接受的连接。

98. 如何判断读文件结束了

  1. 使用 bufio.NewScanner 创建一个文件扫描器 scanner,然后通过循环调用 scanner.Scan() 来逐行读取文件内容。当 scanner.Scan() 返回 false 时,表示已经读取到文件末尾。

  2. 使用 file.Read 从文件中读取数据,并判断读取的字节数 n。当 n 为 0 时,表示已经读取到文件末尾。

99. 如何在打开一次文件后,读到末尾,不重新打开文件,再读一次

可以使用 os.Seekos.File.Seek 函数将文件指针移动到文件的开头,以便重新读取文件内容。

// 将文件指针移动到开头
	_, err = file.Seek(0, 0)

100. new 一个 map 结构会有什么问题

使用 make 函数来创建一个空的 map 结构是推荐的做法,而不是使用 new 关键字。使用 new 关键字创建 map 结构可能会导致以下问题:

  1. new 函数返回的是指向该类型零值的指针。而 map 类型的零值是 nil,不能直接用于存储键值对。如果你使用 new 创建一个 map,会得到一个 nil 指针,当你尝试向该 map 中存储键值对时,会触发运行时错误。

  2. new 函数只会为 map 类型分配存储空间,而不会进行初始化。这意味着你无法立即开始向该 map 中添加键值对,因为它的内部数据结构还没有被初始化。如果你尝试在一个通过 new 创建的 map 上执行添加操作,会导致运行时错误。

101. 传数组和传切片有什么区别

当数组作为函数参数时,函数操作的是数组的一个副本,不会影响原始数组;当切片作为函数参数时,函数操作的是切片的引用,会影响原始切片。

102. 为什么 bmap 里面存储的是八个键值对

  1. 内存分配效率:固定大小的桶可以在预分配的内存块上操作,避免频繁的内存分配和释放,提高性能。

  2. 冲突处理:哈希表中可能存在冲突,即不同的键映射到了同一个桶中。通过使用固定大小的桶,可以在桶内部使用更高效的冲突解决方法,例如链表或开放寻址法。

  3. 空间利用:通过固定大小的桶,可以在一定程度上减少内存空间的浪费。如果每个桶的大小过小,会导致内存碎片和额外的内存开销;如果每个桶的大小过大,会导致内存浪费。

103. Go 是深拷贝还是浅拷贝

拷贝操作既可以是深拷贝 (deep copy) 也可以是浅拷贝 (shallow copy)

  1. 对于基本数据类型 (如整数、浮点数等) ,赋值操作会进行浅拷贝,即只复制数据的值,而不是数据本身。

  2. 对于数组和切片,赋值操作也会进行浅拷贝,即复制切片的引用,而不是复制切片的元素。这意味着对一个切片的修改会影响到其他拷贝的切片。

  3. 对于 map 类型,默认进行浅拷贝。这意味着新的 map 会指向与原始 map 相同的底层数据。如果修改新的 map 中的键值对,原始 map 中对应的键值对也会被修改。

  4. 对于结构体类型,默认进行浅拷贝。这意味着新的结构体会复制原始结构体的字段值,但是如果结构体中包含引用类型的字段 (如指针、切片、map 等) ,则新的结构体和原始结构体会共享相同的引用。

如果需要进行深拷贝,需要手动逐个复制每个字段的值,并确保引用类型的字段也进行深拷贝。可以使用递归或其他方法来实现深拷贝操作。

104. Context 原理

Context 对象的原理是通过 channel 来实现的。Context 接口中的 Done 方法返回一个 channel,当 Context 对象被取消或超时时,这个 channel 会被关闭。函数可以通过监听这个 channel 来判断是否应该取消操作。Context 对象还提供了其他方法,如 Err、Deadline 和 Value,用于获取 Context 对象的状态、截止时间和传递的值。

105. Context 数据结构

Context 是一个接口类型,定义如下:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

Context 接口提供了以下方法:

  • Deadline() 方法返回 Context 的截止时间,如果没有设置截止时间,则返回 false。

  • Done() 方法返回一个通道,当 Context 被取消或超时时关闭。

  • Err() 方法返回 Context 被取消的原因。如果 Context 尚未被取消,则返回 nil。

  • Value(key interface{}) 方法用于获取与指定键关联的值。

106. Goroutine 通信方式

Go 协程的通信可以通过共享内存和通道两种方式实现。

  1. 共享内存:通过共享内存进行通信意味着多个协程可以访问和修改相同的内存空间。在 Go 中,可以使用互斥锁 (sync.Mutex) 来保护共享内存的访问,以避免数据竞争和并发问题。

  2. 通道:通道是 Go 语言提供的一种用于协程之间通信的机制。通道提供了一种同步和安全的方式来传递数据。通道可以在协程之间传递消息,确保数据的顺序和一致性。

107. Go GC 缺陷

  1. 停顿时间 (Stop-the-World) :在进行垃圾回收时,Go 程序需要停止所有的 goroutine,这会导致应用程序的暂停,影响用户体验。尤其是在大型内存堆上进行垃圾回收时,停顿时间可能会很长。

  2. 内存占用:Go 的垃圾回收器需要维护堆上的对象信息,这会占用一定的内存空间。当堆上的对象数量增加时,垃圾回收器需要更多的内存来存储标记信息,这可能导致内存占用过高。

  3. 内存碎片:Go 的垃圾回收器使用了标记 - 清除算法,这种算法可能导致内存碎片的产生。当内存碎片过多时,可能会导致内存分配效率下降,甚至导致内存不足的情况。

108. pprof 怎么用来排查内存泄漏

在代码中导入 net/http/pprof 包,并在主函数中启动一个 HTTP 服务器,以便 pprof 可以收集运行时的分析数据。然后,使用 go tool pprof 命令连接到 pprof HTTP 服务器,并生成内存分析报告。

pprof 提供了一系列的命令用于分析内存分析报告,比如 top 命令可以显示分配内存最多的函数,list 命令可以显示指定函数的源代码,web 命令可以生成内存分配和函数调用的图形展示。通过这些命令,可以定位到可能存在内存泄漏的代码位置。

109. 排查 gc 问题思路

  1. 查看程序运行过程中的 GC 信息:可以设置 gctrace 的变量值为 1,通过环境变量或命令行参数来开启 GC 追踪功能。这样可以输出程序运行过程中的 GC 信息,包括执行次数、耗时等。

  2. 使用 pprof 进行性能分析:可以通过引入 net/http/pprof 包并启动一个 HTTP 服务器,然后使用 go tool pprof 命令连接到服务器来进行性能分析。可以查看堆的使用情况、CPU 耗时、当前运行的 goroutine 等信息,以定位 GC 问题所在。

  3. 观察内存分配和逃逸分析:通过分析内存分配和逃逸情况,可以了解对象的生命周期和内存使用情况,进而判断是否存在内存泄漏或频繁的内存回收。可以使用 go build -gcflags="-m" 命令来查看编译器的逃逸分析信息,或使用 go tool pprof -alloc_space 命令来查看内存分配情况。

  4. 分析程序日志和监控数据:通过查看程序日志和监控数据,可以观察程序的运行状态、资源使用情况和错误信息,从而判断是否存在 GC 问题。可以关注 CPU 使用率、内存占用、GC 时间等指标。

110. 值类型和引用类型的区别

值类型(Value Types):

在 Go 语言中,值类型包括:int、float、bool、string、array 和 struct。当我们创建一个值类型的变量时,变量值被存储在栈中,每个变量都有自己的内存地址。

引用类型(Reference Types):

在 Go 语言中,引用类型包括: slice、map、chan、interface、以及 pointer。当创建一个引用类型的变量时,它的值实际上是存储在堆内存中的一个地址。当我们把引用类型的变量赋值给一个新的变量时,我们实际上复制的是内存地址,也就是说,两个变量引用的是内存中的同一个地址。

111. Gin 中间件洋葱模型

  1. 当一个请求到达 Gin 的路由器时,它首先经过全局中间件。全局中间件是在路由器创建时添加的,它们会对每个请求都执行。

  2. 接下来,请求进入路由组中的中间件。路由组是一组相关路由的集合,可以为它们添加共享的中间件。这些中间件会在请求进入路由组时执行。

  3. 然后,请求进入路由组中具体的路由处理函数。这是请求的最终目的地,它会执行特定的业务逻辑。

  4. 在路由处理函数执行完毕后,请求会从路由组中逐层返回,依次执行路由组中的中间件。这个过程就像剥离洋葱的外层一样,每一层都会执行相应的中间件。

  5. 最后,请求返回到全局中间件,执行全局中间件的剩余部分。这时,请求已经完成了整个处理过程。

Gin 中间件的洋葱模型是一种层层剥离和包裹的执行模型,中间件的执行顺序是先进后出的。它允许开发者在请求的不同阶段添加和执行中间件,以实现各种功能,如身份验证、日志记录、错误处理等。这种模型使得中间件的添加和配置更加灵活和可扩展,提高了代码的可维护性和可读性。

112. CSP 模型

与其他主流语言通过共享内存来进行并发控制不同,Go 语言采用了 CSP 模式,通过显式地使用通道 (channel) 来进行并发通信和同步。CSP 模型的核心思想是通过通信来实现内存共享,而不是通过共享内存来进行通信。

Last updated