golang底层知识汇总
本文基于go1.21.2,不同版本的go可能会有差异。文中部分代码会由于不是知识点强相关而省略,但被忽略的每行代码或多或少都有它们实际的用处,甚至可能不宜删除,欢迎指出
分析底层的相关的代码时,往往会由于平台架构而带来代码上的差异,比如我机器是darwin/arm64,使用vscode查看代码,而且我希望基于linux/amd64来看代码,可以在
.vscode/settings.json
中设置GOOS
和GOARCH
:
1 2 3 4 5 6 { "go.toolsEnvVars": { "GOOS": "linux", "GOARCH": "amd64" } }这样我在打开平台相关的代码文件比如
os_linux.go
时,go插件就能支持代码跳转
数组
[10]int
和[20]int
是不一样的类型,也就是数组的类型由元素类型和元素个数共同决定。
对于literal的数组分配,在不考虑逃逸分析的情况下:
类型 | 内存分配 |
---|---|
元素个数<=4 | 直接分配在栈上 |
元素个数>4 | 放到静态区并在运行时取出 |
数组的访问和赋值大多数会被转换成直接内存读写,需要同时依赖编译时和运行时(比如有的常量下标访问在编译时检查出越界,有时候使用变量下标则在运行时检查)。
切片
数据结构
编译时的切片是Slice
类型的,只包含元素类型,因此切片类型只由元素类型决定。而在运行时切片由SliceHeader
表示:
1
2
3
4
5
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
使用make
初始化切片时,会调用makeslice
最终调用runtime.mallocgc
申请内存,申请的内存比较小就会直接初始化在Go语言调度器的P结构中上,否则初始化在堆上。
append
当切片依赖的底层数组容量不足时,就会进行数组扩容:
- 如果期望容量>2*当前容量,那么新数组容量为期望容量
- 如果当前容量较小(<1024),那么容量翻倍
- 如果当前容量较大(>=1024),那么1.25*当前容量
确定完上述容量之后,还会进行内存对齐,用于减少内存碎片。
copy
切片的拷贝,最终会使用runtime.memmove
将整块内存拷贝到目标区域。相比依次拷贝元素拥有更好的性能。
哈希表
装载因子 = 元素数量 / 桶数量
开放地址法的装载因子最大为1,当趋近1时查找和插入复杂度都几乎为O(n)。拉链法的装载因子可以超过1
数据结构
golang map本质上是一个指针,指向hmap
结构体,其buckets
成员指向桶数组buckets
,桶用类型bmap
表示。extra
成员记录溢出桶的使用情况,其中overflow
指向溢出桶地址,oldoverflow
指向旧桶数组的溢出桶地址(扩容迁移的时候用到),nextoverflow
指向下一个空闲溢出桶
bmap
主要的成员是tophash
, keys
, values
三个数组,这样可以使内存排列更加紧凑。tophash
类型是[]uint8
,每个tophash
存储了每个哈希值的高8位,访问哈希表时,只需要对比高8位就能知道键不存在,减少完整比对哈希值的开销(如果对比高8位相同,才需要比较哈希值),如果比较后发现相同,就能直接通过当前tophash偏移量定位到key偏移量。bmap
最后还有一个bmap
指针,指向溢出桶,当该桶存满了,增加一个溢出桶继续存入新数据。
扩容
map的扩容策略为渐进式扩容,oldbuckets
指向旧桶数组,只读不写,buckets指向当前桶数组,nevacuate记录下一个要迁移的旧桶编号。
map的扩容规则:
- 如果负载因子>6.5,则翻倍扩容
- 如果使用太多溢出桶,则等量扩容(B<=15并且溢出桶数量大于桶数量 或者 B>15并且溢出桶数量大于215)
扩容期间,迁移evacuate
这个动作只在write/delete时完成,read不会发生迁移,每次迁移一个桶(如果是时翻倍扩容则会每次迁移两个桶以加速迁移)。
Q:为什么溢出桶过多是等量扩容,解决了什么问题?A:负载因子不大但是溢出桶很多是因为大量key被删除,因此等量扩容后,大量溢出桶的key会存到常规桶中,从而减少溢出桶的数量
字符串
string
字符串由StringHeader
表示,Data
表示字节指针:
1
2
3
4
type StringHeader struct {
Data uintptr
Len int
}
由于字符串的内容是不可改变的,因此不需要切片中Cap
,只需要Len
。
string
和[]byte
之间的直接转换是通过拷贝实现的,长度越大需要申请的空间越大,可以用unsafe.SliceData
和unsafe.StringData
解决这个问题。
接口
接口数据结构分为两类:
- 带方法的接口:
runtime.iface
1
2
3
4
type iface struct { // 16 字节
tab *itab
data unsafe.Pointer
}
- 不带方法的接口:
runtime.eface
1
2
3
4
type eface struct { // 16 字节
_type *_type
data unsafe.Pointer
}
其中_type
是go语言类型的运行时表示,包含了类型的大小、类型的hash(用于快速确定类型是否相等)等。而itab
是接口类型的核心,可以看成是接口类型+具体类型的组合:
1
2
3
4
5
6
7
type itab struct { // 32 字节
inter *interfacetype // 接口类型
_type *_type // 具体类型
hash uint32 // 用于interface转换成具体类型时,快速判断是否与具体类型的_type一致
_ [4]byte
fun [1]uintptr // 虚函数表,进行动态派发
}
关于接口,这部分大多是汇编层面的分析,直接看下不同情况下函数调用的benchmark:
直接调用 | 动态派发 | |
---|---|---|
指针 | ~3.03纳秒 | ~3.58纳秒 |
结构体 | ~3.09纳秒 | ~6.98纳秒 |
最好的是使用指针实现+直接调用,不过设计好的项目一般善于面向接口编程,因此指针调用+动态派发也很常用。
channel
- 未初始化的channel(
nil
),无论是接收还是发送都会直接阻塞 - 不能关闭
nil
的或者已经关闭的channel - 不能向已经关闭的channel发送数据
channel(chan
)在运行时使用runtime.hchan
表示:
1
2
3
4
5
6
7
8
9
10
11
12
13
type hchan struct {
qcount uint // chan中的元素个数
dataqsiz uint // 循环队列长度
buf unsafe.Pointer // 缓冲区指针
elemsize uint16 // 元素大小
elemtype *_type // 元素类型
sendx uint // 发送操作处理到的位置
recvx uint // 接收操作处理到的位置
recvq waitq // 接收阻塞的goroutine队列,使用双向链表waitq表示,每个节点代表一个goroutine
sendq waitq // 发送阻塞的goroutine队列
lock mutex
}
hchan
包含了一把保护其他成员的互斥锁,从某种程度上来说channel是有锁队列,虽然在某些关键路径上进行了无锁化的优化,但并不是完全的无锁队列。
初始化
调用runtime.makechan
创建管道,分三种情况:
- 当缓冲区大小为0,那么只会给
hchan
分配内存 - 当元素类型不是指针,就会给
hchan
和buf
分配一段连续的内存 - 否则会给
hchan
和buf
单独分配内存
最后初始化elementType
, elementSize
, dataqsiz
字段
发送
发送操作最终转换为调用runtime.chansend
,分成三种情况。
当存在阻塞的接收者,将接收者从recvq
中出队,然后调用runtime.sned
将数据发送给它:
1
2
3
4
5
6
7
8
9
10
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if sg.elem != nil {
sendDirect(c.elemtype, sg, ep)
sg.elem = nil
}
gp := sg.g
unlockf()
gp.param = unsafe.Pointer(sg)
goready(gp, skip+1)
}
调用runtime.sendDirect
将数据拷贝到x = <-c
的x
所在内存中,然后调用runtime.goready
将接收者的goroutine标记成可运行状态(注意不是马上运行),放到发送方处理器的runnext
上等待执行,处理器在下一次调度时唤醒这个阻塞的接收者。
当缓冲区未满,将数据放入缓冲区:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
if c.qcount < c.dataqsiz {
qp := chanbuf(c, c.sendx) // 计算下一个存储数据的位置
typedmemmove(c.elemtype, qp, ep) // 拷贝元素到该位置
// 更新数据在队列中的索引
c.sendx++
if c.sendx == c.dataqsiz {
c.sendx = 0
}
// 增加队列大小
c.qcount++
unlock(&c.lock)
return true
}
...
}
当缓冲区满,阻塞等待。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
if !block {
unlock(&c.lock)
return false
}
// 将g和发送的数据绑定到sudog,将sudog入队sendq,然后将该sudog设置到goroutine的waiting上,阻塞等待。
gp := getg()
mysg := acquireSudog()
mysg.elem = ep
mysg.g = gp
mysg.c = c
gp.waiting = mysg
c.sendq.enqueue(mysg)
goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3)
// 被唤醒后进行一些收尾工作,释放sudog
gp.waiting = nil
gp.param = nil
mysg.c = nil
releaseSudog(mysg)
return true
}
接收
接收会被转换为调用runtime.chanrecv1
或runtime.chanrecv2
,最终都会调用runtime.chanrecv
接收存在两种特殊情况:当chan
为nil
,接收操作直接被阻塞并且永远不会被唤醒;当chan
已经被关闭,接收操作直接返回,并且接收值所在内存区域被清空(即<-c
返回零值)。
除了这两种特殊情况,就是下面普通的三种情况:
当存在阻塞的发送者,通过runtime.recv
从发送者那里(sudog)获取数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
if c.dataqsiz == 0 {
if ep != nil {
recvDirect(c.elemtype, sg, ep)
}
} else {
qp := chanbuf(c, c.recvx)
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
typedmemmove(c.elemtype, qp, sg.elem)
c.recvx++
c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
goready(gp, skip+1)
}
如果缓冲区不存在数据,使用recvDirect
直接从发送者sudog
那里拿,否则拿缓冲区队头的数据,然后将发送者的数据放到缓冲区队尾。最终都会调用goready
将发送方goroutine设置为可运行状态,等待调度器下一次调度。
Q:为什么优先从缓冲区拿数据?A:为了遵循FIFO,先进先出的特性。因为缓冲区的数据肯定是先来的,缓冲区满了之后才会有发送者阻塞在后面的发送操作上
当没有阻塞的发送者,并且缓冲区不为空:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
if c.qcount > 0 {
qp := chanbuf(c, c.recvx)
if ep != nil {
typedmemmove(c.elemtype, ep, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++
if c.recvx == c.dataqsiz {
c.recvx = 0
}
c.qcount--
return true, true
}
...
}
chanrecv
会将缓冲区的数据取出(使用typedmemmove
将队列的数据拷贝到内存中),然后做些收尾工作。
当没有等待的接收者,缓冲区也为空的情况下,接收者将会被阻塞:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
if !block {
unlock(&c.lock)
return false, false
}
// 将g、c、ep绑定到sudog,将sudog入队recvq,然后阻塞
gp := getg()
mysg := acquireSudog()
mysg.elem = ep
gp.waiting = mysg
mysg.g = gp
mysg.c = c
c.recvq.enqueue(mysg)
goparkunlock(&c.lock, waitReasonChanReceive, traceEvGoBlockRecv, 3)
// 此时sudog被唤醒,做些收尾工作,释放sudog
gp.waiting = nil
closed := gp.param == nil
gp.param = nil
releaseSudog(mysg)
return true, !closed
}
类似阻塞发送者那样,基于sudog
进行等待队列的管理。
关闭
关闭管道会出队所有recvq
和sendq
的元素,并将他们唤醒。
select
golang中的select
支持了类似系统调用select
那样的多路复用:对channel的多路复用。
select
多个同时满足的case
会随机地选择其中一个执行default
分支实现了非阻塞的channel读写
select
在运行时对应的是runtime.scase
结构体,每个case
对应一个scase
:
1
2
3
4
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
针对不同的select结构,编译器会有不同的优化。
当没有case
,即select{}
的时候,直接gopark
进入永久阻塞
当只存在一个case
的时候,编译器会改写为直接对channel的操作,比如select { case v, ok := <-ch: }
改写为v, ok := <-ch
当只存在两个case
并且其中一个是default
的时候,会转化为runtime.chansend
/runtime.chanrecv
,传入的block
参数为false
,表明当没有接收者并且缓冲空间不足时会直接返回而不是阻塞。
当存在多个case
时:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
selv := [3]scase{}
order := [6]uint16
for i, cas := range cases {
c := scase{}
c.kind = ...
c.elem = ...
c.c = ...
}
chosen, revcOK := selectgo(selv, order, 3)
if chosen == 0 {
...
break
}
if chosen == 1 {
...
break
}
if chosen == 2 {
...
break
}
每个case
被转换成scase
,调用runtime.selectgo
选择一个将会执行的case
,生成多个if
用于判断是不是自己被选中的case
,每个if
对应一个case
。
最重要的处理逻辑都在selectgo
中。selectgo
首先会确认轮询顺序和加锁顺序
- 轮询顺序:为了避免
case
饥饿,使用runtime.fastrandn
确定随机轮询顺序来避免case
饥饿 - 加锁顺序:为了避免死锁,按照channel的地址顺序来确定(用堆排序实现)。
Q:为什么需要确定加锁顺序,不限制加锁顺序的话什么情况下会出现死锁?A:比如有ch1和ch2,并且有两个goroutine同时对这两个channel进行select:select { case <-ch1: break; case <-ch2: break }
,不限制加锁顺序的话,可能g1的加锁顺序为ch1, ch2,而g2的加锁顺序为ch2, ch1,如果g1锁完ch1后,此时调度到g2,g2去锁ch2,这样就发生了死锁
确定完轮询顺序和加锁顺序后,就对channels进行加锁,然后进行循环,循环一共有三轮,每轮做的事情都不一样:
- 直接执行:查看有没有可以直接执行的
case
,比如case <-ch
有对应的发送者在等待,有的话就goto
到对应分支执行相应的操作,比如bufrecv
,bufsend
,recv
…等(这些分支实际上对应的就是runtime.chansend
,runtime.chanrecv
逻辑,只不过因为这里还包含了select
的相关操作,所以不能直接调用runtime.chansend
/runtime.chanrecv
等函数)。轮询下来后如果没有满足条件的case
,并且如果没有default(block=true),那么进入下一轮 - 入队阻塞:对于每个
case
都生成一个绑定当前goroutine的sudog
并加入各个channel的等待队列(接收就入队recvq
,发送就入队sendq
,这是为了当任意channel有更新时当前goroutine都能得到唤醒)。最后调用gopark
阻塞当前协程 - 唤醒执行:此时协程被其中一个channel唤醒,循环出队其他的channel并释放对应的
sudog
,解锁channel,最后返回。
GMP
内容比较多,放在另一篇
参考链接🔗
【lunar@qq.com】Golang 1.21.4 GMP调度器底层实现个人走读