内存分配
jingyugao/golang_runtime_reading
glibc
用的malloc为ptmalloc2。往往不能满足我们的需要,造轮子实现一个内存池或者内存分配器几乎是家常便饭。对于ptmalloc2,至少有以下缺点。
1. 内存碎片多。
2. 内存释放方式不太好。ptmalloc 收缩内存是从 top chunk 开始,如果与 top chunk 相邻的 chunk 不能释放, top chunk 以下的 chunk 都无法释放。
3. 锁的开销,并发性能低。
4. 内存释放不可控(free后不会立刻把内存还给os,而且采用)。
5. 线程不均衡。(线程A分配的内存用完之后free掉,这块内存一般不会立刻归还os,线程B malloc的时候不能用这块内存)
6. 元数据较大。
7. 小对象分配速度低。
glibc和tmalloc对比
go的内存分配器
tmalloc是通用的内存分配器,通用性往往会降低性能,因此go借鉴tmalloc的思想实现了一套内存分配器。此外go还有垃圾回收机制,这也增加了分配器的复杂度。 首先,os的内存是以页为单位的,go自己搞了一个页的概念,一页为8k,span是多个页。 分为多级姑且称为(0-3)
0级:封装了mmap和ummap,向os申请内存,每次都申请一大块。
1级:heap,里面管理了所有的内存,全局唯一。负责把内存切割成span。并且有内存信息统计功能。
2级:mcentral,全局唯124个。把内存切割为相同大小的"对象",一个"对象"就是最小的分配单位。(有一点点浪费,但是整齐,方便管理)
3级:mcache,每个P一个。相当于TLS变量,不需要加锁,很快。
span
go的内存是以span组织的,一个span是多个页。span主要是给内存做一个bitmap,标记是否使用,有O(1)的分配效率。 span这个bitmap很巧妙,主要有两个函数。nextFreeIndex
和nextFreeFast
首先看nextFreeFast:
func nextFreeFast(s *mspan) gclinkptr {
// 第几位开始不是0,从0位开始,从右边开始数。
theBit := sys.Ctz64(s.allocCache) // Is there a free object in the allocCache?
// 超过了说明没可用的了
if theBit < 64 {
result := s.freeindex + uintptr(theBit)
// 超过了表明无可用的
if result < s.nelems {
// 可能有可用的
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
// 不是最后一个,且整除64,返回0
return 0
}
// 真的有可用的
// 更新一下
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(result*s.elemsize + s.base())
}
}
return 0
}
看到这难免有些疑问 allocCache是什么? Ctz64又是什么? * 这段代码又是如何工作的?
首先来看go的注释:
allocCache 是 allocBit的一段(所以叫cache),从freeindex开始的64位(可能会溢出,自行考虑) freeindex 在0-nelems之间,代表了查找空闲对象的起点。 每次分配都会从freeindex开始扫描allocBits直到它为0(allocBits的第x位),意味着这里是(x)空闲对象 freeindex 调节以使以后的扫描从新发现的freeobject开始。 如果freeindex==nelem 说明没有空闲对象了 allocBits是对象的bitmap,allocBit中1表示使用,allocCache中1表示未使用 如果n>=freeindex而且allocBits[n/8]&(1<<(n%8))为0,(即allocBits的第n位为0),说明这是free的。 否则,n是已经分配的。nelem后的Bits未定义的,不应该被引用 object n 的起使内存是 n*elemsize+(start<<\pageShift) (span起使内存+对象偏移)
这里比较乱,大概意思是,allocBits是一个bitmap,标记是否使用,通过allocBits已经可以达到O(1)的分配速度,但是go为了极限性能,对其做了一个缓存,allocCache,从freeindex开始,长度是64。
初始状态,freeindex是0,allocCache全是1,allocBit是0。
第一次分配,theBit是0,freeindex为1,allocCache为01111...。
第二次分配,theBit还是0,freeinde是2,allocCache为001111...。
...
在一个span清扫之后,情况会又些复杂。 freeindex会为0,不妨假设allocBit为1010....1010。全是1和0交替。
第一次分配,theBit是1,freeindex是2,allocCache是001010...10。
第二次分配,theBit是1,freeindex是4,allocCache是00001010..10。 ...
最终freeindex变为了64返回0,这是会调用func (s *mspan) nextFreeIndex() uintptr
func (s *mspan) nextFreeIndex() uintptr {//这个函数有硬件优化,性能极高。
sfreeindex := s.freeindex
snelems := s.nelems
if sfreeindex == snelems { // 没有空闲的对象了
return sfreeindex
}
aCache := s.allocCache
bitIndex := sys.Ctz64(aCache)
for bitIndex == 64 { //allocCache为0,没有一个空闲对象,说么要更新一下allocCache了
// 找到下一个缓存的地址。这个是吧sfreeindex取向上64的倍数
sfreeindex = (sfreeindex + 64) &^ (64 - 1)
if sfreeindex >= snelems {
// 说明真的没了
s.freeindex = snelems
return snelems
}
// 只是缓存的值不行了,更新一下allocCache就好
whichByte := sfreeindex / 8 // sfreeindex是64的倍数,不需要担心整除的问题
s.refillAllocCache(whichByte)
aCache = s.allocCache
bitIndex = sys.Ctz64(aCache)
}
result := sfreeindex + uintptr(bitIndex)
if result >= snelems {
s.freeindex = snelems
return snelems
}
s.allocCache >>= uint(bitIndex + 1)
sfreeindex = result + 1
if sfreeindex%64 == 0 && sfreeindex != snelems {
whichByte := sfreeindex / 8
s.refillAllocCache(whichByte)
}
s.freeindex = sfreeindex
return result
}
这个函数会试着移动一下缓存,如果不行就申请新的span。通过这个操作,极大的提高了内存的复用和速度。 回收的时候也只需要标记一下bitmap就可以了。不会出现间隙对象不能复用的情况。
mcache
对于小块内存(<32k)是从mcache开始分配,若不足则从上一级分配内存。多级分配。mcache每个P一个,分配时不需要加锁,这一层用来解决并发问题。 另外由于go是有gc的,go的gc是标记清楚法,这个算法会从根对象开始遍历所有储存指针的地方并递归标记。 这种方法往往需要知道一个地方储存的是指针还是整数,go通过bitmap储存了这个信息。 但是go对此做了一个优化,把内存分为noscan和scan的,对于前者,nosca意味着不含指针,在gc扫描过程中可以被忽略。 我们程序中经常使用的都是一些不含指针的结构体,他们不会被扫描,这样可以提高gc的性能。 对于noscan且非常小的对象(<16byte),为了避免浪费,go做了一个小对象分配器来分配,减小的内存分配效率。
mcentral
mcentral是固定大小的分配器,有124个。这一级出现了对象的概念,比如class=3的mcentral是对象大小为32byte的分配器。 其中的span以双向链表的方式组织,分为两个。 noempty表示表示确定该span最少有一个未分配的元素,empty表示不确定该span最少有一个未分配的元素。 在查找的时候还会辅助做一些内存回收。如果没有内存可以会继续向上一级分配内存。 mcentral.go文件比较小,有意思的函数是func (c *mcentral) cacheSpan() *mspan
,这个函数有100多行,占了文件的一半。下面分析一下简要流程。 这个函数是用来给mcache发放span的,分配之前会做一些回收工作,具体做多少根据mcentral的类型决定。 比如class=3的mcentral要做一个pageSize(8k)的gc工作量。(这个8k还需要乘以一个系数,之后得到的是要清扫的页数,这个页数就是工作量)
retry:
for s = c.nonempty.first; s != nil; s = s.next {
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
//至少有一个可以使用的对象。
//待会分配完就不保证还有了,所以放入empty中。
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
s.sweep(true)
goto havespan
}
if s.sweepgen == sg-1 {
// 正在清扫,下一个
// the span is being swept by background sweeper, skip
continue
}
// 请扫过了 可以直接用
// we have a nonempty span that does not require sweeping, allocate from it
c.nonempty.remove(s)
c.empty.insertBack(s)
unlock(&c.lock)
goto havespan
}
这里需要解释一下nonempty 和empty的语意。 nonempty意味着至少有一个对象。 比如class=3,这个span有8k,总共可以有256个对象,nonempty意味着现在最多有255个,也就是说,还有一个空余,也可能更多。 empty意味着不确定只少有一个可用的对象。可能有,也可能没有。只要可能没有,就放进去。 如果没有的话会去empty中碰碰运气,
for s = c.empty.first; s != nil; s = s.next {
// 需要清扫
if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
//这个链表遍历操作很秀,下面的remove和inertBack
c.empty.remove(s)
c.empty.insertBack(s) //现在s是链表末尾且s.next=nil
unlock(&c.lock)
s.sweep(true)
freeIndex := s.nextFreeIndex()
if freeIndex != s.nelems {
s.freeindex = freeIndex
//找到空闲的就返回了。
goto havespan
}
lock(&c.lock)
//这里会回到函数开头。
goto retry
}
if s.sweepgen == sg-1 {
// the span is being swept by background sweeper, skip
continue
}
// already swept empty span,
// all subsequent ones must also be either swept or in process of sweeping
break
}
mheap
堆是负责最终的内存分配,准确的说是span的分配,堆中负责把内存切割为span(以及span再切割,span合并), 还会统计各种内存信息,gc信息,bitmap信息,还包括了几个特殊的分配器(go内部使用) 首先heap把span分为四类:
free [MaxMHeapList]mSpanList:小于128页的span,且未使用。是一个数组链表。下标代表span的页数。
freelarge mTreap:大于128页的span,且未使用。是一个树堆。
busy [MaxMHeapList]mSpanList:小于128页的span,且已使用。
busylarge mSpanList:大于128页的span,且已使用。是一个链表。
我们知道,mcentral都是有固定型号的(对象大小,span页数),比如class=34的mcentral需要的span是16k,两页。mheap则负责去查找2页的span。这里是first-fit。
for i := int(npage); i < len(h.free); i++ {
list = &h.free[i]
if !list.isEmpty() {
s = list.first
list.remove(s)
goto HaveSpan
}
}
如果查找不到则会从freeLarge中查找一个。best-fit。
for t != nil {
if t.spanKey == nil {
throw("treap node with nil spanKey found")
}
if t.npagesKey < npages {
t = t.right
} else if t.left != nil && t.left.npagesKey >= npages {
t = t.left
} else {
result := t.spanKey
root.removeNode(t)
return result
}
}
如果span较大会对其进行切割,将剩余的边角料放入free或者freeLarge中去。 此外,分配之前还会进行回收工作,如果busy中的span有不用了的,会将其放入free中。
参考资料: