GO 训练营第 12、13 周—— runtime
Goroutine 原理
模型
gorouine 可以理解为比线程轻量级的“线程”,轻量指的是切换、创建、销毁消耗小,内存占用少;且采用线程/goroutine = M:N 模式,同一时刻线程只能处理一个 goroutine,其它是暂停的,但通过一定的调度逻辑使多个 goroutine 并发执行,这样的模式能够让多个线程托管大量的 goroutine。
GMP 调度
G 是 goroutine;M 指的是线程;P 是 CPU 的抽象,主要有个队列功能,所以可以把它当队列看。
早期因为是全局锁的方式去调度 M 和 G,后来为了性能考虑引入了 P,它的数量决定了并行运行的线程任务数量,P 会在队列(本地或全局)有任务(goroutine 等待执行)时创建或调度一个 M 来执行。
另外 P 的本地队列为了避免锁的性能消耗,使用了 lock-free 的方法。
M 的调度策略通俗描述:没 P 找 P,有 P 找 G。
存在的问题:
goroutine 没有关联性的优先级,可能会存在多个 goroutine 等一个核心条件的 goroutine 完成才能继续,这时候无法得到合理的调度。
Working-stealing
由于有多个 P,且其有本地队列,加上全局队列,调度可能会出现以下问题:
如果 P 只执行其本地队列,则某个 P 任务少的话则会空闲;
如果本地队列都比较满,那全局队列就没 P 接管,则会产生饥饿;
解决它们需要 Working-stealing 算法,它指导空闲的 P 去尝试获取别的 P 的本地队列,且平时会时不时到全局队列拿任务来处理。
Syscall
如果 goroutine 执行了 syscall,则会使 M 也处于阻塞状态(因为实际干活的操作系统的线程),此时的 P 也会进入 syscall 状态,暂时不绑定其它的 M,等待一小段时间,如果在此期间 M 处理完成则可以在不用切换数据上下文的情况下完成系统调用,保证数据的局部性(因为每个 CPU 有自己的缓存,如果线程切换了 CPU 就需要缓存的同步,降低性能),如果“超时了”则会把 P 分配给其它的 M,这个“超时”策略是由 sysmon 负责的。
go 没有限制阻塞调度的 goroutine 数量,即没有限制阻塞的线程数量,所以用阻塞的系统调用时需要考虑操作系统线程耗尽的问题。
线程自旋
如果 M 要找 P 运行,会自旋查找队列,这样会消耗一定的 CPU 资源,但也能提高调度的效率,一小段时间的自旋是比较通常的做法。
Network poller
go 的 I/O 都是阻塞的,但它仅是阻塞 goroutine,不会阻塞线程;
被 I/O 阻塞的 G 在 schedule, sysmon, start the world 的情况下被重新调度;
优点: goroutine 里的写法都是同步的,代码可读性好;
Scheduler Affinity
在队列里等待的 G 一般是 FIFO 的,但有些后进的 G 已经先从阻塞中恢复了,可以通过 P 的 runnext 机制提高其被调度的优先级。
Goroutine Lifecycle
start
启动 m0 主线程,初始化 g0 负责 schedule,初始化 P, sysmon 线程, GC 协程;
然后 P 负责创建 os thread 关联 M;
G 切换时,暂存当前的 PC 及 GO 堆栈即可;
回收:G 用完会放到空闲列表,由 P 来负责回收。
内存分配原理
堆栈
栈由编译器决定,分配无锁较轻量,一般是局部变量;
堆由编译器与开发者共同管理分配,需要配合 GC 回收,分配需加锁较昂贵;
局部变量如果较大,也会被分配在堆上;
内存逃逸
原本是栈上的变量,需要在栈外时则会分配在堆上,即变量的作用域从当前栈内变为栈外的域;
下图举了个栈上变量逃逸到堆的例子:
分析方法:go build -gcflags '-m'
原则:应该尽量减少内存逃逸,减轻 GC 压力和内存碎片;
连续栈
问题:程序运行需要的栈空间有时会超出 goroutine 初始化的栈大小,需要进行栈扩容。
解决:
go1.3 之前使用分段栈,即栈被分成多个段,像链表一样串起来。但存在 hot split 问题,即程序的栈空间变化大,一直在扩段和缩段之间频繁切换,导致频繁的 alloc/free,导致性能问题。
go1.3 之后采用连续栈,即栈不够时通过拷贝数据到更大的连续栈空间来扩容栈,且会在栈空间使用率较小时自动缩小栈空间。
栈空间扩缩需要在运行时检测栈的长度,这是在编译时候通过插入 runtime.morestack 来判断,不是每个函数都插入,会根据当前剩余的栈长度判断,且通过几种栈阈值尽量减少计算,保证性能损失最小化。
内存结构
问题:
内存碎片;
内存申请要加锁,锁开销大,影响性能;
借鉴 TCMolloc 的设计;
分为 mspan, mcache, mcentral, mheap, 系统内存;
mspan 为 go 内存的最小分配块;
mspan 包含多个 page,每个 page 是 8KB,page 会根据 sizeclass(内存规格)均分为多个 object,且 sizeclass 有多种类型,如 8byte, 16byte, 32byte 等,go 会根据当前所需对象大小来分配;
mcentral 管理全局线程的 mspan;
mcache 管理当前 P 的 mspan;
如果 mcache 内存不够,则通过 mcentral 申请 mspan;
如果 mcentral 内存不够,则向 mheap 申请,如果 mheap 也不够就向系统内存申请;
使用:
分配 16B 以下的内存对象使用 tiny;
分配 32KB 以下的内存对象使用上述多层级管理;
分配 32KB 以上的直接到 mHeap 申请。
优点:
在 mcache 中,因为一个 P 同时刻只运行一个 goroutine,所以分配内存是无锁的;
mspan, page, object 的形式,为对象分配的特定长度的内存,避免内存碎片;
缺点:
object 内存存储完对象后可能还剩余一些空间,造成利用率下降。
优化实践
• 小对象结构体合并:结构体嵌入值对象,只初始化一次,减少对象数量
• bytes.Buffer
• slice、map 预创建:避免内存数据迁移、创建、销毁;
• 避免长调用栈导致的栈扩容
• 避免频繁创建临时对象
• 字符串拼接 strings.Builder
• 不必要的 memory copy:writev, readv
• 分析内存逃逸
GC 原理
Mark & Sweep
mark, 标记,标记追踪式算法;
sweep,标记没有被使用的对象将被“清理”;基于 span 中的位图 gcmarkBits 来判断是否能被清理;有即时清理和分配时清理两种方式;
两者是并行执行的;
存在的问题:
早期使用暂停 STW(stop the world,暂停其它所有线程的运行)的方式,安全地标记内存使用,但对性能影响很大。
Tri-color Mark & Sweep
三色标记法,黑为活跃对象,灰为活跃对象但存在引用白色对象,白为不活跃对象。
整个过程是:首先所有节点是白,从根节点出发,先把根染为灰色,下一步再把灰色染为黑,把灰的白色染为灰,直到所有对象都染色。
Write Barrier
写屏障+删屏障 = 混合屏障
Channel 原理
一般作为多个 goroutine 通信的通道。
特点
goroutine 安全;
在 goroutine 间存储或传输数据;
消息先进先出;
缓冲或无缓冲通道会引起 goroutine 非阻塞或阻塞;
原理
以下是 chan 的核心结构
circular queue 表示环形队列,是 chan 存数据的结构,当队列满了,停止接收数据,阻塞发送的操作;
send index, receive index 分别表示接下来要发送或接收的数据在环形队列中的链表,存储了等待中的 goroutine 及数据引用,相当于为两个 goroutine 的栈数据打通了直接访问的通道,这样的好处是,数据不需要重新到环形队列获取,提高了效率;
mutex 是互斥锁,发送和接收都是通过加锁实现的,也保证了当前 chan 只能有一个 goroutine 在通信,比如接收或发送;
chan 对象在堆内存,创建的 chan 对象是引用类型,所以作为函数参数时不需要使用指针类型就能访问到实参的数据;
使用
参考:https://www.ardanlabs.com/blog/2017/10/the-behavior-of-channels.html
经验
chan 和 proc 的代码值得看;
挑感兴趣的先看原理再看源码;
go 性能优化相关文章:
fasthttp:
https://mp.weixin.qq.com/s/7gaSxmA1aqfxfbGpMeZK-A
goroutine 池:
https://www.jianshu.com/p/8f8abb504267
https://www.cnblogs.com/williamjie/p/9267741.html
工具:
https://github.com/dgryski/go-perfbook
https://cch123.github.io/perf_opt/
评论