go runtime

#runtime #GMP #goroutine

go语言组成有两部分,一部分是用户程序代码,一部分是runtime,runtime作用是为了实现额外功能,在程序运行时自动加载/运行的的一些模块,runtime由4部分组成:

  • Scheduler: 调度器管理所有的GMP,在后台执行调度循环。
  • Memory Management: 当代码需要内存时,负责内存分配工作。
  • Garbage Collector: 当内存不再需要时,负责回收内存。
  • Netpoll: 网络轮询负责管理网络FD相关的读写、就绪事件。


调度器 Scheduler

协程调度器GMP

调度器本质是一个生产-消费流程,用户在程序中执行go func{}生成一个协程实体,提交到协程调度器,线程来执行(消费)。

  • G: goroutine,一个计算任务。由需要执行的代码和其上下文组成,上下文包括:当前代码位置,栈顶、栈底地址,状态等。
  • M: machine,系统线程,执行实体,想要在CPU上执行代码,必须有线程,与C语⾔中的线程相同,通过系统调⽤clone来创建。
  • P: processor,虚拟处理器,M必须获得P才能执行代码,否则必须陷入休眠(后台监控线程除外),你也可以将其理解为⼀种token ,有这个token,才有在物理CPU核心上执行的权力。


协程框架图

协程产生框架图

上图左边是表示协程生产过程,包括虚拟处理器部分P和队列部分,每个P下面有runnext和local run quene,而global run quene是全局链表,所有P都可以共享,G执行优先级别最高是runnext,其次是本地队列,最后是全局队列。

  • runnext: 下一个执行的G,类型是一个值。
  • local run quene:每个P自己队列,类型是数组,最大长度为256。
  • global run quene,全局队列,类型是链表,长度没有限制。
为什么队列要分为本地队列和全局队列?

为了在性能上达到更好目标,每个P执行自己的本地队列,不需要枷锁,而不同P之间频繁从全局队列获取G时要加锁的,队列分级就是避免频繁加锁,提高并发性能。


为什么最新创建的协程会被放到runnext去优先执行?

在计算机执行过程中,程序分为代码的局部性和数据的局部性,根据局部性原理,最近调用的代码,很大概率需要再一次调用,优先级更高,程序执行到当前时刻,变量和数据很大概率在当前CPU访问的cache里,因此访问效率也是最高的。刚刚创建的G很大概率是高优先级的G,因此放到runnext去优先执行。


上图的右边是协程的消费端,包括系统线程部分,工作的线程绑定P后一直调度循环,线程是按需创建的,空闲的线程在队列里,需要时再拿出来。


协程生产端

新创建的协程加入队列的流程图

协程加入队列的流程

使用go func()函数,通过newproc打包生产一个G,newproc里面做了申请栈、判断当前runnext、本地队列、全局队列是否需要对已存在的G进行转移,有三种情况:

  • 第一种情况:runnext为空,新创建的G直接放到runnext去执行。
  • 第二种情况:runnext为不空,本地队列未满(最大256),把runnext旧的G转移到本地队列,新创建的G放到runnext去执行。
  • 第三种情况:runnext为不空,本地队列已满(最大256),把runnext旧的G和和本地队列的一半G放到全局队列(全局队列时链表,理论是无限大),新创建的G放到runnext去执行。


协程消费端

协程消费端框架图

协程消费流程


消费过程:

每循环调度一次schedtick值加1,每轮询60次本地队列,就去全局队列获取,目的是让全局队列的G也有机会被执行。

schedtick对60取模等于0的消费过程

全局队列不为空,本地队列不为空情况,从全局队列获取一个G来执行。


schedtick对60取模不为0的消费过程
  • 当runnext有G,直接从runnext获取G执行。
  • 当runnext为空,本地队列有G,从本地队列中获取G执行
  • 当runnext为空,本地队列为空,全局队列有G,从全局队列获一批G来执行,从全局队列获取G数量规则是从全局队列获取一半,如果数量超过128,最大值取128,获取的是全局队列的尾部。
  • 当runnext为空,本地队列为空,全局队列为空,查询其他线程的本地队列是否有G,如果其他P的本地队列有G,就从其他P的本地队列偷取一半(后半部分)到本地队列执行,如果其他P的本地队列也为空,则挣扎一下再查询一遍,如果全局和其他P都为空,然后进入休眠状态。


阻塞

上面的goroutine都是正常执行,当goroutine出现阻塞怎么处理呢。有些阻塞可以被runtime拦截,有些阻塞不能被runtime拦截。


runtime可以拦截的阻塞

常见会出现阻塞场景

(1) 调用time.Sleep函数

(2) 一直往channel写数据,另一端没来得及读取channel

(3) 一直读取channel数据,另一端没来得及写数据到channel

(4) 使用select,如果都没有出发channel,会阻塞

selct {
    case <-c1:
        fmt.Println("c1 read")
    case <-c2:
        fmt.Println("c2 read")    
}

(5) 锁,当资源被锁了,还没释放,另一个goroutine获取不到锁,出现阻塞

(6) 网络读写

var conn net.Conn
var buf = make([]byte, 1024)

// 读,没数据时阻塞
conn.Read(buf)

// 写,缓冲满时阻塞
conn.Write(buf)

阻塞内部结构


runtime 不能拦截的阻塞

有些阻塞runtime不能被捕获到,例如cgo、系统调用,执行c代码或系统调用时,如果长时间运行需要剥离P执行,单独占用⼀个线程。

在协程系统调用


阻塞处理

如果一个8核处理器的8个线程同时都执行系统调用,而且都阻塞了,怎么办?

需要一个专有线程sysmon(system monitor)专门处理这个问题,sysmon线程拥有优高先级,而且不需要绑定P就可以执行。

sysmon主要功能有三个:

  • checkdead: 检查所有线程是否都已经被阻塞了,如果是,则抛出异常,如果只是网络服务,这个检测不起作用,因为accept是正常运行的,不要被字面意思误解为可以检查死锁。
  • netpoll: 将g列表注入全局运行队列。
  • retake: 如果是syscall卡了很久,那就把p剥离(handoffp),如果是用户g运行很久了(10ms),那么发信号SIGURG抢占。



内存管理 Memory Management

内存管理的三个角色

角色 说明
Mutator fancy(花哨的) word for application ,其实就是你写的应用程序,它会不断地修改对象的引用关系,即对象图。
Allocator 内存分配器,负责管理从操作系统中分配出的内存空间,malloc 其实底层就有⼀个内存分配器的实现(glibc中),tcmalloc是malloc多线程改进版。 Go中的实现类似tcmalloc 。
Collector 垃圾收集器,负责清理死对象,释放内存空间。


内存管理概览

内存内部结构图


内存管理抽象

内存管理抽象


进程对应虚拟内存布局

进程对应虚拟内存布局


内存分配器类型

(1) 线性分配器(Bump/Sequential Allocator)

Bump Sequential不会复用已经释放的内存,产生比较多内存碎片,基本不使用,Sequential Allocator可以复用已经释放内存,但是要额外维护一个free链表。


(2) 空闲链表分配器(Free List Allocator)

空闲链表分配器有first-fit、next-fit、best-fit、segregate-fit几种,go使用的是segregate-fit,减少内存碎片。


go语言内存分配

执行malloc时

  • 分配内存小于128kb,brk只能通过调整 program break 位置推动堆增⻓
  • 分配内存大于128kbmmap 可以从任意未分配位置映射内存


内存分配器在 Go 语⾔中维护了⼀个多级结构:mcache –> mcentral –> mheap

类型 说明
mcache 与P绑定,本地内存分配操作,不需要加锁。
mcentral 中⼼分配缓存,分配时需要上锁,不同spanClass使⽤不同的锁
mheap 全局唯⼀,从OS申请内存,并修改其内存定义结构时,需要加锁,是个全局锁。

go的内存分类,预先分配好内存。

// class  bytes/obj  bytes/span  objects  tail waste  max waste  min align  
//     1          8        8192     1024           0     87.50%          8  
//     2         16        8192      512           0     43.75%         16  
//     3         24        8192      341           8     29.24%          8  
//     4         32        8192      256           0     21.88%         32  
//     5         48        8192      170          32     31.52%         16  
//     6         64        8192      128           0     23.44%         64  
//     7         80        8192      102          32     19.07%         16  
//     8         96        8192       85          32     15.95%         32  

...

//    66      28672       57344        2           0      4.91%       4096  
//    67      32768       32768        1           0     12.50%       8192


堆内存管理有Tiny alloc、Small alloc、Large alloc几种方式

  • Tiny alloc分配内存


  • Small alloc分配内存

Small alloc分配内存


  • Large alloc分配内存

⼤对象分配会直接越过mcache 、 mcentral ,直接从mheap进⾏相应数量的page分配,pageAlloc 结构经过多个版本的变化,从: freelist -> treap -> radix tree ,查找时间复杂度越来越低,结构越来越复杂。


Refill 流程:

  • 本地 mcache 没有时触发 (mcache.refill)
  • 从 mcentral ⾥的 non-empty 链表中找 (mcentral.cacheSpan)
  • 尝试 sweep mcentral 的 empty , insert sweeped -> non-empty(mcentral.cacheSpan)
  • 增⻓ mcentral ,尝试从 arena 获取内存 (mcentral.grow)
  • arena 如果还是没有,向操作系统申请 (mheap.alloc)

最终还是会将申请到的mspan放在mcache中,如下图所示:

refill过程


mspan内部结构

mspan内部结构


go的变量分配在栈和堆是由编译器自动分配的,编译器如果能在编译期间确定变量的生命周期,就会在栈上分配,否则就是逃逸行为,需要在堆上分配内存。分配效率栈大于堆,空间大小堆大于栈。

常见变量逃逸场景:

  • 函数返回内部变量的指针
  • 发送指针或带有指针的值到 channel 中
  • 在一个切片上存储指针或带指针的值
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量(cap)
  • 在 interface 类型上调用方法
  • 申请内存容量过大

编译过程进行逃逸分析命令:

# 示例
go build -gcflags="-m" main.go

# 参数-m越多,打印信息越详细
go build -gcflags="-m -m" main.go



垃圾回收 Garbage Collector

内存垃圾类型分为语义垃圾和语法垃圾两种。

语义垃圾(semantic garbage),有的被称作内存泄露,语义垃圾指的是从语法上可达 ( 可以通过局部、全局变量引⽤得到 ) 的对象,但从语义上来讲他们是垃圾,垃圾回收器对此⽆能为⼒。

type a sturct {
    
}

s:=make([]*a, 10,10)
s=s[:5]

// 后面5个在堆上的内存语义上是应该回收,实际是一直占用内存的


语法垃圾(syntactic garbage),那些从语法上⽆法到达的对象,这些才是垃圾收集器主要的收集⽬标。

func fHeap()  {  
   s := make([]int, 10240)  
   fmt.Println(s)  
}

// 执行完函数,变量s内存会被回收


垃圾回收算法:

  • 引用计数 (Reference Counting) :某个对象的根引用计数变为0时,其所有子节点均需被回收。
  • 标记压缩 (Mark-Compact) :将存活对象移动到⼀起,解决内存碎片问题。
  • 复制算法 (Copying) :将所有正在使⽤的对象从From复制到To空间,堆利用率只有⼀半。
  • 标记清扫 (Mark-Sweep) :解决不了内存碎片问题。需要与能尽量避免内存碎片的分配器使用,如tcmalloc,go使用的垃圾回收算法。


触发gc条件:

  • 人工runtime.GC
  • 需要分配内存时runtime.mallocgc
  • 强制gc forcegchelper


三色抽象:

颜色 说明
已经扫描完毕,⼦节点扫描完毕,(gcmarkbits = 1,且在队列外)
已经扫描完毕,⼦节点未扫描完毕。(gcmarkbits = 1,在队列内)
未扫描,collector不知道任何相关信息,标记结束后被回收的对象


具体过程看 https://www.kancloud.cn/aceld/golang/1958308#GC_376


具体过程

垃圾回收以STW作为界限可以分为5个阶段:

gc基本流程


阶段 说明 赋值器状态
GCoff 内存归还阶段,将内存依照策略归还给操作系统,写屏障关闭 并发
SweepTermination 清扫终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障 STW
Mark 扫描标记阶段,与赋值器并发执行,写屏障开启 并发
MarkTermination 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 STW
GCoff 内存清扫阶段,将需要回收的内存暂存,写屏障关闭 并发

写屏障是一个在并发垃圾回收器中才会出现的概念,垃圾回收器的正确性体现在:不应出现对象的丢失,也不应错误的回收还不需要回收的对象。


(1) 标记设置

收集开始时,必须执行的第一个活动是打开写入屏障。写屏障的目的是允许收集器在收集期间保持堆上的数据完整性,因为收集器和应用程序 goroutine 将同时运行。为了打开 Write Barrier,必须停止运行的每个应用程序 goroutine,这个过程时间非常快,平均在10~30微秒内。


(2) 标记

一旦打开写屏障,收集器就会开始标记阶段,收集器做的第一件事就是为自己至少占用25%的可用CPU容量(如果有4个线程,一个用于执行GC),这个阶段用户gc的goroutine和普通goroutine是并发执行的。如果收集内存对象速度赶不上新内存分配速度,收集器把原来执行应用程序 goroutine用来协助标记工作,这称为标记辅助。任何应用程序 Goroutine 被放置在 Mark Assist 中的时间量与它添加到堆内存中的数据量成正比,Mark Assist的作用是助于更快地完成收集。

收集内存对象


并发标记,默认所有对象都是白色,使用三色标记法,优先扫描各个goroutine的栈对象,从根节点开始遍历所有对象,将可达的对象标记为黑色,再扫描标记堆对象。

并发扫描标记期间,其他goroutine在栈和堆有可能出现新建对象、对象引用指向变更等场景,有些场景会触发写屏障,写屏障只发生在堆的对象,栈对象的引用改变不会引起屏障触发,因为go是并发运行的,大部分的操作都发生在栈上,成千上万goroutine的栈都进行屏障保护会有性能问题。

场景1:并发扫描标记期间其他goroutine在栈或堆上创建的新对象

这些新建对象统一标记为黑色。


场景2:并发扫描标记期间,一个栈对象(编号1)引用一个堆对象(编号7)

因为对象1是在栈区,不启动写屏障,对象1标记为黑色,后面对象7被扫描到时标记为黑色。

栈对象引用堆对象


场景3:并发扫描标记期间,一个新建的栈对象(编号9)引用一个栈对象(编号3),同时原来一个栈对象(编号2)删除引用对象(编号3)

因为对象都是在栈区,不会触发写屏障,对象9标记为黑色,后面扫描到对象3时标记为黑色。

栈对象引用栈对象


场景4:并发扫描标记期间,一个堆对象(编号10)引用一个堆对象(编号7)

因为是在堆区,会触发写屏障,对象10为黑色,此时对象7标记为灰色,下游对象6被保护,后面扫描到对象7和对象6会时会标记为黑色。

堆对象添加引用堆对象


场景5:并发扫描标记期间,一个堆对象(编号4)删除引用堆对象(编号7)

因为对象4是在堆区,会触发写屏障,此时对象7标记为灰色,最终标记为黑色。

堆对象删除引用堆对象


混合写屏障规则:

  • GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)。
  • GC期间,任何在栈上创建的新对象,均为黑色。
  • 被删除的对象标记为灰色。
  • 被引用的对象标记为灰色。

使用变形的弱三色不变式

弱三色不变式


(3) 标记终止

标记工作完成后,下一阶段是标记终止。这阶段关闭Write Barrier,执行各种清理任务,计算下一个收集目标的时时间。在标记阶段发现自己处于紧密循环中的 Goroutines 也可能导致标记终止 STW 延迟延长,这个过程时间非常快,平均在60~90微秒内。


(4) 扫除

收集完成后会发生另一个活动叫扫除(sweeping),扫除是指与堆内存中未标记为正在使用的值关联的内存被回收。当应用程序 Goroutine 尝试在堆内存中分配新值时,会发生此活动。

  • gcStart –> gcBgMarkWorker && gcRootPrepare,这时gcBgMarkWorker在休眠中  
  • schedule –> findRunnableGCWorker,唤醒适宜数量的gcBgMarkWorker
  • gcBgMarkWorker –> gcDrain –> scanobject –> greyobject(set mark bit and put to gcw)
  • 在 gcBgMarkWorker 中调⽤ gcMarkDone 排空各种 wbBuf 后,使⽤分布式 termination 检查算法,进入gcMarkTermination –> gcSweep 唤醒后台沉睡的 sweepg 和 scvg –> sweep –> wake bgsweep && bgscavenge

垃圾回收


Golang各个版本垃圾回收区别:

  • GoV1.3 普通标记清除法,整体过程需要启动STW,效率极低。

  • GoV1.5 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通

  • GoV1.8 三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动,整个过程几乎不需要STW,效率较高。


GC跟踪

在运行任何 Go 应用程序时,可以通过GODEBUG在选项中包含环境变量来生成 GC 跟踪。gctrace=1每次发生收集时,运行时都会将 GC 跟踪信息写入stderr.

# 示例
GODEBUG=gctrace=1 ./app

gc 1405 @6.068s 11%: 0.058+1.2+0.083 ms clock, 0.70+2.5/1.5/0+0.99 ms cpu, 7->11->6 MB, 10 MB goal, 12 P

各个值的含义:
// General
gc 65       : 自程序开始以来运行了65次GC
@6.068s     : 程序开始后的6秒
11%         : 到目前为止,有11%的可用CPU被用在了GC上

// Wall-Clock
0.058ms     : STW        : 标记开始,打开写屏障
1.2ms       : Concurrent : 标记时间
0.083ms     : STW        : 标记终止,写入障碍物关闭和清理

// CPU Time
0.70ms      : STW        : 标记开始
2.5ms       : Concurrent : 标记-辅助时间(GC与分配一致)
1.5ms       : Concurrent : 标记 - 背景GC时间
0ms         : Concurrent : 标记 - 闲置的GC时间
0.99ms      : STW        : 标记终止

// Memory
7MB         : 记开始前使用的堆内存
11MB        : 标记结束后使用中的堆内存
6MB         : 标记结束后,堆内存被标记为活的
10MB        : 标记结束后,堆内存的收集目标是使用中的

// Threads
12P         : 用于运行Goroutines的逻辑处理器或线程的数量


上面是在日志打印每次垃圾回收数据,不够直观,可以使用go tool trace命令,通过图形化界面查看程序生命周期内的所有协程执行过程(包括gc过程)。

(1) 首先在程序中插入跟踪程序代码:

// 在程序当前目录生成trace.out文件
type Trace struct {
    F *os.File
}

func (t *Trace) Start() {
    var err error
    t.F, err = os.Create("trace.out")
    if err != nil {
        panic(err)
    }

    err = trace.Start(t.F)
    if err != nil {
        panic(err)
    }
}

func (t *Trace) Stop() {
    trace.Stop()
    t.F.Close()
}

func main() {
    tr := &Trace{}
    tr.Start()
    defer tr.Stop()
    
    // 你的程序
}


(2) 执行你的程序代码,等待程序正常结束,在程序当前目录下生成trace.out

(3) 查看程序跟踪信息

go tool trace trace.out

在浏览器显示支持跟踪类型

  • View trace
  • Goroutine analysis
  • Network blocking profile (⬇)
  • Synchronization blocking profile (⬇)
  • Syscall blocking profile (⬇)
  • Scheduler latency profile (⬇)
  • User-defined tasks
  • User-defined regions
  • Minimum mutator utilization


点击第一个View trace,选中小控制面板的zoom,在指定位置点击鼠标左键网上拖动放大细节,如下图所示:

垃圾回收跟踪

从图中可以看出,在垃圾回收阶段,处理器1和处理器2是专门用来给收集器收集对象,其中处理器3也会辅助标记,使得更快的完成收集,并发收集对象过程中也有用户程序在执行,同时看到在垃圾回收这个过程出现两次STW。

在垃圾收集启动期间,运行时会调用 runtime.gcBgMarkStartWorkers 为全局每个处理器创建用于执行后台标记任务的 Goroutine,每一个 Goroutine 都会运行 runtime.gcBgMarkWorker,所有运行 runtime.gcBgMarkWorker 的Goroutine在启动后都会陷入休眠等待调度器的唤醒。一般情况下此函数不会占用这么多的 cpu,出现这种情况一般都是内存 gc 问题,如果分配对象的数量非常多,采集器来不及采集对象,就会唤醒runtime.gcBgMarkWorker的goroutine进行台标记。



参考:



专题「golang相关」的其它文章 »