Willson Chen

Stay Hungry, Stay Foolish.

Golang 底层原理

Golang 底层原理

字符串实现原理

  • 字符串底层是只读字节数组,字符串结构包括数组指针和长度。
  • 字符串默认用utf-8编码,一个中文为3字节。
  • 如果要获取字符串上的字符,可以先转换成rune切片。
  • len()获取的是字节数,字符数用utf8.RuneCountInString()。
  • 字符串的修改和转换都涉及到内存复制,大量拼接用strings.Builder。

切片实现原理

  • 切片由数组指针、长度和容量组成,底层数组分配在堆上。
  • 不同切片可以共用一个底层数组,只要指针、长度和容量不同,切片表达的数据就不同。
  • 截取操作的结果会创建新切片,但是底层数组不变。
  • 追加操作的结果会创建新切片,如果容量够底层数组也不变,否则会分配容量更大的底层数组,即扩容。
  • 新容量由扩容因子决定,不同版本的 Go 扩容策略不同:
    • Go 1.8 之前:当旧容量小于 1024 时,扩容因子为 2 倍,否则为 1.25。
    • Go 1.8 及之后:策略稍微改进,在 旧容量小于等于 256时,扩容因子 2 倍,后面会逐步减小到 1.25。
type slice struct {
 array unsafe.Pointer // 指向底层数组
 len   int            // 长度
 cap   int            // 容量
}

map 实现原理

  map 的数据结构,核心是哈希表+桶链,具体如下:

  • map 是一个指针,指向一个哈希表 hmap。
  • 哈希表包含一个桶数组,长度是2的B次方。
  • 桶是固定长度结构,包括最多8个键值对和键哈希值高8位。
  • key的哈希值低B位决定存储在哪个桶,桶内则顺序存储。
  • 桶内存储超过8个时,链接到溢出桶存储。
  • 装载因子(键数/桶数)达到 6.5 时增量扩容,溢出桶过多时触发等量扩容。

  map 的算法

  • 查找
    • 用 key 和哈希种子(map 创建时生成)计算哈希值(64 位)。
    • 取哈希值低位(长度B)对应桶索引,
    • 取哈希值高 8 位在桶内遍历topbits地址表,查找key的位置。
    • 找到key位置后,进一步比较key是否相等,如果不等则继续遍历。
    • 如果桶内找不到,则通过桶链继续查找溢出桶。
    • 如果遍历完找不到 key,则返回value类型的默认值。
  • 插入/更新
    • 同查找过程,尝试找到 key 位置,如果找到,则更新 value。
    • 如果找不到,且遍历过程找到了空位,则插进空位。
    • 如果没有空位,则判断是否要扩容。
    • 如果不需要扩容,则生成一个溢出桶,将元素放在第一个位置。
    • 如果需要扩容,则等扩容完毕再重复以上步骤。
  • 删除
    • 同查找过程,尝试找到 key 位置,如果找到,则清除对应该位置的数据。
  • 扩容
    • 插入和删除时都会进行扩容检查。
    • 当装载因子(键数/桶数)达到 6.5 时,触发增量扩容,B加1哈希表长度翻倍。
    • 6.5/8 约等于80%,是测试得到的较平衡的数值。
    • 当溢出桶过多时,触发等量扩容,哈希表长度不变,溢出桶收缩,桶内元素重新排列,本质是碎片整理。
  • 遍历
    • map 的遍历是无序的,每次遍历都会设置随机起点,目的在于避免开发者利用顺序。
    • 遍历过程中可以删除key,不会导致遍历失效。
    • 遍历过程中虽然可以插入元素,但是新插入的元素是否会在后续的遍历中是不确定的。

闭包实现原理

  • 当一个函数创建了另一个函数作为局部变量时,编译器就生成一个闭包。
  • 闭包本身是个结构体,包含函数指针和局部变量的指针。
  • 闭包会触发编译器的逃逸分析,以判断局部变量是否需要分配在堆上。
  • 闭包是在堆中分配的,所以外部函数执行完成之后,闭包仍然存在。

defer 实现原理

  • 编译器遇到 defer 会生成一个_defer结构体
  • _defer结构包含延迟执行的函数指针、参数返回值内存大小等。
  • _defer结构体作为header,与参数返回值形成连续空间。
  • 延迟函数的参数是预计算的,即参数在 defer 语句执行时就确定了。
  • 所有_defer结构组成了一个链表,挂在协程上。
  • _defer链表的插入使用头插法,在执行时从头到尾顺序执行,所以是LIFO。
  • 有注册 defer 的函数,在 return 时,执行以下操作:
    • 设置返回值。
    • 执行 _defer 链中由当前函数注册的节点,执行过的节点摘除。
    • 跳转回原函数继续执行。

panic 实现原理

  • panic 本质是一个panic()特殊函数调用。
  • panic 产生原理:
    • 直接调用 panic() 函数,参数可以是任何类型的变量。
    • 编译器产生,如除法运算时会增加对 0 值的判断,调用 panic()。
    • 由系统进程的信号处理程序调用 panic(),如空指针或非法地址访问等。
  • panic()函数实现原理:
    • 产生一个结构体挂在协程上,包含是否恢复的标记。
    • 执行 _defer链,执行过程中如果有调用了 recover(),则修改是否恢复的标记。
    • _defer链执行完,如果标记恢复,则按正常返回逻辑。
    • 如果标记不恢复,则打印panic信息,进程退出。
  • recover() 实现原理:
    • 修改是否恢复的标记。
    • 返回 panic()时的参数。

协程调度原理

  • 协程的调度抽象看是一个生产者与消费者问题。程序生成协程G,线程M消费协程,由GMP调度模型解决。
  • 抢占式调度用于解决协程占用CPU执行时间过久的问题。

GM模型

  • 早期版本(v1.1)的实现。
  • 只有一个全局队列暂存 G,由多个 M 来获取 G 执行。
  • 缺陷1:存在全局锁,多个M是并发从全局队列获取 G,所以需要对全局队列加锁,导致性能下降。
  • 缺陷2:忽略了G的关系,比如G1创建了G2,G1和G2是大概率相关的,交给不同线程来执行会破坏时间和空间的局部性,导致性能下降。

GMP模型

  • GM模型的改进。
  • P 的作用及特点:
    • 将全局队列拆成多个本地队列,管理队列的结构是P。
    • M 通过 P 队列获取 G 时不需要全局锁。
    • 每个P的队列是固定长度256的数组,全局队列则是长度不限的链表。
    • P中除了待运行队列外,还加了一个runnext的结构,为了优先运行刚创建的G,提高局部性。
    • 线程缓存从 M 上转移到了 P,P 切换 M 时不需要重新分配线程缓存。
    • 通过环境变量 GOMAXPROCS 控制 P 的数量。
    • GOMAXPROCS 默认是 cpu 的核心数,使用容器时需要修改为限制的核心数。
  • M 的消费逻辑:
    • 先从绑定的 P 的本地队列(优先runnext)上获取 G。
    • 然后从全局队列获取 P,另外每61次调度会检查一下全局队列,并且会将全局队列的 G 分给各个 P。
    • 如果全局队列没有 G,则随机选择一个 p 偷一半 G 过来。
    • 偷任务过程会访问并发访问本地队列,需要加自旋锁。
    • M 的自旋状态表示绑定了 P正在获取 G。
    • 如果还没有任务则休眠。
  • G 的生产逻辑:
    • 使用 go 关键字进行函数调用时,生成 G,优先放入 P 的 runnext 队列。
    • runnext 满了,将 runnext的 G 踢出放入本地队列,再将 G 放入 runnext。
    • 如果本地队列也满了,将本地队列的一半和 runnext踢出的 G 放入全局队列。

GMP模型

抢占式调度

  • v1.2实现了基于协作的抢占式调度。
    • 监控线程发现协程执行太久,则为其设置抢占标记。
    • 协程调用函数时,会检查是否栈扩张,同时检查抢占标记,如果标记则让出。
    • 问题:在没有任何调用或栈扩张的死循环里不会发生调度。
  • v1.14实现了基于信号的抢占式调度。
    • 线程M在处理第一个G时,会注册信号处理函数。
    • 监控线程发现某个G执行太久,则会给M发送抢占信号。
    • 收到信号后M会将当前G插入到全局队列,找下一个G执行。

通道实现原理

  • 通道创建时是在堆中创建了一个结构体,并返回指针,所以通道是引用类型。
  • 通道结构体中主要包含:缓冲区循环数组,发送索引、接收索引、互斥锁、接收和发送的协程队列等。
  • 读取缓冲区时,先加锁,再用结构体中的索引读写循环数组,索引位置循环自增,再释放锁。
  • 当需要阻塞时,发送端或接收端会调用协程调度器,阻塞自己并让出系统线程。
  • 当接收队列中存在阻塞协程时,缓冲区肯定是空的,发送端会直接复制数据到接收栈中,不会经过缓冲区也不需要加锁。
  • 当发送队列中存在阻塞协程时,缓冲区肯定是满的,接收端需要从缓冲区读取数据,再将发送者数据写入缓冲区,然后才唤醒发送者。

通道的操作流程图(有缓冲区情况):
通道的操作流程图

“`mermaid
graph LR
a([向通道\n发送数据])
b{通道\n为nil}
c([阻塞])
d{通道\n已关闭}
e([panic])
f{是否\n有阻塞的\n接收者}
g[直接发送\n给接收者]
h[唤醒\n接收者]
i([返回])
j{通道\n缓冲区\n已满}
k[发送到\n缓冲区]
l[阻塞等待被\n接收者唤醒]
m[被唤醒]
a–>b
b–是–>c
b–否–>d
d–是–>e
d–否–>f
f–是(缓冲区肯定是空的)–>g
g–>h
h–>i
f–否–>j
j–否–>k
k–>i
j–是–>l
l–>m
m–>i
“`

“`mermaid
graph LR
a([从通道\n接收数据])
b{通道\n为nil}
c([阻塞])
d{通道\n已关闭}
e{缓冲区\n是否有数据}
f([返回零值])
g{是否有\n阻塞的\n发送者}
h[从缓冲区\n读取数据]
h1[将发送者数据\n写入缓冲区]
h2[唤醒\n发送者]
i([返回数据])
j{缓冲区\n是否有数据}
k[从缓冲区\n读取数据]
l[阻塞等待被\n发送者唤醒]
m[被唤醒]
a–>b
b–是–>c
b–否–>d
d–是–>e
e–否–>f
e–是–>k
d–否–>g
g–是(缓冲区肯定是满的)–>h
h–>h1
h1–>h2
h2–>i
g–否–>j
j–是–>k
k–>i
j–否–>l
l–>m
m–>i
“`

“`mermaid
graph LR
a([关闭通道])
b{通道\n为nil}
c([panic])
d{通道\n已关闭}
e[唤醒所有接收者与发送者]
f([返回])
a–>b
b–是–>c
b–否–>d
d–是–>c
d–否–>e
e–>f
“`

内存分配

  • 栈是专属于协程的内存空间,用于存储局部变量、函数参数、返回值等。
  • 栈内存分配的单位是栈帧。
  • 栈帧大小在编译期确定,由编译器分配和释放。
  • 在函数调用开始时push栈帧,函数调用结束时pop栈帧。
  • 函数参数和返回值在调用者的栈帧中。
  • 栈的大小会随着函数调用层次增加而增加。
  • 协程栈的初始容量是 2K,可以动态增长到 1G。

  • 堆是协程间共享的内存空间。
  • 分配在堆上的数据:
    • 全局变量。
    • 发生逃逸的值类型数据。
    • 未被优化到栈上的引用类型数据(slice 可能被优化到栈上)。
  • 堆内存管理基于 TCMalloc 的核心思想构建:
    • 为每个线程分配一块缓存,小内存从缓存直接分配避免系统调用,且避免加锁。
    • 提供所有线程共享的中心缓存,与线程缓存结构相同。
    • 分配的单位是内存分页(1分页为8K)的整数倍。
    • 缓存的结构按空间大小(分页数量)提前划分好,方便直接提取使用。
    • 在中心缓存与系统内存之间增加一层堆内存,作为系统内存的抽象。
    • 小对象、中对象、大对象的分配策略差异化处理,平衡内存利用率和分配效率。
  • 与 TCMalloc 的差异:
    • 线程缓存是挂在 GMP 模型的 P 下。
    • 线程缓存提供的是可能小于内存分页的对象。

逃逸分析

  • 逃逸分析是指在编译期分析代码,决定是在栈还是堆上分配内存。
  • 如果局部变量的作用域超出了函数范围,则会逃逸到堆上。
  • 如果局部变量体积过大,也会逃逸到堆上。
  • new() 或 make()出来的局部变量,如果没超出函数范围,也可能被优化到栈上。
  • 指针和闭包都会引发逃逸分析。
  • 使用命令输出分析结果:go build -gcflags ‘-m -l’ x.go

垃圾回收

  • 堆分配的内存是由垃圾回收进行自动释放的。
  • Go 垃圾回收的特点:
    • 渐进式回收,将代价分散到整个程序运行期间,避免停顿。
    • 并发式回收,垃圾回收与程序运行并发执行。
  • Go 垃圾回收的实现基于三色标记法+混合写屏障法。
  • 三色标记法:
    • 从根出发,可达的对象全部标记为灰色。
    • 遍历所有灰色,已遍历标记黑色,对灰色引用的白色对象标记灰色。
    • 重复遍历灰色,直至不存在灰色对象。
    • 最后回收白色对象。
    • 三色标记法可以处理循环引用问题,并且可以并行处理不同区域的对象。
    • 根是全局变量和协程栈变量等。
  • 混合写屏障:
    • 标记过程中,并发写入可能导致标记错误,引发野指针或内存泄露。
    • 编译期写操作插入hook,确保三色不变性。
  • 三色不变性,满足其中之一则可:
    • 强三色不变性,黑色不能引用白色。
    • 弱三色不变性,黑色引用的白色必须有灰色的引用作为保护。
  • STW(Stop The World,万物静止)
    • 垃圾回收时,某些阶段需要停止所有用户代码的执行,该状态称为STW
    • STW越长,对用户代码的影响就越大。
    • 通过三色标记和混合写屏障,GO的GC过程中STW在毫秒级以下。
  • 垃圾回收的触发:
    • 主动触发 runtime.GC()。
    • 定期触发,默认每 2 分钟,由监控线程触发。
    • 内存分配量超过阈值时触发。

监控线程

  • 监控线程sysmon是独立运行的后台线程,是不和P结合的M。
  • 内部是无限循环,在启动一段时间后会每轮循环10ms。
  • 处理内容:
    • 如果GC超过2分钟,则强制触发GC。
    • 检查是否有长时间运行的G,进行抢占。
    • 进行netpool,进行io多路复用epoll_wait()的调用。

结构体的内存对齐

  • 编译器给结构体分配内存时,会使用内存对齐,以便CPU按块读取时效率更高。
  • 因为内存对齐,可以避免小于对齐宽度的数据落在两个块中,导致CPU需要多次读取。
  • 影响:
    • 结构体字段排列顺序不同可能导致结构体大小不同。
    • 内存大小一定是对齐宽度的整数倍。
  • 64位架构时最大对齐宽度8字节,32位为4字节。
  • 构造结构体时,按字段大小有序排列,可以提高内存利用率。

内存泄漏

  • 内存泄漏是指堆分配的内存,不使用但无法被GC回收。
  • 泄漏场景:
    • 协程没有按预期退出,则关联的内存无法被释放。
    • timer.Ticker不stop,会一直占用内存空间。
    • 截取子切片后,与父切片公用底层数组,导致父切片无法被GC回收。
  • 排查,使用pprof工具。