踩坑记:go服务内存暴涨

坑.jpg

这周换换口味,记录一下去年踩的一个大坑。node

大概是去年8月份,那会儿咱们还在用着64GB的“小内存”机器。git

因为升级一次版本须要较长的时间(1~2小时),所以咱们天天只发一次车,由值班的同窗负责,发布全部已merge的commit。 程序员

当天负责值班的我正开着车,忽然收到 Bytedance-System 的夺命连环call,打开Lark一看:github

[ 规则 ]:机器资源报警
[ 报警上下文 ]:
 host: 10.x.x.x
内存使用率: 0.944
[ 报警方式 ]:电话&Lark

打开ganglia一看,更使人惧怕:golang

memory.jpg

吓得我都瘫了.png

这看起来像是典型的内存泄漏case,那就按正常套路排查: 面试

一方面,通知车上的同窗review本身的commit,看看是否有代码疑似内存泄漏,或者新增大量内存占用的逻辑; 后端

另外一方面,咱们的go服务都默认开启了pprof,因而找了一台机器恢复到原版本,用来对比内存占用状况:ide

$ go tool pprof http://$IP:$PORT/debug/pprof/heap 
(pprof) top 10
Showing top 10 nodes out of 125
      flat  flat%   sum%        cum   cum%
 2925.01MB 17.93% 17.93%  3262.03MB 19.99%  **[此处打码]**
 2384.37MB 14.61% 32.54%  4817.78MB 29.52%  **[此处打码]**
 2142.40MB 13.13% 45.67%  2142.40MB 13.13%  **[此处打码]**
 ...

就这样,一顿操做猛如虎,涨跌全靠特朗普,最终结果是,一方面没看出啥问题,另外一方面也没看出啥问题。性能

WechatIMG397.jpeg

正在束手无策、准备回滚之际,内存它本身稳了:spa

虽然占用率仍然很高,可是没有继续上升,也没有出现OOM的状况。

灵车.png

排查过程当中,咱们还发现一个现象:并非全部机器的内存都涨。

memory2.png

(确实有点“灵”……)

这些机器的硬件都是一致的,可是用 uname -a 能够看到,内存异常的机器版本是 4.14,比内存正常机器的 3.16 高不少:

<异常机器>$ uname -a #
Linux 4.14.81.xxx ...
<正常机器>$ uname -a
`Linux 3.16.104.xxx ...`

说明两个 kernel 版本的某些差异是缘由之一,但并不足以解释前述问题:毕竟发车以前也是这些机器。

此外,Y同窗提到,他把编译服务指定的 go 版本从 1.10 升级到了 1.12。

当时 go 1.12 已经发布半年,Y 同窗在开发环境编译和运行正常,在线上灰度机器也运行了一段时间,看着没毛病,因此就决定升级了。

既然其余可能性都排查过了,那就先降回来看看吧。

咱们用 go 1.10 从新编译了master,发布到几台内存异常的机器上。

因而问题解决了。

黑人抬棺.gif

黑人问号.jpeg

为何 go 1.12 会致使内存异常上涨呢?

查查  Go 1.12 Release Notes,能够找到一点线索:

Runtime 
Go 1.12 significantly improves the performance of sweeping when a large fraction of the heap remains live. This reduces allocation latency immediately following a garbage collection.

(中间省略2段不太相关的内容)
On Linux, the runtime now uses MADV_FREE to release unused memory. This is more efficient but may result in higher reported RSS. The kernel will reclaim the unused data when it is needed. 

golang.org/doc/go1.12

翻译一下:

在堆内存大部分活跃的状况下,go 1.12 能够显著提升清理性能,下降 [紧随某次gc的内存分配] 的延迟。

在Linux上,Go Runtime如今使用 MADV_FREE 来释放未使用的内存。这样效率更高,可是可能致使更高的 RSS;内核会在须要时回收这些内存。

这两段话每一个字都认识,合到一块儿就

WechatIMG445.jpeg

不过都写到这了,我仍是试着解释下,借用 C 语言的 malloc 和 free (Go的内存分配逻辑也相似):

  • 内存分配

在Linux下,malloc 须要在其管理的内存不够用时,调用 brk 或 mmap 系统调用(syscall)找内核扩充其可用地址空间,这些地址空间对应前述的堆内存(heap)。

注意,是“扩充地址空间”:由于有些地址空间可能不会当即用到,甚至可能永远不会用到,为了提升效率,内核并不会马上给进程分配这些内存,而只是在进程的页表中作好标记(可用、但未分配)。

注:OS用页表来管理进程的地址空间,其中记录了页的状态、对应的物理页地址等信息;一页一般是 4KB。

当进程读/写还没有分配的页面时,会触发一个缺页中断(page fault),这时内核才会分配页面,在页表中标记为已分配,而后再恢复进程的执行(在进程看来彷佛什么都没发生)。

注:相似的策略还用在不少其余地方,包括被swap到磁盘的页面(“虚拟内存”),以及 fork 后的 cow 机制。

  • 内存回收

当咱们不用内存时,调用 free(ptr) 释放内存。

对应的,当 free 以为有必要的时候,会调用 sbrk 或 munmap 缩小地址空间:这是针对一整段地址空间都空出来的状况。

但更多的时候,free 可能只释放了其中一部份内容(例如连续的 ABCDE 5个页面中只释放了C和D),并不须要(也不能)把地址空间缩小

这时最简单的策略是:什么也不干。

但这种占着茅坑不拉屎的行为,会致使内核没法将空闲页面分配给其余进程。

因此 free 能够经过 madvise 告诉内存“这一段我不用了”。

  • madvise

经过 madvise(addr, length, advise) 这个系统调用,告诉内核能够如何处理从 addr 开始的 length 字节。

在 Linux Kernel 4.5 以前,只支持 MADV_DONTNEED(上面提到 go 1.11 及之前的默认advise),内核会在进程的页表中将这些页标记为“未分配”,从而进程的 RSS 就会变小。OS后续能够将对应的物理页分配给其余进程。

注:RSS 是 Resident Set Size(常驻内存集)的缩写,是进程在物理内存中实际占用的内存大小(也就是页表中实际分配、且未被换出到swap的内存页总大小)。咱们在 ps 命令中会看到它,在 top 命令里对应的是 REZ(man top有更多惊喜)。

被 madvise 标记的这段地址空间,该进程仍然能够访问(不会segment fault),可是当读/写其中某一页时(例如malloc分配新的内存,或 Go 建立新的对象),内核会 从新分配 一个 用全0填充 的新页面。

若是进程大量读写这段地址空间(即 release notes 说的 “a large fraction of the heap remains live”,堆空间大部分活跃),内核须要频繁分配页面、而且将页面内容清零,这会致使分配的延迟变高。

  • go 1.12 的改进

从 kernel 4.5 开始,Linux 支持了 MADV_FREE (go 1.12 默认使用的advise),内核只会在页表中将这些进程页面标记为可回收,在须要的时候才回收这些页面。

若是赶在内核回收前,进程读写了这段空间,就能够继续使用原页面,相比 DONTNEED 模式,减小了从新分配内存、数据清零所需的时间,这对应 Release Notes 里写的 "reduces allocation latency immediately following a garbage collection",由于在 gc 之后当即分配内存,对应的页面大几率尚未被 OS 回收。

但其代价是 "may result in higher reported RSS",因为页面没有被OS回收,仍被计入进程的 RSS ,所以看起来进程的内存占用会比较大。


差很少就解释到这里吧,建议再重读一遍:

在堆内存大部分活跃的状况下,go 1.12 能够显著提升清理性能,下降 [紧随某次gc的内存分配] 的延迟。

在Linux上,Go Runtime如今使用 MADV_FREE 来释放未使用的内存。这样效率更高,可是可能致使更高的 RSS;内核会在须要时回收这些内存。

若是仍然有不理解的地方,能够留言探讨。

对更多细节感兴趣的同窗,推荐阅读《What Every Programmer Should Know About Memory》(TL; DR),或者它的精简版《What a C programmer should know about memory》(文末参考连接)。

转²

至此前述内存暴涨问题也算是收尾了,但 Y 同窗仍然有点不放心:是否是有可能某个bug在 Go 1.12 才会出现、致使内存泄漏?

这问题有点轴,可是好像颇有道理,毕竟前面那么一大段,光说不练,像假把式。

你再说一遍.jpeg

但这要如何才能实锤呢?

前面提到 go 1.12 用 MADV_FREE ,内核会在须要的时候才回收这些页面。

若是咱们能想办法让内核以为须要、去回收这些可回收的页面,就能实锤了。

熟悉虚拟化(如xen、kvm)的同窗,可能会以为这个问题很眼熟:若是宿主机(准确地说是hypervisor)可以回收客户机再也不使用的内存,那就能够 超卖更多VPS赚更多钱 大幅提升内存的利用率。

他们是怎么作的呢?

xen的解决方案是:在客户机里植入一段程序,其主要工做是申请新的内存。能被它申请到的内存,就是客户机能够不用的内存(固然也不能申请得太过度,不然会致使客户机使用swap,或其余进程OOM)。而后宿主机就能够放心将这些内存对应的物理页挪做他用了。

这个过程就像在吹气球,把客户机里能占用的空间都占住。

因此这段程序的名字叫作:balloon driver。

那么实锤方案就呼之欲出了:

若是咱们也弄个不断膨胀的气球(申请内存),内核就会以为须要去找其余进程回收那些被FREE标记的内存。

说干就干:

#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main() {
    char *p = NULL;
    const int MB = 1024 * 1024;
    while (1) {
        p = malloc(100 * MB);
        memset(p, 0, 100 * MB);
        sleep(1);
    }
    return 0;
}

(注意memset,不然内存不会实际分配)

效果以下:

balloon.png

能够看到,虽然打码进程的 VIRT(地址空间大小)仍是52G,可是实际占用的内存已经降低到 35G,气球生效了。

深藏功与名.jpg

合²

简单汇总一下前面的内容:

  1. Go 1.12 升级能下降内存分配的延迟,但会致使进程RSS变高
  2. 由于 Go 1.12 用 MADV_FREE ,会让内核延迟回收内存
  3. 经过在页表中作标记的方式,延迟内存的分配和回收,能够提升内存管理的效率
  4. 能够经过气球来让 OS/Hypervisor 挤占内存,另做他用

顺便一提,本文涉及的部分知识点,我在面试时偶尔会问到。

有些候选人就以为我是在刁难他,反问我:

“你问的这些,工做中都用获得吗?”

WechatIMG435.jpeg

在字节跳动真用得上,不信你来试试?

~ 投递连接 ~

网盟广告(穿山甲)-后端开发(上海)
https://job.toutiao.com/s/sBAvKe

网盟广告(穿山甲)-后端开发(北京)
https://job.toutiao.com/s/sBMyxk

其余地区、其余职能线
https://job.toutiao.com/s/sB9Jqk

关于字节跳动面试的详情,可参考我以前写的:

程序员面试指北:面试官视角

参考连接:

[1] Go 1.12 关于内存释放的一个改进
https://ms2008.github.io/2019...

[2] What a C programmer should know about memory
https://marek.vavrusa.com/mem...

[3] tcmalloc2.1 浅析
https://wertherzhang.com/tcma...

weixin2s.png

相关文章
相关标签/搜索