文章目录
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 放入全局队列。
抢占式调度
- v1.2实现了基于协作的抢占式调度。
- 监控线程发现协程执行太久,则为其设置抢占标记。
- 协程调用函数时,会检查是否栈扩张,同时检查抢占标记,如果标记则让出。
- 问题:在没有任何调用或栈扩张的死循环里不会发生调度。
- v1.14实现了基于信号的抢占式调度。
- 线程M在处理第一个G时,会注册信号处理函数。
- 监控线程发现某个G执行太久,则会给M发送抢占信号。
- 收到信号后M会将当前G插入到全局队列,找下一个G执行。
通道实现原理
- 通道创建时是在堆中创建了一个结构体,并返回指针,所以通道是引用类型。
- 通道结构体中主要包含:缓冲区循环数组,发送索引、接收索引、互斥锁、接收和发送的协程队列等。
- 读取缓冲区时,先加锁,再用结构体中的索引读写循环数组,索引位置循环自增,再释放锁。
- 当需要阻塞时,发送端或接收端会调用协程调度器,阻塞自己并让出系统线程。
- 当接收队列中存在阻塞协程时,缓冲区肯定是空的,发送端会直接复制数据到接收栈中,不会经过缓冲区也不需要加锁。
- 当发送队列中存在阻塞协程时,缓冲区肯定是满的,接收端需要从缓冲区读取数据,再将发送者数据写入缓冲区,然后才唤醒发送者。
通道的操作流程图(有缓冲区情况):
内存分配
栈
- 栈是专属于协程的内存空间,用于存储局部变量、函数参数、返回值等。
- 栈内存分配的单位是栈帧。
- 栈帧大小在编译期确定,由编译器分配和释放。
- 在函数调用开始时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工具。