Golang内存分配

最近项目做上线前的压力测试,遇到一个”诡异”的问题,GameServer进程持有内存,即使在所有机器人下线后一段时间,内存依然没有归还OS。

问题描述

服务器启动后,内存占用大概600M,此时机器人开始陆续上线,服务器的内存使用逐渐增加,最高达到11GB。 大概持续1个小时后,机器人开始陆续下线,机器人全部下线后GameServer进程的RSS竟丝毫没有下降。当时有些困惑,Golang GC最迟2分钟就会触发1次,是程序BUG? 还是垃圾回收没触发?

内存采样

  1. top命令查看, RSS(RES)为11GB
  2. pprof对内存heap和allocs数据采样,发现inuse_space不到1GB
  3. 输出进程运行时信息, HeapSys(从OS分配的堆内存), HeapAlloc(进程已用内存), HeapIdle(未使用的内存), 分别为11GB, 0.8GB, 10.2GB

从内存采样结果来看,基本可以排除程序Bug和GC没触发这两种情况。

原因

查了很多资料,最后在Golang项目的issues中找到了答案。内存分配器为什么持有10.2GB内存不归还OS?Golang项目组成员是这样解释的:

  1. 进程把持有的内存归还给OS或向OS重新分配内存都会有一定代价。
  2. 应用程序通常不会有这样的峰值,如果你的程序运行在服务器或容器中,通常会有足够的内存。所以及时归还没有意义。原文

解决方法

在文中作者给出了方法,调用debug.FreeOSMemory()函数手动触发垃圾回收并主动把多余的内存归还OS, 我在Win10和Linux两个平台分别做了实验。

  1. Win10上调此函数时,会立即把多余内存归还给OS。
  2. Linux上调此函数时,无效。 根据官方的解释,在golang1.12中,Linux内核版本>4.5采用的是MADV_FREE方式来释放内存,当OS有内存压力时,才会把多余内存归还给OS。即使调用 FreeOSMemory()也不会生效。原文
    解决:Linux平台可以给进程设置环境变量,禁用MADV_FREE。 GODEBUG=madvdontneed=1 ./GameServer

之前一直有误解,被GC的内存直接归还OS。但事实并非如此,Golang的内存分配器会一直持有GC的内存,直到OS有内存压力或调用debug.FreeOSMemory()手动归还。

为了弄清楚这一问题,我看了一遍Golang内存分配流程。

一、概念及名词解释

  • 页(Page):进程中的地址都是虚拟地址,地址总线指向的物理地址才是真实地址,页表的作用就是为了实现虚拟地址和物理地址的转换,页大小默认为4KB。详情参考
  • 伪共享(false sharing):现代多核CPU的缓存一般分为三级,每个核上都一、二级缓存,而第三级缓存则是多个核之间共享。当Core从内存取数据时,一次性会取CacheLine大小的数据到自己的缓存中,然后存进CacheLine。但如果Core1、Core2同时读取了变量A或者它们CacheLine有交集,假如Core1先修改,则Core2的CacheLine会失效(CacheMiss),只有在Core1数据写回内存后,Core2重新读取, 这样造成了本来应该是并行的操作变成线性。这种情况在多核高并发的场景下影响很大。Golang内存分配器为了规避这种情况,在central上通过pad来填充不够CacheLineSize的部分,避免CacheMiss以提高多核并发时的性能。
  • RSS(Resident Set Size):常驻内存大小,当前进程已经分配的内存大小。用top命令可以看到RSS(RES)。
  • MADV_FREE:当应用程序不需要addr,len区间的页时,内核只会在OS内存有压力时释放。在此之前,这些页只是被标记为待释放的,当这些页即将被写时才会触发释放。原文
  • Span: Golang内存管理的基本单位,一个sapn包含多个页。
1
2
3
4
5
6
7
8
9
10
type mspan struct {
next *mspan // 下一个span
prev *mspan // 上一个span
npages uintptr // 当前span包含的页数
freeindex uintptr // 下一个空闲的obj索引
nelems uintptr // obj数量
spanclass spanClass // 对应的class(0~66)
scavenged bool // span相关的页是否已经归还OS
elemsize uintptr // obj大小
}

span会被切分成8byte ~ 32KB不等obj class1 ~ class66, 例如:class2把大小为8K的span分成了512个16B的obj,当newobj时,根据对象大小,找到相应的class进行分配,比如 8byte < obj <=16byte 会用class为2的sapn分配。>32KB的对象会用class0分配。

1
2
3
4
5
6
7
8
    class  bytes/obj  bytes/span  objects  tail waste  max waste
// 1 8 8192 1024 0 87.50%
// 2 16 8192 512 0 43.75%
// 3 32 8192 256 0 46.88%
// . . . . . .
// 64 27264 81920 3 128 10.00%
// 65 28672 57344 2 0 4.91%
// 66 32768 32768 1 0 12.50%
  • 小对象:对象大小<=16B, 之所以是16B是在浪费空间和可合并性之间做的权衡,小对象在class为2的span(noscan)上,微分配器根据对象实际大小来分配。
  • 普通对象:对象大小<=32KB, 在class1~class66的sapn上分配。
  • 大对象:对象大小>32KB, 分配时会跳过mcache、mcentral直接在mheap上分配, 所对应的class值为0
  • mcache:每个P都有一个mcache, 当分配<=32KB的小对象时,首先在当前P的mcache中分配,因为P中任意时刻只有1个goroutine在执行,所以mcache中分配对象不需要加锁
1
2
3
type mcache struct {
alloc [numSpanClasses]*mspan //可分配的sapn, 根据待分配对象大小,计算出alloc的索引,在对应span中找到空闲obj
}
  • mcentral: 全局缓存(包含在mheap中),当mcache分配不成功,会向mcentral申请,因为mcentral是全局的,向mcentral申请需要加锁(多个P可能同时向mcentral申请)。当申请成功后,把对应的span从nonempty移到empty,并设置回P
1
2
3
4
5
6
type mcentral struct {
lock mutex // 全局锁,分配时会调用
spanclass spanClass // span对应的class(0~66)
nonempty mSpanList // 空闲的span列表
empty mSpanList // 无空闲对象或已经分配到mcache中的span列表
}
  • mheap: mheap是一个全局对象(包含了mcentral),在runtime.mallocinit()中初始化,包含了所有分配过的span和种规格(class1~66)的central,这样做可以减少锁的粒度,例如:从central申请class为2的span时,只用对central[2]加锁,不用对整个central加锁,central中的pad是为了避免伪共享(false sharing)而填充的字节。
1
2
3
4
5
6
7
8
type mheap struct {
allspans []*mspan //所有分配过的span, all spans out there
// 每种类型的class(1~66)对应一种central
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
}

二、分配流程

1.计算对象大小
2.在mcahe.alloc中找到对应大小的span,若对应span的obj已经用完。
3.在heap的central中分配
4.在heap的arena中分配
5.向OS申请

下面是内存分配流程的部分源代码(go1.12.7),任何一步成功分配到span都会返回给mcache

三、源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
//内存分配开始函数
func newobject(typ *_type) unsafe.Pointer {
return mallocgc(typ.size, typ, true)
}

// 小对象尝试在mcache中分配,大对象(>32kB)直接从堆中分配
// needzero 是否需要对分配的内存清零
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
c := gomcache() //获取当前goroutine的mcache
span := c.alloc[spc] //根据对象大小,算出class值(spc)
v := nextFreeFast(span) //尝试快速从当前span中分配
if v == 0 {
v, span, shouldhelpgc = c.nextFree(spc)
}
}

//从当前span中快速分配失败,则从下一个空闲span中分配
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
s = c.alloc[spc]
freeIndex := s.nextFreeIndex()
if freeIndex == s.nelems {
// 当前span的obj已经用完(根据calss类型,每个span可分为大小相等的1个或多个obj)
c.refill(spc)
}

//从center中分配一个有空闲obj,且大小为spc指定的span,并保存到cache中
func (c *mcache) refill(spc spanClass) {
s = mheap_.central[spc].mcentral.cacheSpan()// 从central lists中获取一个span
c.alloc[spc] = s/设置回mcache
}

// 分配一个sapn
func (c *mcentral) cacheSpan() *mspan {
c.nonempty //如果有空闲的,直接返回
s = c.grow()//否则从heap上申请
}

// 计算机出要分配的页数,直接从堆上分配
func (c *mcentral) grow() *mspan {
npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
s := mheap_.alloc(npages, c.spanclass, false, true)
}

//从已经GC的堆上分配n页
func (h *mheap) alloc(npage uintptr, spanclass spanClass, large bool, needzero bool) *mspan {
var s *mspan
systemstack(func() {
s = h.alloc_m(npage, spanclass, large)
})
}

func (h *mheap) alloc_m(npage uintptr, spanclass spanClass, large bool) *mspan {
_g_ := getg()
s := h.allocSpanLocked(npage, &memstats.heap_inuse)
}

//首先在mheap的free中分配,如果失败,则向area申请
func (h *mheap) allocSpanLocked(npage uintptr, stat *uint64) *mspan {
var s *mspan
s = h.pickFreeSpan(npage)
if s != nil {
goto HaveSpan
}
// On failure, grow the heap and try again.
if !h.grow(npage) {
return nil
}
}
func (h *mheap) grow(npage uintptr) bool {
ask := npage << _PageShift
v, size := h.sysAlloc(ask)

}

//向arena申请分配
func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
n = round(n, heapArenaBytes)
// First, try the arena pre-reservation.
v = h.arena.alloc(n, heapArenaBytes, &memstats.heap_sys)

}

func (l *linearAlloc) alloc(size, align uintptr, sysStat *uint64) unsafe.Pointer {
p := round(l.next, align)
if p+size > l.end {
return nil
}
l.next = p + size
if pEnd := round(l.next-1, physPageSize); pEnd > l.mapped {
// We need to map more of the reserved space.
sysMap(unsafe.Pointer(l.mapped), pEnd-l.mapped, sysStat)
l.mapped = pEnd
}
return unsafe.Pointer(p)
}

//用mmap向OS申请
func sysMap(v unsafe.Pointer, n uintptr, sysStat *uint64) {
mSysStatInc(sysStat, n)
p, err := mmap(v, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_FIXED|_MAP_PRIVATE, -1, 0)
if err == _ENOMEM {
throw("runtime: out of memory")
}
if p != v || err != 0 {
throw("runtime: cannot map pages in arena address space")
}
}