【操作系统】Go Runtime 的 MADV_FREE 内存释放问题

Posted by Hao Liang's Blog on Saturday, November 27, 2021

1、 背景

相关issue:

runtime: memory not being returned to OS #22439

runtime: provide way to disable MADV_FREE

在使用 go 1.12~1.15 编译的应用时,时常发生应用启动后常驻内存 RSS 随运行时长增加而持续上升,内存一直没有得到释放。

2、分析过程

使用 pprof 分析Go Runtime 中各种内存占用情况,以下为 pprof 中各种内存的含义说明:

参考:Go pprof内存指标含义备忘录

// 总共从OS申请的字节数
// 是下面各种XxxSys指标的总和。包含运行时的heap、stack和其他内部数据结构的总和。
// 它是虚拟内存空间。不一定全部映射成了物理内存。
Sys

// 见`Sys`
HeapSys

// 还在使用的对象,以及不使用还没被GC释放的对象的字节数
// 平时应该平缓,gc时可能出现锯齿
HeapAlloc

// 正在使用的对象字节数。
// 有个细节是,如果一个span中可包含多个object,只要一个object在使用,那么算的是整个span。
// `HeapInuse` - `HeapAlloc`是GC中保留,可以快速被使用的内存。
HeapInuse

// 已归还给OS的内存。没被堆再次申请的内存。
HeapReleased

// 没被使用的span的字节数。
// 这部分内存可以被归还给OS,并且还包含了`HeapReleased`。
// 可以被再次申请,甚至作为栈内存使用。
// `HeapIdle` - `HeapReleased`即GC保留的。
HeapIdle

/// ---

// 和`HeapAlloc`一样
Alloc

// 累计的`Alloc`
// 累计的意思是随程序启动后一直累加增长,永远不会下降。
TotalAlloc

// 没什么卵用
Lookups = 0

// 累计分配的堆对象数
Mallocs

// 累计释放的堆对象数
Frees

// 存活的对象数。见`HeapAlloc`
// HeapObjects = `Mallocs` - `Frees`
HeapObjects

// ---
// 下面的XxxInuse中的Inuse的含义,和XxxSys中的Sys的含义,基本和`HeapInuse`和`HeapSys`是一样的
// 没有XxxIdle,是因为都包含在`HeapIdle`里了

// StackSys基本就等于StackInuse,再加上系统线程级别的栈内存
Stack = StackInuse / StackSys

// 为MSpan结构体使用的内存
MSpan = MSpanInuse / MSpanSys

// 为MCache结构体使用的内存
MCache = MCacheInuse / MCacheSys

// 下面几个都是底层内部数据结构用到的XxxSys的内存统计
BuckHashSys
GCSys
OtherSys

// ---
// 下面是跟GC相关的

// 下次GC的触发阈值,当HeapAlloc达到这个值就要GC了
NextGC

// 最近一次GC的unix时间戳
LastGC

// 每个周期中GC的开始unix时间戳和结束unix时间戳
// 一个周期可能有0次GC,也可能有多次GC,如果是多次,只记录最后一个
PauseNs
PauseEnd

// GC次数
NumGC

// 应用程序强制GC的次数
NumForcedGC

// GC总共占用的CPU资源。在0~1之间
GCCPUFraction

// 没被使用,忽略就好 

正常情况下,随着 HeapReleased 上升,RSS 会下降(Go Runtime 通过 GC 将使用过的内存归还给 OS), 而通过 pprof 观察到随着 HeapReleased 上升,RSS 仍然保持原有值,并没有下降。为什么会发生这种奇怪的现象呢?

我们来看看go 的内存回收相关代码逻辑

//src/runtime/mem_linux.go
func sysUnused(v unsafe.Pointer, n uintptr) {
    ...
	var advise uint32
    // go 1.12 ~ 1.15 版本中 debug.madvdontneed 默认为0,因此不会使用 _MADV_DONTNEED 的方式标记可回收内存
	if debug.madvdontneed != 0 {
		advise = _MADV_DONTNEED
	} else {
		advise = atomic.Load(&adviseUnused)
	}
	// 优先尝试使用 _MADV_FREE 的方式标记可回收内存
	if errno := madvise(v, n, int32(advise)); advise == _MADV_FREE && errno != 0 {
		// MADV_FREE was added in Linux 4.5. Fall back to MADV_DONTNEED if it is
		// not supported.
		atomic.Store(&adviseUnused, _MADV_DONTNEED)
		madvise(v, n, _MADV_DONTNEED)
	}
}

以上内存回收代码可看出,go 1.12 ~ 1.15 版本中,优先尝试使用 _MADV_FREE 的方式通过 madvise 系统调用回收内存, 以下为 madvise 系统调用的两种标记内存回收方式的简单说明:


       MADV_DONTNEED
              ...

              Note that, when applied to shared mappings, MADV_DONTNEED
              might not lead to immediate freeing of the pages in the
              range.  The kernel is free to delay freeing the pages
              until an appropriate moment.  The resident set size (RSS)
              of the calling process will be immediately reduced
              however.
              
              ...
       MADV_FREE (since Linux 4.5)
              The application no longer requires the pages in the range
              specified by addr and len.  The kernel can thus free these
              pages, but the freeing could be delayed until memory
              pressure occurs.  For each of the pages that has been
              marked to be freed but has not yet been freed, the free
              operation will be canceled if the caller writes into the
              page.  After a successful MADV_FREE operation, any stale
              data (i.e., dirty, unwritten pages) will be lost when the
              kernel frees the pages.  However, subsequent writes to
              pages in the range will succeed and then kernel cannot
              free those dirtied pages, so that the caller can always
              see just written data.  If there is no subsequent write,
              the kernel can free the pages at any time.  Once pages in
              the range have been freed, the caller will see zero-fill-
              on-demand pages upon subsequent page references.

              ...

简单来说,MADV_DONTNEED 标记后的内存被回收后 RSS 会立即减少,内存直接释放。

而 MADV_FREE 标记后的内存采用一种 lazy free 延迟释放的方式, 内核会等到内存紧张时才会释放,并且在释放之前,这块内存依然可以复用。

在 go 1.16 版本中,将 MADV_DONTNEED 重新作为 linux 默认的内存回收标记方式

相关 commit: runtime: default to MADV_DONTNEED on Linux

相关代码:

// src/runtime/runtime1.go
	debug.cgocheck = 1
	debug.invalidptr = 1
	if GOOS == "linux" {
		// On Linux, MADV_FREE is faster than MADV_DONTNEED,
		// but doesn't affect many of the statistics that
		// MADV_DONTNEED does until the memory is actually
		// reclaimed. This generally leads to poor user
		// experience, like confusing stats in top and other
		// monitoring tools; and bad integration with
		// management systems that respond to memory usage.
		// Hence, default to MADV_DONTNEED.
		debug.madvdontneed = 1
	}

而使用 go 1.12 ~ 1.15 版本的用户可以通过在环境变量中添加 GODEBUG=madvdontneed=1 强制使用 MADV_DONTNEED 回收内存。 例如在 Kubernetes 的 Pod 容器中添加环境变量:

apiVersion: v1
kind: Pod
metadata:
  name: etcd
  namespace: kube-system
spec:
  containers:
  - env:
    - name: GODEBUG
      value: madvdontneed=1
    command:
    - etcd
...