Back

Go Goroutine和GC

go 协程、调度、GC相关原理

[TOC]

Runtime

  • 不同于Java,Go没有虚拟机,很多东西比如自动GC、对操作系统和CPU相关操作都变成了函数,写在runtime包里。
  • Runtime提供了go代码运行时所需要的基础设施,如协程调度、内存管理、GC、map、channel、string等内置类型的实现、对操作系统和CPU相关操作进行封装。
  • 诸如go、new、make、->、<-等关键字都被编译器编译成runtime包里的函数
  • build成可执行文件时,Runtime会和用户代码一起进行打包。

pprof

使用net/http/pprof库,进行HTTP服务进行分析,需要主动开启,比如import _ "net/http/pprof",使用默认的http.DefaultServeMux,默认端口是6060;也可自定义Mux,手动注册路由。

pprof提供应用运行的过程中分析当前应用的各项指标来辅助进行性能优化以及问题排查功能,提供以下功能:

类型 描述
allocs 查询内存分配情况,所有对象的内存分配,在堆(Heap)分配的时候,记录一下调用堆栈。默认情况下,是每 1000 次分配,取样一次,这个数值可以改变。栈(Stack)分配 由于会随时释放,因此不会被内存分析所记录。由于内存分析是取样方式,并且也因为其记录的是分配内存,而不是使用内存。开启后会对runtime产生压力,通过runtime.MemProfileRate设置采样的内存比例,默认大小是512kb。
blocks 查询阻塞操作情况,类似于 CPU 性能分析,但是它所记录的是 goroutine 等待资源所花的时间。阻塞分析对分析程序并发瓶颈非常有帮助,阻塞性能分析可以显示出什么时候出现了大批的 goroutine 被阻塞了。阻塞性能分析是特殊的分析工具,在排除 CPU 和内存瓶颈前,不应该用它来分析。
cmdline 应用启动命令及参数
goroutine 当前所有协程的堆栈信息,开启时会STW
heap 堆上内存使用情况采样信息,活跃对象的内存分配
mutex 锁持有的堆栈,次数(采样)的信息
profile CPU占用情况采样,启动后会对runtime产生压力,runtime每10ms会STW,记录当前运行的 goroutine 的调用堆栈及相关数据
threadcreate 系统线程创建情况的采样信息,不会STW
trace 程序运行跟踪信息,跟踪GC和G调度
  • curl http://ip:port/debug/pprof/{上面列表的功能} > profile文件名 把此时的统计下载下来;像trace、profile 默认采集时间是30s,可以使用参数 seconds=xx 来调整采样时间;

  • 除了trace使用go tool trace 文件名打开外,其他都是使用go tool pprof -http=:8080 profile文件名,打开web终端,左上角view里就有各种视图;

  • 关于火焰图

    Y轴表示调用栈,从上到下每一层都是一个函数,调用栈越深火焰就越高,最底部是正在执行的函数,上面是它的父函数;

    X轴表示这个函数的抽样数,如果一个函数在X轴占的越宽,代表抽样数越高,CPU占用的时间越长;注意,X轴不代表时间,而是所有的调用栈合并后,按字母顺序排列的;

    火焰图就是看顶层的哪个函数占据的宽度最大。只要有"平顶"(plateaus),就表示该函数可能存在性能问题。

  • debug包下有几个可以读取运行时指标的方法,但需要手动编写输出

    debug.ReadGCStatus(),具体的指标可以看debug.GCStats{}里的字段

    runtime.ReadMemStats(),具体的指标可以看runtime.MemStats里的字段

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    func printGCStats() {
        // 每隔一秒钟监控一次 GC 的状态
        t := time.NewTicker(time.Second)
        s := debug.GCStats{}
        for {
            select {
            case <-t.C:
                debug.ReadGCStats(&s)
                fmt.Printf("gc %d last@%v, PauseTotal %v\n", s.NumGC, s.LastGC, s.PauseTotal)
            }
        }
    }
    func main() {
        go printGCStats()
        (...)
    }
    

netpoller

参考:https://strikefreedom.top/go-netpoll-io-multiplexing-reactor,讲得足够详细了

https://www.luozhiyun.com/archives/439

https://zhuanlan.zhihu.com/p/272891398

netpoller本质上是利用操作系统本身的非阻塞IO模型 + IO多路复用的封装,从而实现goroutine-per-connection模式,使用同步编程模式达到异步执行的效果。

netpoller算是运行在go scheduler中的一个子系统,但它主要是处理网络请求的,使其不阻塞整个调度。

netpoller中的几个方法:对epoll的封装 netpollinit、netpollopen、netpollnet.listenListener.AcceptConn.Read / Conn.Write,对网络的fd会设置成NonBlocking模式,根据不同的返回值设置G的状态,在M的调度、sysmon、gc STW结束等阶段会poll出ready的G进行运行或者添加到GRQ中。

这里记一下Go网络相关的goroutine是如何被规划调度的:

首先,client 连接 server 的时候,listener 通过 accept 调用接收新 connection,每一个新 connection 都启动一个 goroutine 处理,accept 调用会把该 connection 的 fd 连带所在的 goroutine 上下文信息封装注册到 epoll 的监听列表里去,当 goroutine 调用 conn.Read 或者 conn.Write 等需要阻塞等待的函数时,会被 gopark 给封存起来并使之休眠,让 P 去执行本地调度队列里的下一个可执行的 goroutine,往后 Go scheduler 会在循环调度的 runtime.schedule() 函数以及 sysmon 监控线程中调用 runtime.netpoll 以获取可运行的 goroutine 列表并通过调用 injectglist 把剩下的 g 放入全局调度队列或者当前 P 本地调度队列去重新执行。

当IO事件发生之后,netpoller通过 runtime.netpoll 即可在epoll监听列表获取到已就绪的fd列表对应的goroutine,具体:runtime.netpoll会调用 epollwait 等待发生了可读/可写事件的fd,循环 epollwait 返回的事件列表,处理对应的事件类型,组装可运行的goroutine链表并返回。

sysmon监控线程会在循环过程中检查距离上一次 runtime.netpoll 被调用是否超过10ms,若是则回去调用它拿到可运行的goroutine列表并调用 injectglist 把 g 列表放入全局调度队列或者当前 P 本地调度队列等待被执行。

即当处理网络IO的goroutine被阻塞住时,通过netpoll + 非阻塞IO,让G不会因为系统调用而陷入内核态,只是被runtime调用gopark住,此时G会被放置到某个wait queue中;当IO可用时,在 epoll 的 eventpoll.rdr 中等待的 G 会被放到 eventpoll.rdllist 链表里并通过 netpoll 中的 epoll_wait 系统调用返回放置到GRQ或者 P 的LRQ,标记为 _Grunnable ,等待 P 绑定 M 恢复执行。

Goroutine

基本

  • GPM模型 - M:N调度模型

    模型分类:

    • N:1 即 N个协程绑定1个线程

      优点:协程在用户态线程即可完成切换,由协程调度器调度,不涉及内核态,无需CPU调度,轻量快速;

      缺点:无法使用多核加速,一旦某协程阻塞,会导致线程阻塞,此时并行变成串行;

    • 1:1 即 1个协程绑定1个线程

      优点:解决N:1模型的缺点;

      缺点:调度均有协程调度器和CPU调度,代价较大,无法并行;

    • M:N 即 M个协程绑定N个线程,由协程调度器调度,线程在内核态通过CPU抢占式调用,协程在用户态通过协作式调度

  • 一般线程会占有1Mb以上的内存空间,每次对线程进行切换时会消耗较多内存,恢复寄存器中的内容还需要向操作系统申请或销毁对应的资源,每一次上下文切换都需要消耗~1us左右的时间,而Go调度器对 G 的上下文切换为~0.2us,减少了80%的额外开销。

  • 协程本质是一个数据结构,封装了要运行的函数和运行的进度,交由go调度器进行调度,不断切换的过程。由go调度器决定协程是运行,还是切换出调度队列(阻塞),去执行其他满足条件的协程。

    go的调度器在用户态实现调度,调度的是一种名叫协程的执行流结构体,也有需要保存和恢复上下文的函数,运行队列。

    协程同步造成的阻塞,只是调度器切换到别的协程去执行了,线程本身并不阻塞。

    抢占:在协程中,要等到一个协程主动让出CPU才能执行下一个协程

  • Go的调度器通过使用与CPU数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的Goroutine来降低操作系统和软件的负载。

  • 1.2~1.3版本使用基于协作的抢占式调度器(通过编译器在函数调用时(函数头或尾)插入抢占式检查指令,比如栈扩容检查,在函数调用时检查当前G是否发起抢占式请求),但 G 可能会因为垃圾回收和循环时间太长,占用资源导致没有让出CPU,造成其他goroutine饿死,甚至让程序暂停;

    从1.14版本开始使用基于信号的抢占式调度,会给需要处理的函数绑上signhandler,处理SIGURG信号,当线程M接收到信号后,会从用户栈 G 切换到gsingal执行信号处理逻辑,实现调度,比如垃圾回收在扫描栈时会触发抢占式调度,sysmon是其中一种实现,sysmon会定时进行监控,触发调度;

    之所以要使用信号抢占式的,是因为如果是 G 主动让出CPU资源才能触发调度,可能会导致某个 G 长时间占用线程,造成其他 G 饿死;另外,垃圾回收需要暂停整个程序,在STW时,整个程序无法工作。

    关于抢占和协作,go本身的调度模型既包含了抢占,也包含了协作;

    • 协作式:各个任务相互合作,程序主动在需要等待的时候让出控制权,比如golang是由编译器隐式插入yield节点,所以程序可以知道各个任务挂起和恢复时需要哪些信息,而OS的线程调度器不知道,也不需要知道,交由任务自己决定;
    • 抢占式:各个程序不协作,相互抢占,由外部的调度器决定运行哪一个任务,所以程序不需要关心用户代码和处理协作的细节,也不知道各个任务挂起和恢复时的信息,而是由OS直接把整个调用栈的状态进行保存和恢复;

早期调度模型-MG模型

goroutine early schedule
goroutine early schedule

线程M想要处理协程G,都必须访问全局队列GRQ,当多个M访问同一资源时需要加锁保证并发安全,因此M对G的创建,销毁,调度都需要上锁,造成激烈的锁竞争,导致性能较差。

另外,当M0执行G0,但G0又产生了G1,此时为了继续执行G0,需要将G1移给M1,造成较差的局部性,因为一般情况下这两个G是有一定的关联性的,如果放在不同的M会增加系统开销;CPU在多个M之间切换也增加了系统开销。

为了解决早期调度器模型的缺点,采用了GMP模型。

调度器的GPM模型

goroutine完全运行在用户态,借鉴M:N线程映射关系,采用GPM模型管理goroutine。

  • G:即goroutine,代码中的go func{},代表一个待执行的任务

    • 一个G最多占有CPU 10ms,防止其他G饿死。
    • 每个Goroutine都有自己独立的栈存放当前的运行内存及状态,当G被调离CPU时,调度器会负责把CPU寄存器的值保存在G对象的成员变量中,当G被调度运行起来时,调度器又会负责把G对象的成员变量所保存的寄存器的值恢复到CPU的寄存器中。

    栈扩张:每一个G都有自己的栈空间,为了避免G过多导致使用过多的内存,一开始只会分配给G一块很小的栈空间,比如2k,当函数发现栈空间不足时,就会申请一块新的栈空间并把原来的栈内容复制过去,这就是G中_Gcopystack状态,64位机器最大是1GB,32位为256MB。

  • M:即machine,操作系统的线程,与一个内核线程绑定,由操作系统的调度器调度和管理。每个线程有一个TLS(Thread-Local Storage),用于保存线程中的本地数据,比如栈的起止位置、当前正在执行的G、M本身是否空闲等信息、通过指针维持与P结构体实例对象的绑定关系、获取系统线程中当前的G和G所属的M实例等;

    • M的数量不一定和P匹配,可以设置多个M,M和P绑定后才可运行,多余的M会处于休眠状态;

    • 调度器最多可创建10000个M,但最多只有GOMAXPROCS个活跃线程能够正常运行,或者自旋;

      所以一般情况下,会设置与P一样数量的M,让所有的调度都发生在用户态,减少额外的调度和上下文切换开销;

    • M被创建的时机:当没有足够的M来关联P,并运行P中LRQ的G,或者所有的M都被阻塞住时,就回去空闲M链表中查找M,如果还没有,就会创建新的M;

  • P:即processor,处理器的抽象,运行在线程上的本地调度器,用来管理和执行goroutine,使得goroutine在一个线程上跑,提供了线程M需要的上下文(局部计算资源,用于在同一线程写多个goroutine的切换),负责调度线程上的LRQ,是实现从N:1到N:M映射的关键,存在的意义在于工作窃取算法,同时也是为了保证在发生系统调用时,M阻塞,此时P就可以交给其他M继续执行。

    • P的个数取决于GOMAXPROCS,默认使用CPU的个数,这些P会绑定到不同内核线程,尽量提升性能,让每个核都有代码在跑。在确定了P的最大数量n后,当程序运行时,创建n个P,P代表代表并发度;

    • P包含一个LRQ(Local Run Queue本地运行队列),保存P需要执行的goroutine的队列。LRQ是一个长度为256的环形数组,有head和tail两个序号,当数量达到256时,新创建的goroutine会保存在GRQ中,LRQ中前一半G打乱顺序后也放到GRQ中;

      当在G0中产生G1,此时会G1会优先加入当前的LRQ队列,保证其在同一个M上执行;

    • P本身还会维护一个local freelist G用于复用G,类似复用池,在短时间内大量创建G且执行完成的时间很短时用处比较大;

  • 全局运行队列GRQ(Global Run Queue):由调度器本身持有,保存所有未分配的goroutine,保存在全局遍历sched中。GRQ是一个链表,有head,tail两个指针。

    在没有P的情况下,所有G只能放在一个GRQ(全局队列)中,当M执行完G,且没有G可执行时,必须锁住该全局队列才能取G。

  • 空闲的M链表:主要用于保存无G可运行时而进入休眠的M,也保存在全局变量sched中,进入休眠的M会等待信号量m.park的唤醒。

  • 空闲的P链表:当无G可运行时,拥有P的M会释放P并进入休眠状态,释放的P会变成空闲状态,加入到空闲的P链表中,也保存在全局变量sched中,当M被唤醒时,其持有的P也会重新进入运行状态。

GRQ、空闲M链表、空闲P链表、复用G列表等都存放在schedt结构体中,该结构体在整个go程序中只有一个实例对象,是一个全局变量。

go中还有特殊的M和G, 它们是M0和G0.

M0是启动程序后的主线程, 这个M对应的实例会在全局变量M0中, 不需要在heap上分配;

M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了;

G0是仅用于负责调度的G,G0不指向任何可执行的函数,每个M都会有一个自己的G0

G0的栈大小是固定的,为8MB,不能动态伸缩,而普通G是2KB,可动态伸缩;

在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0,其他G的栈扩容也是由G0来执行;

GPM三者的关系

  • 持有P的M会执行G,执行完G后会先判断是否满足61次调用,如果是,则先从GRQ中获取,否则继续从P的LRQ中继续获取G进行消费;之所以先有次数判断,是为了防止GRQ里的G被饿死

    当发现P的LRQ中没有其他G可执行时,则会从GRQ的队首里获取G放入本地队列,获取数量n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2)),一次不会获取太多(一次不超过128个),保证其他M在GRQ中有G可拿,获取时会加锁

    当P发现自己的LRQ、GRQ都没有G了,会从netpoller(网络轮询器)上获取G来执行;

    如果LRQ、GRQ、netpoller上都没有G时,则会随机从其他P的LRQ队首中窃取一半的G放到自己的LRQ,其他P的LRQ队首的第一半+1个拿来执行;通过CAS获取G;(work stealing 工作窃取

    获取到的G都会先放到P的runnext队列(该队列的长度是1),然后再交由M执行;

    以上过程由M执行 runtime.schedule 函数来执行调度。

  • 当P的LRQ满时,会把当前P的LRQ前一半的G打乱顺序后转移到GRQ;

    如果此时runnext也有G,则新创建的G会替换runnext中的G,原来runnext中的G跟着LRQ中挑出来的那些G一起加入GRQ;

  • 当P的LRQ和调度器的GRQ都没有可执行的G时,M进入自旋状态;

    M进入自旋,是为了避免频繁的休眠和唤醒产生大量的开销,当其他G准备就绪时,首先被调度到自旋的M上,其次才是去创建新线程;

    自旋只会持续一段时间,如果自旋期间没有G需要调度,则之后会进入休眠状态,加入空闲M链表,等待被唤醒;

    M自旋时会调用G0协程,G0协程主要负责调度时协程的切换;

    M是否新建取决于正在自旋的M或者休眠的M的数量;

  • 当M执行的G发生阻塞时,将挂起当前M和G,从当前P中摘除,从空闲M链中唤醒一个M服务这个P,执行这个P的LRQ上的其他G;(挂起时,M的现场信息保存在G上,如果G还没执行完,M也可以被复用,再次执行时,就可以从G上恢复M的现场,继续执行)

    如果当前P的LRQ、调度器的GRQ上没有G,则P加入空闲P链表,等待M来获取可用的P;

    阻塞结束后,该M和G会尝试找回原来的P(有利于数据局部性),如果原来的P没空,则获取其他空闲的P,放入其LRQ,等待执行;如果没有空闲的P,则G放入GRQ,M继续休眠;(hand off策略

  • G被调用运行时,会尝试唤醒其他空闲的M和P进行组合,由于M和P由于刚被唤醒,进入自旋状态,G0发现P的本地队列没有G,则先去GRQ里进行working stealing,如果GRQ里没有,则去其他P的LRQ里working stealing;

通过netpoller进行网络系统调用,调度器可以防止G在进行这些系统调用时阻塞M,使得M可以执行P的LRQ中的其他G,而不需要使用新的M,该G在netpoller执行完后,会重新回到之前P的LRQ中(或者被放到GRQ),等待调用。执行网络系统调用不需要额外的M,netpoller使用系统线程时刻处理一个有效的事件循环(通过linux上的epoll实现,网络相关fd与G绑定),即在这种场景下,G会和M、P分离,G挂在了netpoller下。(网络调用导致G阻塞,会由netpoller接管,此时M可以继续执行下一个G)

如果是系统调用,如读取文件导致G被阻塞,此时不关netpoller什么事,这种阻塞使得G阻塞M,调度器会使得GM与P分离,而P使用新的M继续执行。(G阻塞导致M阻塞,P寻找新M来执行G)

如果是让G执行一个sleep、原子、互斥量或者chan操作,导致M被阻塞,则由sysmon监控线程来监控那些长时间运行的G,然后设置可以抢占的标识符,别的G就可以抢占来执行。(阻塞的G被切换出去,M不阻塞,转而执行其他空闲的G)

这些不同类型阻塞的G都是由sysmon来调度的。

这里有一个有趣的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func main() {
    runtime.GOMAXPROCS(1)
    for i := 0; i < 10; i++ {
        i := i
        go func() {
            fmt.Println(i)
        }()
    }

    var ch = make(chan int)
    <- ch
}
// 输出的数是 9、0、1、2、3、4、5、6、7、8
原因是此时P=1不涉及GRQfor循环中产生的协程都会进到这个p里
由于此时还没发生调度每次新产生的协程都会被M持有这时就会把老的协程放到p的LRQ中当i=9M持有i=9的协程此时p的LRQ队列因为先进先出的缘故持有的协程顺序是012345678
此时执行到读chan的语句调度发生main goroutine挂起m运行持有的协程i=9之后继续消费LRQ里的协程因此输出顺序是 9012345678

调度过程中存在的阻塞

  • IO,select
  • 系统调用syscall
  • channel
  • 锁等待
  • runtime.Gosched()运行时调度

调度器的策略

  • 复用线程,避免线程频繁的创建和销毁,使用work stealing机制和hand off机制
  • 并行:GOMAXPROCS设置P的数量,最多有GOMAXPROCS个线程在CPU上同时运行或自旋
  • 抢占:一个G最多运行10ms,还有sysmon协程协助式抢占,防止其他G被饿死

调度(抢占)的时机

  • go调度器,本质是为需要执行的G寻找M以及P,不是一个实体,调度是需要发生调度时由M执行runtime.schedule方法进行;
  • 函数调用期间,如G需要栈扩容,就会调度另一个G
  • 调度器初始化时,会依次调用mcommoninit:初始化M资源池、procresize:初始化P资源池、newproc:G的运行现场和调度队列;
  • channel、mutex等sync操作发生协程阻塞;
  • time.sleep,go1.13以前的版本,会创建一个goroutine,专门用来唤醒挂载timer上的时间未到期的goroutine,但在1.14之后不会创建goroutine来做这件事,而是在调度循环的各个地方、sysmon中都有唤醒timer的代码,使得timer的唤醒更加及时;
  • IO
  • GC,如STW期间、P上执行safe point、GC栈扫描;
  • sysmon后台监控,当G运行过久时会触发,比如G每次只执行10ms,或系统调度过久;
  • panic崩溃期间;

调度器的生命周期

go调度器的生命周期
go调度器的生命周期

总的调度流程

goroutine schedule
goroutine schedule

5.1 当M执行某个G时发生syscall或其他阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除,然后再创建一个新的M(操作系统线程或者复用其他空闲线程)来服务这个P,即此时的M会直接管理阻塞的G,之前跟它绑定的P转移到其他M,执行其他G。

当原阻塞的M系统调用或阻塞结束时,其绑定的这个G要继续往下执行,会优先尝试获取之前的P,若之前的P已经跟其他M绑定,则尝试从空闲的P链表获取P,将G放入这个P的本地队列,继续执行。如果获取不到P,则该M进入休眠,加入休眠队列,G则放入全局队列,等其他P消费它。

调度Demo

单核机器,只有一个处理器P,系统初始化两个线程M0和M1,处理器P优先绑定线程M0,线程M1进入休眠状态。目前P正在处理G0,LRQ里的G1、G2、G3等待处理,GRQ里的G4、G5等到分配。

如果G0短时间处理完,P就会从LRQ取出G1进行处理,LRQ从GRQ取出G4进行分配;

goroutine runtime_1
goroutine runtime_1

如果G0处理得很慢,系统就会让M0休眠,挂起G0,唤醒线程M1,将LRQ转移给M1进行处理;

如果此时G1也处理得很慢,此时会阻塞,或者休眠M1,唤醒M0,回去继续处理G0;切换M和G的操作由sysmon协程进行处理,即抢占式由sysmon函数实现

如果G1处理得很快,则继续获取LRQ里的下一个G;待LRQ里的G都执行完了,切回M0,继续处理G0。

goroutine runtime_2
goroutine runtime_2

如果是多核的,有多个P,多个M,当有一个P处理完所有的G后,会先从GRQ中获取G,如果获取不到,就会从另一个P的LRQ里取走一半G,继续处理。

sysmon协程

由sysmon协程进行协作式抢占,对goroutine进行标记,执行goroutine时如果有标记就会让出CPU,对于syscall过久的P,会进行M和P的分配,防止P被占用过久影响调度。

sysmon协程运行在M上,且不需要P,它会每隔一段时间检查runtime,确保没有出现异常状态,也会触发调度、GC、检查死锁、获取下一个需要被触发的计时器、定时从netpoll中获取ready的G、打印调度和内存信息。

go sysmon goroutine
go sysmon goroutine

M:Machine

M本质是一个循环调度,不断的执行schedule函数,查找可运行的G。会在自旋与休眠的状态间转换。

没有状态标记,只是会处于以下几个场景:

  • 自旋:M正在从LRQ中获取G,此时M会拥有一个P
  • 拥有一个P,执行G中的代码
  • 进行系统调用或者G的阻塞操作,此时M会释放P
  • 休眠,无G可执行,不拥有P,此时存在空闲线程队列

G:Goroutine的状态

go goroutine state
go goroutine state

goroutine的状态不止以下几种,只是这几种比较常用

G状态 说明
_Gidle 0 刚刚被分配,还没被初始化
_Grunnable 1 表示在runqueue上,即LRQ,还没有被执行,此时的G才能被M执行,进入Grunning状态
_Grunning 2 执行中,不在runqueue上,与M、P绑定
_Gsyscall 3 在执行系统调用,没有执行go代码,没在runqueue上,只与M绑定,此时P转移到其他M中
_Gwaiting 4 被阻塞(如IO、GC、chan阻塞、锁)不在runqueue,但一定在某个地方,比如channel中,锁排队中等
_Gdead 6 现在没有在使用,也许执行完,或者在free list中,或者正在被初始化,可能有stack
_Gcopystack 8 栈正在复制,此时没有go代码,也不在runqueue上,G正在获取一个新的栈的空间,并把原来的内容复制过去,防止GC扫描
_Gscan 0x1000 与runnable、running、syscall、waiting等状态结合,表示GC正在扫描这个G的栈

P:Processor的状态

go processor state
go processor state

状态 描述
_Pidle 空闲,无可运行的G,这时M拥有的P会加入空闲P队列中,LRQ为空
_Prunning 被线程 M 持有,并且正在执行G或者G0(即用户代码或者调度器逻辑)
_Psyscall 用户代码触发了系统调用,此时P没有执行用户代码
_Pgcstop 被线程 M 持有,且因gc触发了STW而停止
_Pdead 当运行时改变了P的数量时,多余的P会变成此状态

泄露与排查

goroutine的泄露一般会导致内存的泄露,最终导致OOM,原因一般是该运行完成的goroutine一直在运行,没有结束,可能的原因是goroutine内阻塞,死循环。

检查工具:pprof,请求/debug/pprof/goroutine接口或者heap接口,判断内存占用走势,分析内存使用情况。一般的走势是整体向上递增,伴随一个一个的峰谷。

内存模型

Go 的内存分配器基于tcmalloc的内存分配算法,它是一个带内存池的分配器,底层直接调用操作系统的mmap函数实现内存分配,减少内存碎片,提升内存利用率。

Go中为每个系统线程分配一个本地的mcache,少量的地址分配就直接从mcache中分配,并且定期做垃圾回收,将线程的mcache中的空闲内存返回给全局控制堆。

根据对象中是否含有指针以及对象大小,内存分配过程分为 3 类:

  • tiny:小于16 byte 并且 没有指针的对象;分配最复杂;
  • small:有指针 或者 大小大于等于16 byte 并且小于等于 32 KB 的对象;
  • large:大小大于 32KB 的对象;

小于32K的为小对象,小对象从本地内存链表中分配;

大于32K为大对象,直接从全局控制堆(中心内存堆)上以页(4K)为单位进行分配,大对象总是以页对齐的,一个页可以存入一些相同大小的小对象;

我们可以将内存分配的路径与 CPU 的多级缓存作类比,这里 mcache 内部的 tiny 可以类比为 L1 cache,而 alloc 数组中的元素可以类比为 L2 cache,全局的 mheap.mcentral 结构为 L3 cache,mheap.arena 是 L4,L4 是以页为单位将内存向下派发的,由 pageAlloc 来管理 arena 中的空闲内存。

L1 L2 L3 L4
mcache.tiny mcache.alloc[] mheap.central mheap.arenas

若 L4 也没法满足我们的内存分配需求,便需要向操作系统去要内存了。

和 tiny 的四级分配路径相比,small 类型的内存没有本地的 mcache.tiny 缓存,其余与 tiny 分配路径完全一致。

L1 L2 L3
mcache.alloc[] mheap.central mheap.arenas

large 内存分配稍微特殊一些,没有上面复杂的缓存流程,而是直接从 mheap.arenas 中要内存,直接走 pageAlloc 页分配器。

大约有100种内存块类别,每一类别都有自己对象的空闲链表。小于32K的内存分配被向上取整到对应的尺寸类别,从相应的空闲链表中分配。一页内存只可以被分裂成同一种尺寸类别的对象,然后由空闲链表分配器管理。

Go内存管理可以看成一个两级的内存管理结构,mheap和mcache。上面一级管理的基本单位是页,用于分配大对象,每次分配都是若干连续的页,也就是若干个4KB的大小,使用的数据结构是mheap和mspan,用BestFit算法做分配,用位示图做回收。下面一级管理的基本单位是不同类型的固定大小的对象,更像一个对象池而不是内存池,用引用计数做回收,使用的数据结构是mcache。

  • mcache:P中的内存缓存,用于小对象(<=32KB)的内存分配,是堆的一部分;
  • mspan:是mheap管理内存最基本的结构,双向链表,保存起始页地址,span大小,span中页的数量;
  • mcentral:将相同大小的span聚合起来,每个mcentral包含两个mspan链表
  • mheap:用于大对象(>32KB)的内存分配,GC的主要地方

如果mcache有空闲的空间,那直接在mcache分配,如果没有,则尝试去mcentral中获取一个空闲的mspan,如果mcentral中也没有可用的mspan,则去mheap向操作系统申请可用的mspan。

逃逸分析

之所以需要逃逸分析,是因为GC只清理堆内存,函数执行时内存分配在栈上,函数结束时栈内存会被回收,无需GC。

  • go通过逃逸分析决定变量在堆上分配还是在栈上分配

    如果变量只在函数内被引用,则优先分配在栈上

    如果变量还在函数外被引用,则优先分配在堆上

  • go中逃逸分析是在编译期完成

  • go中逃逸分析只针对指针,一个值引用变量如果没有被取址,那它永远不可能逃逸

  • 使用go run -gcflags "-m -l"命令运行时加入参数,-m表示打印逃逸分析信息,-l 表示禁止内联编译

逃逸场景

  • 在函数内new出来或使用字面量创建的变量,其值作为函数的返回值,该变量一定发生逃逸;
  • 被已经逃逸的变量引用的指针,也发生了逃逸;
  • 被指针类型的slice、map和chan引用的指针一定发生了逃逸;
  • 返回值为interface{}类型,由于编译期间无法判断是具体类型,也会发生逃逸;
  • 栈空间不足时也会发生逃逸,对于64位机器,栈空间是8MB,使用ulimit -a命令查看机器上栈允许占用的内存大小;
  • 闭包函数直接访问外部的变量;

GC

基本

  • 使用可达性分析判断对象是否被回收
  • 三色标记法进行GC,本质是标记-清除算法,三色标记法是其改进版,主要是为了减少STW的时间
  • Go 语言为了实现高性能的并发垃圾收集器,使用三色抽象、并发增量回收、混合写屏障、调步算法以及用户程序协助等机制将垃圾收集的暂停时间优化至毫秒级以下

使用三色标记法的原因

  • Go运行时的内存分配算法基于 tcmalloc,基本没有碎片问题,对对象进行整理不会带来实质性的性能提升,所以并不需要像Java一样对内存进行分代,另外Java作为面向对象语言,存在大量的对象分配,对内存进行分代有利于对不同的分代采取不同的GC算法,也能及时回收生命周期较短的对象,减少STW影响,提升GC效率;

    关于tcmalloc内存分配算法:

    • 线程级别的内存管理模式;

    • 速度快,减少锁竞争,对于小对象,只有在对应线程分配的空闲块不足时,才会用到锁;对于大对象,会尝试使用自旋锁,最大化内存使用效率,最小化分配时间;

    • 对内存碎片的处理:

      tcmalloc会提前分配多种size的内存:8、16、32、48、64…产生最多12.5%的内存碎片,分配并不是按照2的幂级数分配,比如如果申请65字节,2次幂就会分配128,但实际上tcmalloc只会分配80,以减少内存碎片的产生;

      如果小内存块不够用,才会分配大内存块,申请内存时以Page为单位进行申请,为了使得内存碎片率最多12.5%,就会申请多个page来解决,合并相邻的page,减少外部碎片;

  • Go的编译器会通过逃逸分析将大部分新生对象存储在栈上,而栈在方法结束后即可被回收,只有那些需要长期存在的对象才会被分配到堆中,goroutine死亡后,栈也可被直接回收,不需要GC参与,因此无需分代、频繁检查对象;

  • Go的垃圾收集器与用户代码并发执行,使得STW的时间变短,STW的时间与对象的代际、对象的大小没有关系;

垃圾收集器标记过程中最先检查的对象,即GCRoot

  • 全局变量:程序在编译期就能确定的存在于整个生命周期的变量;
  • 执行栈:每个goroutine包含自己的执行栈,这些执行栈上包含栈上的变量以及指向分配的堆内存的指针;
  • 寄存器:寄存器的值可能表示一个指针,这些指针可能指向某些赋值器分配的堆内存区块

三色标记

  • 白色:潜在垃圾,其内存可能会被垃圾收集器回收;
  • 灰色:活跃对象,因为存在指向白色对象的外部指针,垃圾收集器只会对象进行扫描,通过引入灰色标记这一中间状态,使得用户代码和标记代码可以并发执行,无需STW,减少停顿时间;
  • 黑色:活跃对象,包括不存在任何引用外部指针对象以及从根对象可达的对象;

go gc简化过程
go gc简化过程

  1. 初始对象都是白色,首先把所有对象都放到白色集合中
  2. 从根节点开始遍历对象,遍历到的对象标记为灰色,并从白色集合放入到灰色集合
  3. 遍历灰色对象,把自己标记为黑色,放入黑色集合,将其引用的对象标记为灰色,放入灰色集合
  4. 重复第3步,直到灰色集合为空,此时所有可达对象都被标记,标记阶段完成
  5. 清除阶段开始,白色集合里的对象为不可达对象,即垃圾,对内存进行迭代清扫,回收白色对象
  6. 重置GC状态,将所有的对象放入白色集合中

实际上并没有对应颜色的集合,对象被内存分配器分配在span中,span里有个gcmarkBits字段,每个bit代表一个slot被标记,白色对象该bit为0,灰色或黑色为1。

每个p中都有wbBuf和gcw gcWork, 以及全局的workbuf标记队列, 实现生产者-消费者模型, 在这些队列中的指针为灰色对象, 表示已标记, 待扫描.

从队列中取出来并把其引用对象入队的为黑色对象, 表示已标记, 已扫描. (runtime.scanobject).

写屏障

在go的早期版本中,在标记阶段时,因为用户程序可能会修改对象的指针,导致标记错误,对象被错误回收,因此在标记阶段需要STW。

之后的版本里,为了减少STW时间,需要进行并发标记,因此需要写屏障来保证标记的正确性

内存屏障是一种屏障指令,使得CPU或编译器对该屏障指令的前后发出的内存操作强制执行排序约束,在内存屏障前执行的操作一定先于内存屏障后执行的操作,防止指令重排引发的问题。

想要在并发或增量的标记算法中保证正确性,需要达成任意一种三色不变性

  • 强三色不变性:黑色对象不会指向白色对象,只会指向灰色对象或黑色对象
  • 弱三色不变性:黑色对象可以指向白色对象,但是被指向的白色对象,必须有被其他灰色对象直接或间接引用

go中使用了写屏障是在写入指针前执行的一小段代码,用以防止并发标记时指针丢失,这一小段代码Go是在编译时加入的。

Dijkstra的插入写屏障 - 灰色赋值器

在标记阶段,在对对象所引用的指针进行修改时触发,比如A引用了B,在标记阶段,A将对B的引用改成了C,C会被标记为灰色,即 指针修改时,指向的新对象要标记成灰色,go会在堆区使用插入写屏障

灰色赋值器:尚未被回收器扫描过,或者已经扫描过但仍然需要重新扫描。如果重新扫描过程中发现了新的灰色对象或白色对象,回收器需要对这些对象遍历扫描追踪,由于此时赋值器还没停止,仍然可以插入新的非黑色引用,因此回收器仍然需要往复扫描,直到重新扫描过程中没有发现新的白色或灰色对象,so最坏情况下,回收器只能将所有赋值器线程都停止才能对根对象进行完整扫描,此时就会STW。

优点:可以立即开始并发标记

缺点:相对保守,将有存活可能的对象都标记为灰色,保证强三色不变性,部分残留对象只能留在下一次进行回收;另外,插入写屏障有两种实现方式:

  1. 在标记阶段,如果每次有指针赋值操作都需要引入写屏障,性能开销比较大;

  2. 因此在标记阶段,go只会对堆区中修改指针的操作启用写屏障,栈区中则不触发,在标记阶段结束时,需要STW重新扫描一遍栈区,直至没有灰色节点(避免黑指向白,因此新对象被黑指向之前,会变成灰色),停止STW,进行GC,所以可能导致整个进程的赋值器卡顿;

go dijkstra插入写屏障
go dijkstra插入写屏障

Dijkstra写屏障是对被写入的指针进行grey操作, 不能防止指针从heap被隐藏到黑色的栈中, 需要STW重扫描栈.

Yuasa的删除写屏障 - 黑色赋值器

在标记阶段,对对象所引用的指针进行删除时触发,保证灰色-白色链路不会断开,比如A引用了B,标记阶段,A取消了对B的引用,B会被标记为灰色,即一个对象即使被删除了最后一个指向它的指针,也依旧可以活过这一轮,在下一轮的GC中才会被清理掉。即 指针修改时,修改前指向的对象要标记成灰色,go会在栈区和堆区都使用删除写屏障

黑色赋值器:已经被回收器扫描过,不会再次对其进行扫描。

优点:标记结束时不需要STW;

缺点:已经被垃圾回收器扫描过,不会再次对其进行扫描,回收精度低;标记开始前开启STW扫描栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,用于把原来指向的对象标记为灰色,完了之后后续不需要STW;

Yuasa写屏障是对将被覆盖的指针进行grey操作, 不能防止指针从栈被隐藏到黑色的heap对象中, 需要在GC开始时保存栈的快照.

go yuasa删除写屏障
go yuasa删除写屏障

混合屏障

如果所有对象都在堆上分配,理论上只需要选择插入写屏障 或者 删除写屏障即可满足强/弱三色不变性,但在Go中,栈上对象操作比较频繁,对栈上对象开启写屏障的成本比较高,颜色判断比较复杂,如果只使用其中一种写屏障,都会有对象丢失的问题,需要对栈进行重新扫描标记,导致STW的时间过长,官方为了简单实现,使用混合写屏障,避免二次扫描栈。

  • GC 开始将栈上的对象全部并发扫描并标记为黑色(之后不再对栈进行第二次重复扫描,无需STW);
  • GC 期间,任何在栈上创建的新对象,均为黑色,扫描过一次就不需要再扫描了,这样就消除了插入写屏障时期最后STW的重新扫描栈;
  • 被删除的堆对象标记为灰色;
  • 被添加的堆对象标记为灰色;

混合屏障结合了删除写屏障和插入写屏障的优点,满足弱三色不变式,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行重新扫描,减少了STW的时间。虽然扫描栈时没有STW,但是扫描具体某一个具体的栈时,还是要停止这个G的赋值器的工作,要么对象全灰,要么全黑。

GC触发条件

  1. 内存大小阈值, 内存达到上次GC后的2倍
  2. 达到定时时间触发 ,默认2min,由sysmon强制触发GC
  3. 用户代码中调用 runtime.GC 主动触发

阈值是由一个gc percent的变量控制的,当新分配的内存占已在使用中的内存的比例超过gcprecent时就会触发。比如一次回收完毕后,内存的使用量为5M,那么下次回收的时机则是内存分配达到10M的时候。也就是说,并不是内存分配越多,垃圾回收频率越高。 如果一直达不到内存大小的阈值呢?这个时候GC就会被定时时间触发,比如一直达不到10M,那就定时(默认2min触发一次)触发一次GC保证资源的回收。

当内存分配速度超过标记清除的速度时:

触发GC后,进入并发标记阶段,并发标记会设置一个标志,并在内存分配mallocgc调用时进行检查,当存在新的内存分配时,会暂停分配内存过快的goroutine,并将其转去执行一些辅助标记工作,从而达到放缓继续分配,辅助GC的标记工作的目的。

垃圾收集过程

  1. 清理终止阶段:

    1. 暂停程序STW,所有的goroutine在这时会进入安全点(Safe point);
    2. 如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;
  2. 标记阶段:

    标记过程是一个广度优先遍历的过程,扫描节点,将节点的子节点推到任务队列中,然后递归扫描子节点的节点,直到所有工作队列都被排空为止,这个阶段进行三色标记。

    1. 将写屏障状态切换至 _GCmark、开启写屏障、辅助GC程序(Mutator Assiste)并将根对象入队;占用25%CPU。

      辅助GC程序是为了防止堆heap的增速太快,在GC执行过程中如果同时运行的G分配了内存,那么这个G会被要求作为辅助GC做一部分的工作,辅助GC的G有两种类型,一种是标记,一种是清扫。

    2. 恢复程序,开始进行三色标记,标记进程和用于辅助GC程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色;

    3. 开始扫描根对象,包括所有 Goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 Goroutine 栈期间会暂停当前处理器;

    4. 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;

    5. 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;

  3. 标记终止阶段:

    1. 暂停程序STW、将写屏障状态切换至 _GCmarktermination 并停止写屏障,关闭辅助标记的用户程序;
    2. 清理处理器上的线程缓存;
  4. 清理阶段;

    1. 将写屏障状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障;
    2. 恢复用户程序,所有新创建的对象会标记成白色;
    3. 后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理;

并发垃圾回收是先STW找到所有的Root对象,开启写屏障,然后结束STW,让垃圾标记线程和用户线程并发执行,垃圾标记完成后,再次开启STW,停止写屏障,清理内存。

go GC执行流程
go GC执行流程

关注的指标

  • CPU利用率
  • GC停顿时间
  • GC停顿频率
  • GC可扩展性

GC性能优化技巧

  • slice预先分配内存,因为slice扩容时会返回新的slice,如果频繁扩容会增加GC压力;

  • slice的缩容,比如一个 arr = make([]*obj, 6) 缩容时,当arr = arr[:3]时,底层数组后面 3 个元素已经无法访问了,但是其关联的堆上内存依然无法释放,触发把对应下标的元素置为nil;

  • map的key和value使用值类型,而不是指针类型,利用map中extra中的特性;

  • go中,string底层也是指针,如果string不可变,可以在转成 byte数组,这样就不会生成原有变量的副本,新的变量共享底层的数据指针。此特性也可用在把字符串转成[]byte,再作为map的key或value;

  • 对于占用空间少,频繁分配的函数,函数返回值使用值类型,不使用指针类型,因为返回指针类型会带来指针逃逸,使得原来可以分配在栈上的内存分配在堆上。在栈上进行小对象拷贝的性能会比对象在堆上分配好得多;

  • 将多个小对象合并成一个大对象,减少对象分配,比如在局部变量逃逸时,聚集起来

1
2
3
4
5
6
for k, v := range m {
   k, v := k, v   // copy for capturing by the goroutine
   go func() {
       // use k and v
   }()
}

可以修改为下面这种,修改后,逃逸的对象变为了 x,将 k,v2 个对象减少为 1 个对象。

1
2
3
4
5
6
for k, v := range m {
   x := struct{ k, v string }{k, v}   // copy for capturing by the goroutine
   go func() {
       // use x.k and x.v
   }()
}
  • 对象如果包含了指针,则需要递归进行扫描,大量使用对象真正会使垃圾回收耗时变长;

  • 减少频繁创建goroutine,可以的话是一批一批的创建;

  • 使用sync.Pool来缓存常用对象;

  • 尽可能使用字节数少的类型,比如当我们知道某些数字的使用只会在一定范围内,直接使用int8类型

  • 切片或map提前预分配

  • 在 Go 中存在数量极少的与 GC 相关的 API:

    • runtime.GC:手动触发 GC
    • runtime.ReadMemStats:读取内存相关的统计信息,其中包含部分 GC 相关的统计信息
    • debug.FreeOSMemory:手动将内存归还给操作系统
    • debug.ReadGCStats:读取关于 GC 的相关统计信息
    • debug.SetGCPercent:设置 GOGC 调步变量
    • debug.SetMaxHeap(尚未发布[10]):设置 Go 程序堆的上限值

参考

图解Go协程调度原理,小白都能理解

深入golang runtime的调度

gopher meetup-深入浅出Golang Runtime-yifhao

Golang调度器GMP原理

【golang】GMP调度详解

GC

Golang垃圾回收(GC)介绍

Golang三色标记+混合写屏障模式全分析

垃圾回收

goroutine原理|YouSec

详解Go语言调度循环源码实现

GC模式图文全分析

Golang 系统调用与阻塞处理

go pprof 实战

Licensed under CC BY-NC-SA 4.0
Last updated on May 15, 2021 00:00 UTC
Built with Hugo
Theme Stack designed by Jimmy