深入Golang Runtime之Golang GC的过去,当前与未来|转载

2020年2月16日 0 条评论 1.91k 次阅读 1 人点赞

对于学习Java的开发来说, GC并不陌生, 实际上Go的GC流程与Java的CMS实现上不尽相同, 但是流程基本类似. 而对于公司大部分C/C++的开发者来说, 习惯了尽量使用栈对象, 手动管理内存,尽量少new, 对GC的一些术语, 流程可能就有点陌生了, 或许可能对GC有一些些怀疑(实际上20世纪90年代后诞生的, 得到广泛应用的语言, 只有VB没有自动内存管理).

如果从手动管理内存, 跨越到GC, 是我的话, 我可能会有以下疑问:

  • 有GC的语言是咋样的? (简化内存管理)
  • GC可以帮我释放文件吗? (可以, 但不建议. finalization)
  • GC会不会出现泄漏?(正确性问题)
  • GC多久一次?(GC频率)
  • GC会不会暂停很久?(Stop The World问题)
  • 每次GC的暂停稳不稳定?(STW时间分布)
  • GC会不会拖累我程序的运行速度?(吞吐量问题)
  • GC是如何与程序并发运行的?(并发标记)
  • 会不会导致碎片很多?(压缩和碎片)
  • GC会不会一直不回收, 撑爆内存? (GC触发)
  • GC影响程序, 如何调优?
  • Go是GC是怎么实现的?
  • Go的GC与其他语言对比如何?

Golang GC系列共分为三篇. 分别为:

  • Golang GC的过去, 当前与未来
  • GC基础理论与基础算法与Golang GC流程
  • Golang GC源码解析

本文主分为三大部分.第一部分主要讲Golang GC每个版本的变更历史, 重要版本更新.

第二部分是官方给出的一些GC STW时间.

第三部分是本人在实际服务和模拟测试中的一些结果.

最后给出一些重要的proposal和desgin docs.

阅读完本文, 你会对GC有大致了解, Go GC发展历程有大致了解, 对Go GC的流程有大致了解, 对Go GC性能有大致了解, 排除对GC的恐惧. 在后续如果要考虑性能问题时, 有数据可参考, 心中有数.

至于GC基础算法, Golang GC具体流程, 以及源码解析, 将在后续几篇阐述.

本文基于Golang 1.11 Linux amd64.

GC相关概念

GC简介

垃圾回收(英语:Garbage Collection,缩写为GC)是一种自动内存管理机制. 垃圾回收器(Garbage Collector)尝试回收不再被程序所需要的对象所占用的内存. GC最早起源于LISP语言, 1959年左右由John McCarthy创造用以简化LISP中的内存管理. 所以GC可以说是一项"古老"的技术, 但是直到20世纪90年代Java的出现并流行, 广大的普通程序员们才得以接触GC. 当前许多语言如Go, Java, C#, JS和Python等都支持GC.

手动管理内存 VS 自动管理内存

存活是一个全局的(global)特征, 但是调用free函数将对象释放却是局部行为

手动内存管理需要开发者时刻注意对象的生命周期.

  1. 显式的指定哪些对象要释放并归还给操作系统
  2. 同时还需要注意需要清空指向已经释放对象的指针
  3. 注意不能过早的回收还在引用的对象
  4. 在处理有循环引用的对象或者指针操作非线程安全的情况下, 非常的复杂.
  5. 调用其他方法或者第三方库时, 需要明确对象所有权, 需要明确其对象管理方式,加大了耦合性.

GC可以解决大部分悬挂指针和内存泄漏的问题.

  1. GC可以将未被任何对象引用的对象的进行回收, 从而避免悬挂指针.
  2. 只有回收器可以释放对象, 所以不会出现二次释放(double-freeing)
  3. 回收器掌握堆中对象的全局信息以及所有可能访问堆中对象的线程信息, 因此其可以决定任意对象是否需要回收.
  4. 回收器管理对象, 模块之间减少了耦合.

以下为不带GC的C++与有GC的Golang的简单对比.

C++

Golang

GC大大减少了开发者编码时的心智负担, 把精力集中在更本质的编程工作上, 同时也大大减少了程序的错误.

GC与资源回收

GC是一种内存管理机制. 在有GC的语言中也要注意释放文件, 连接, 数据库等资源!

前面提到GC是一种自动内存管理机制, 回收不再使用的对象的内存. 除了内存外, 进程还占用了socket, 文件描述符等资源等, 一般来说这些都不是GC处理的.

有一些GC系统可以将一些系统资源与一块内存关联, 在回收内存时, 其相关的资源也被释放, 这种机制称为finalization. 但是finalization存在很大的不足, 因为GC是不确定的, 无法明确GC什么时候会发生, finalization无法像析构函数那样精确的控制系统资源的释放, 资源的不再使用与被释放之间可能存在很大的时延, 也无法控制由谁释放资源.

Java和Go均有类似的机制, 目前Java 1.9中已经明确把finalizer标记为废弃.

术语简单说明

这里简单的说明一些术语,帮助快速了解, 并不追求完全准确.

mutator

mutate的是变化的意思, mutator就是改变者, 在GC里, 指的是改变对象之间引用关系的实体, 可以简单的理解为我们写的的应用程序(运行我们写的代码的线程, 协程).

allocator, collector

自动内存管理机制一般包含allocator(分配器)和collector(回收器). allocator负责为应用代码分配对象, 而collector则负责寻找存活的对象, 并释放不再存活的对象.

STW

stop the world, GC的一些阶段需要停止所有的mutator(应用代码)以确定当前的引用关系. 这便是很多人对GC担心的来源, 这也是GC算法优化的重点. 对于大多数API/RPC服务, 10-20ms左右的STW完全接受的. Golang GC的STW时间从最初的秒级到百ms, 10ms级别, ms级别, 到现在的ms以下, 已经达到了准实时的程度.

Root对象

根对象是mutator不需要通过其他对象就可以直接访问到的对象. 比如全局对象, 栈对象, 寄存器中的数据等. 通过Root对象, 可以追踪到其他存活的对象.

可达性

即通过对Root对象能够直接或者间接访问到.

对象的存活

如果某一个对象在程序的后续执行中可能会被mutator访问, 则称该对象是存活的, 不存活的对象就是我们所说的garbage. 一般通过可达性来表示存活性.

Mark Sweep

三大GC基础算法中的一种. 分为mark(标记)和sweep(清扫)两个阶段. 朴素的Mark Sweep流程如下:

  1. Stop the World
  2. Mark: 通过Root和Root直接间接访问到的对象, 来寻找所有可达的对象, 并进行标记
  3. Sweep: 对堆对象迭代, 已标记的对象置位标记. 所有未标记的对象加入freelist, 可用于再分配.
  4. Start the Wrold

朴素的Mark Sweep是整体STW, 并且分配速度慢, 内存碎片率高.

有很多对Mark Sweep的优化,

比如相同大小阶梯的对象分配在同一小块内存中, 减少碎片率.

freelist改成多条, 同一个大小范围的对象, 放在一个freelist上, 加快分配速率, 减少碎片率.

并发Sweep和并发Mark, 大大降低stw时间.

并发收集:

朴素的Mark Sweep算法会造成巨大的STW时间, 导致应用长时间不可用, 且与堆大小成正比, 可扩展性不好. Go的GC算法就是基于Mark Sweep, 不过是并发Mark和并发Sweep.

一般说并发GC有两层含义, 一层是每个mark或sweep本身是多个线程(协程)执行的(concurrent),一层是mutator(应用程序)和collector同时运行(background).

首先concurrent这一层是比较好实现的, GC时整体进行STW, 那么对象引用关系不会再改变, 对mark或者sweep任务进行分块, 就能多个线程(协程)conncurrent执行任务mark或sweep.

而对于backgroud这一层, 也就是说mutator和mark, sweep同时运行, 则相对复杂.

首先backgroup sweep是比较容易实现的, 因为mark后, 哪些对象是存活, 哪些是要被sweep是已知的, sweep的是不再引用的对象, sweep结束前, 这些对象不会再被分配到. 所以sweep容和mutator内存共存, 后面我们可以看到golang是先在1.3实现的sweep并发. 1.5才实现的mark并发.

写屏障

接上面, mark和mutator同时运行就比较麻烦, 因为mutator会改变已被scan的对象的引用关系.

假设下面这种情况:

mutator和collector同时运行.

b有c的引用. gc开始, 先扫描了a, 然后mutator运行, a引用了c, b不再引用c, gc再扫描b, 然后sweep, 清除了c. 这里其实a还引用了c, 导致了正确性问题.

为了解决这个问题, go引入了写屏障(写屏障有多种类型, Dijkstra-style insertion write barrier, Yuasa-style deletion write barrier等). 写屏障是在写入指针前执行的一小段代码用于防止指针丢失. 这一小段代码Golang是在编译时写入的. Golang目前写屏障在mark阶段开启.

Dijkstra write barrier在

mutaotr a.obj1=c

这一步, 将c的指针写入到a.obj1之前, 会先执行一段判断代码, 如果c已经被扫描过, 就不再扫描, 如果c没有被扫描过, 就把c加入到待扫描的队列中. 这样就不会出现丢失存活对象的问题存在.

三色标记法

三色标记法是传统Mark-Sweep的一个改进, 由Dijkstra(就是提出最短路径算法的)在1978年发表的论文On-the-Fly Garbage Collection: An Exercise in Cooperation中提出.

它是一个并发的GC算法.

原理如下,

  1. 首先创建三个集合:白, 灰, 黑. 白色节点表示未被mark和scan的对象, 灰色节点表示已经被mark, 但是还没有scan的对象, 而黑色表示已经mark和scan完的对象.
  2. 初始时所有对象都在白色集合.
  3. 从根节点开始广度遍历, 将其引用的对象加入灰色集合.
  4. 遍历灰色集合, 将灰色对象引用的白色对象放入灰色集合, 之后将此灰色对象放入黑色集合.

标记过程中通过write-barrier检测对象引用的变化.重复4直到灰色中无任何对象. GC结束, 黑色对象为存活对象, 而剩下的白色对象就是Garbage. Sweep所有白色对象.

下面我们会提到Golang也是使用的三色标记法. 在Go Runtime的实现中, 并没有白色集合, 灰色集合, 黑色集合这样的容器. 实现如下:

白色对象: 某个对象对应的gcMarkBit为0(未被标记)

灰色对象: gcMarkBit为1(已被标记)且在(待scan)gcWork的待scan buffer中

黑色对象: gcMarkBit为1(已被标记)且不在(已经scan)gcWork的待scan buffer中

Golang GC发展历史

Golang GC简介

Golang对于GC的目标是低延迟, 软实时GC, 很多围绕着这两点来设计.

Golang刚发布时(13,14年)GC饱受诟病, 相对于当时Java成熟的CMS(2002年JDK1.4中发布)和G1(2012年JDK7中发布)来说, 不管在吞吐量还是暂停时间控制上来说, 都有比较大的差距.

1.3-1.9之间的Golang版本更新, 都把GC放在了重要的改进点上, 从最初的STW算法到1.5的三色并发标记, 再到1.8的hybird write barrier, 完全消除了并发标记-清除算法需要的重新扫描栈阶段, Golang GC做到了sub ms的gc pause.

目前Golang GC(我这里指Go 1.11, 2018年8月发布)具有以下特征.

  • 三色标记
  • Mark Sweep算法,并发标记, 并发清除
  • Muator会执行辅助标记, 辅助清扫
  • 非分代
  • 准确式GC, 能够知道内存中某个数据是数字还是指向对象的指针. 相对的是保守式GC.
  • 非紧缩, 非移动,GC之后不会进行紧缩堆(也就不会移动堆对象地址)
  • 写屏障实现增量式

在生产上, Golang GC对GC Pause的控制在Go 1.6以后超过了Java CMS和G1.当然Java 11(2018年9月发布)中加入了实验性质的ZGC, 在128G的堆上, GC Pause能够100%控制在2ms内, 是非常厉害的. 目前可能Java用的比较多的还是JDK7或者JDK8, 那么GC一般是CMS和少量的G1, 而Go的版本一般都在1.9以后, 这一点上, 生产上Go的GC体验还是比Java好一些.

兰陵美酒郁金香

大道至简 Simplicity is the ultimate form of sophistication.

文章评论(0)

你必须 登录 才能发表评论