文章

【八股】详细聊聊GOGC与GMP架构

【八股】详细聊聊GOGC与GMP架构

1.GOGC

在golang中,运行时会定期采用三色标记法对堆对象进行垃圾回收(gc),无需开发人员手动进行内存控制,简化了开发流程。本章我们从GOGC的历程讲起,详细聊聊GC的工作原理和GC的一些特性。

1.1.GOGC的发展历程

  • go 1.0~1.4:全STW的标记-清除

    这个阶段是golang对垃圾回收的简单实现,采用经典的标记-删除法。在垃圾清除过程中,业务goroutine会被暂停。

    • 标记阶段 1.暂停所有goroutine(STW) 2.从根对象(全局变量、栈变量)开始,递归标记所有可达对象
    • 清除阶段 扫描堆,释放未标记的对象。

    标记-清除法可以很安全的将所有未标记对象删除。得益于全STW,此种方法的实现也很简单,但相当于加了一把大锁,STW时间与堆大小成正比,可能导致数百毫秒的延迟,不适合低延迟应用。

  • go 1.5:引入三色标记与并发GC

    这个阶段的GC采用了三色标记算法,并将部分标记工作改为与应用程序goroutine并发执行,减少了STW时间

    • 标记阶段并发化

    1-大部分标记工作与goroutine并发执行 2-STW只用于初始化和结束阶段

    • 写屏障 确保并发标记时,新分配或修改的对象不被遗漏

    此优化将STW的时间从数百毫秒降低到几毫秒

  • go 1.8:进一步优化

    此阶段清除操作可以并发执行或延后执行,进一步减少STW。同时,引入了GOGC参数(默认100),动态平衡内存使用和GC频率

1.2.三色标记法

  • 对象颜色:
    • 白色:未被标记
    • 灰色:已发现但未完全扫描
    • 黑色:已完成扫描
  • 工作原理:
    • 初始化(STW):标记所有根对象(全局变量、栈)为灰色。
    • 并发标记:
      • GC和应用goroutine并发执行
      • 从灰色对象出发,扫描引用,将子对象标记为灰色,直到完成本轮所有灰色对象的扫描
      • 重复执行,指导所有被指向的对象都经过了扫描
      • 使用写屏障,记录应用协程对对象引用的修改
    • 清除:并发或延迟清除白色对象(未被引用)

1.3.STW(Stop The World)

在最新的golang版本中,大幅减少了STW的时间,仅在初始扫描阶段与结束扫描阶段STW

  • 初始扫描:扫描根对象,确保所有根对象都被置为灰色
  • 结束扫描:结束扫描阶段,不再允许新的灰色对象被加入,避免GC线程无法真正结束。
  • 其他:采用与应用goroutine并发策略

1.4.GC时机:GOGC变量与runtime.GC()

GC的时机可以通过golang的启动环境变量或运行时代码设置:

  • GOGC环境变量:GOGC
  • 运行时:runtime.GOGC(50)

不同GOGC策略适用于不同的场景

  • 默认100:当对象堆大小+已分配的堆大小>上一次GC后的100%,触发GC,适用于大多数应用。
  • 50-100:需要扫描的对象少,STW的时间短,适用于低延迟的应用,CPU占用会增加
  • 200-500:高吞吐量的应用,例如批处理、数据处理,更少的GC触发,允许堆增长,且延迟不是主要关注点。

2.GMP调度

2.1.GMP调度中的核心概念

  • G(goroutine):协程,每个G可以有自己的栈(默认大小2MB),一个G表示一个函数调用,由于G是轻量级,因此支持百万级别的创建。

  • P(Process):调度器,表示运行时可用的调度资源
    • 默认大小=CPU核心数
    • 有一个本地运行队列,队列存储待执行的G
    • 解耦G和M,P不具备执行能力,需要将P与M绑定才可执行G,P负责G的管理与调度
  • M(Machine):OS级的线程,与P绑定后执行P本地队列中的G

  • 本地队列:每个P都有一个本地队列,存放待执行的G

  • 全局队列:全局共享的队列,当P的本地队列为空

2.2.Goroutine与操作系统线程的区别

特性goroutineos线程
创建开销2KB1MB(500倍)
数量支持百万级别几千个或更少
切换开销用户态切换,无需进入内核态,开销小上下文切换及涉及内核态,开销大
栈空间初始小,动态增长固定较大
系统调用需要调度到真正OS线程(M)执行原生支持
阻塞影响会被P自动切出阻塞影响整个线程
  • G与M的M:N模型:多个G可以被绑定到P上,一个P可以被绑定到多个M上
  • 使用GOMAXPROCX可以控制最大同时运行的线程数(即P的数量)

尽管goroutine是轻量的,但是也不能滥用goroutine,泄露或阻塞不退出的goroutine会消耗内存

2.3.状态切换与任务窃取

  • 状态切换:G在运行时,可能会有两种阻塞原因
    • 系统调度阻塞时:

      1-G被放到其他P本地队列的队尾,若没有空闲的P本地队列,放到全局队列 2-解绑当前P与M,创建一个新的(或从缓存中获取一个新的)M执行P中的其他G

    • IO阻塞时:

      1-G放到池子里(比如网络阻塞则放到NetPooler中),M不阻塞,继续执行P中剩下的G 2-待G中的IO完成后,G重新进入P的本地队列

  • 任务窃取:平衡各个P之间的任务负载,确保每个CPU核心得到充分利用
    • 优先本地队列:P优先从本地队列中取G来执行
    • 从全局队列窃取:当本地队列没有任务时,从全局队列窃取任务放入本地队列
    • 从其他P的本地队列窃取:当全局队列也没有G时,从其他P的本地队列窃取任务到本地执行
  • 补充:go 1.4引入信号抢占
    • 目的:强制让长时间占用CPU的G让出CPU
    • 流程:如果某个G占用CPU时间过长(通常是10ms),调度器P会向M发送一个抢占信号。此操作系统会跳出G的执行,转而执行信号函数,在信号函数中,P会保存当前G的执行上下文信息,并将当前G从M中移出,执行其他G
本文由作者按照 CC BY 4.0 进行授权