Golang 内存管理

167 阅读8分钟

Linux内存管理

内存管理的核心目标

  • 有效的管理系统的内存资源,以提高系统性能、确保程序的正确执行
  • 最大限度的利用可用的内存空间

PTmalloc

glibc(GUN C)默认的内存管理器;内存分配单位为chunk

graph TD
    A(("thread")) --> |"malloc()"|B["加锁分配区"]
    B --> C{"chunk_size <= 64B"}
    C --> |"yes"|D["在fast bins中分配"]
    C --> |"no"|E{"chunk_size < 512B"}
    D --> |"失败"|F["small bin中分配"]
    E --> |"yes"|F
    F --> |"失败"|G["合并fase bin中相邻chunk到unsorted bin中,从中分配"]
    G --> |"失败"|H["从large bins中分配"]
    E --> |"no"|H
    H --> |"失败"|I["从top chunk中分配"]
    I --> |"失败"|J{"主分配区?"}
    J --> |"否"|L["mmap()增加top chunk或者直接分配"]
    J --> |"是"|M["sbrk()增加top chunk或大内存直接mmap()"]

优点:

  • 良好的兼容性,可移植性,稳定性,兼具效率。

缺点:

  • 后分配的内存先释放,因为 ptmalloc 收缩内存是从 top chunk 开始,如果与 top chunk 相邻的 chunk 不能释放, top chunk 以下的 chunk 都无法释放;
  • 对多线程的支持不好,多线程切换锁的开销较大;
  • 内存碎片问题,不定期分配长生命周期的内存容易造成内存碎片,不利于回收。
  • 每个线程都有独立的内存管理空间(arena),每个arena管理着其线程独有的内存分配与释放。这种设计方式是为了避免不同线程之间的竞争条件,提高并发性能和减少锁的开销。同时也导致了不同线程之间无法共享内存,不能从一个arena移动到另一个arena。如果多线程使用内存不均衡,容易导致内存的浪费;

TCmalloc : Thread-Caching malloc

tcmalloc是Google开源的一个内存管理库, 作为glibc malloc的替代品。为了多线程并发的内存管理设计的 ,TCmalloc主要是在线程级实现了缓存(Thread Cache),使得用户申请内存时,大多情况是无锁分配。提高了运行的效率

TCmalloc的三级缓存

  1. Thread Cache(线程缓存) :每个线程都有自己的本地缓存,用于快速分配和释放内存。当线程请求内存分配时,tcmalloc会首先检查线程本地缓存中是否有可用的内存块。这种线程本地缓存机制能够避免多线程竞争,提高内存操作的性能。
  2. Central Cache(中央缓存) :所有线程共享的中央缓存用于存储较大的内存块,它会在多个线程之间共享和重用内存资源。当线程本地缓存不足或未命中时,tcmalloc会尝试从中央缓存中获取可用的内存块,以满足分配请求。
  3. Page Heap(页堆) :页堆是用于存储较大的内存块和未被使用的内存页的地方。当中央缓存无法满足分配请求时,tcmalloc会从页堆中获取更大的内存块,并将其分割为适当大小的块以供分配。

tcmalloc.png

分配流程:

  • 小对象 <=32k:threadCache->centralCache->pageHeap->os
  • 大对象 >32k: pageHeap->os

优点:

  • 小内存不加锁分配,提高线程的性能,大内存申请使用自旋锁,更加高效;
  • 通过缓存和分级管理机制,以及避免不必要的锁竞争来提高系统的可伸缩性
  • 减少了内存碎片,能够通过内存区块的精细管理和智能分配策略来减少内存碎片的产生,提高内存的利用率,ThreadCache阶段性回收内存到CentralCache中;

缺点:

  • 在多线程大内存分配的场景下,自旋锁可能导致系统的CPU暴涨;
  • 线程本地缓存的内存开销;

Jemalloc

jemalloc是facebook开源的内存管理器。具有强大的多核/多线程的内存分配能力,对比设计,比ptmalloc,tcmalloc更加复杂。随着硬件发展,锁竞争成为了多核/多线程内存分配的最大瓶颈;

核心概念:

  • size_class:代表 jemalloc 分配的内存大小,共有 NSIZES(232)个小类
    • 小内存:对于64位机器来说,通常区间是 [8, 14kb],常见的有 8, 16, 32, 48, 64, ..., 2kb, 4kb, 8kb,注意为了减少内存碎片并不都是2的次幂;
    • 大内存:对于64位机器来说,通常区间是 [16kb, 7EiB],从 4 * page_size 开始,常见的比如 16kb, 32kb, ..., 1mb, 2mb, 4mb
    • size_index:size 位于 size_class 中的索引号,区间为 [0,231],比如8字节则为0,14字节(按16计算)为1,4kb字节为28,当 size 是 small_class 时,size_index 也称作 binind
  • bin:管理正在使用中的 slab(即用于小内存分配的 extent) 的集合,每个 bin 对应一个 size_class
  • extent:管理 jemalloc 内存块(即用于用户分配的内存)的结构,每一个内存块大小可以是 N * page_size(4kb)(N >= 1)。每个 extent 有一个序列号(serial number)。一个 extent 可以用来分配一次 large_class 的内存申请,但可以用来分配多次 small_class 的内存申请。
  • slab:当 extent 用于分配 small_class 内存时,称其为 slab。一个 extent 可以被用来处理多个同一 size_class 的内存申请
  • area:用于分配&回收 extent 的结构,每个用户线程会被绑定到一个 arena 上,默认每个逻辑 CPU 会有 4 个 arena 来减少锁的竞争,各个 arena 所管理的内存相互独立。
    • arena.extents_dirty : 刚被释放后空闲 extent 位于的地方
    • arena.extents_muzzy : extents_dirty 进行 lazy purge 后位于的地方,dirty -> muzzy
    • arena.extents_retained : extents_muzzy 进行 decommit 或 force purge 后 extent 位于的地方,muzzy -> retained
  • cache_bin:每个线程独有的用于分配小内存的缓存
  • tsd:Thread Special Data:每个线程独有,存放有关于这个线程相关的结构结构

分配流程:

  • 小内存 <=32k:cache_bin -> slab -> slabs_nonfull -> extents_dirty -> extents_muzzy -> extents_retained -> os
  • 大内存 >32k:extents_dirty -> extents_muzzy -> extents_retained -> os

优点:

  • 多线程下的性能以及内存碎片的减少
  • 对于保证多线程性能有不同 arena、降低锁的粒度、使用原子语义

缺点:

  • arena之间内存不可见,导致两个arena的内存出现大量交叉从而无法合并
  • 额外的内存开销

golang内存管理

golang的内存管理就是基于TCMalloc的核心思想来构建的;

golang_malloc.png

span:span是由1个或多个连续Page组成,每个Span对象都有一个起始Page地址以及包含的Page数量。同时Span还有prev和next两个指针,方便组成双向链表。这里的spanClass就是我们上面所说的分级,每个span都只会服务于一种SpanClass。

sizeClass:

  • ObjectSize:是指协程应用逻辑一次向Golang内存申请的对象Object大小。Object是Golang内存管理模块针对内存管理更加细化的内存管理单元。一个Span在初始化时会被分成多个Object。
  • SizeClass:一块内存的所属规格或者刻度。Golang内存管理中的Size Class是针对Object Size来划分内存的,划分Object的大小级别;
  • SpanClass:针对Span大小级别进行划分的,一个SizeClass会对应两个SpanClass,一个存放需要进行GC扫描的对象(指针对象),另一个不需要进行GC扫描的对象

span.jpg mcache:线程局部缓存,不需要加锁,作为一个缓存,由0~133多个span-class组成的,里面存放了大量已分配或未分配的Span。当用户程序申请小对象内存时,mcache会查找自己管理的内存块,如果有符合条件的就直接返回,否则向中端请求一批内存来重新填充。** 每个mcache绑定一个P,而不是M(局部性原理,避免线程切换带来的开销)**

mcentral:和mcache的组成一样,0~133多个span-class组成的,但每个级别都保存了2个span Set,即2个span集合 1. Partial Span Set :这个集合里的span,所有span都至少有1个空闲的对象空间。这些span是mcache释放span时加入到该链表的。2. Full Span Set:这个集合里的span,所有的span都不确定里面是否有空闲的对象空间。当一个span交给mcache的时候,就会加入到Full集合中。

mheap:保存了两棵二叉排序树,按span的page数量进行排序,垃圾回收导致的span释放,span会被加入到scav,否则加入到free:

  • free:free中保存的span是空闲并且非垃圾回收的span。
  • scav:scav中保存的是空闲并且已经垃圾回收的span。

mheap中还有arenas,由一组heapArena组成,每一个heapArena都包含了连续的pagesPerArena个span,这个主要是为mheap管理span和垃圾回收服务。mheap本身是一个全局变量,它里面的数据,也都是从OS直接申请来的内存,并不在mheap所管理的那部分内存以内。

内存分配的过程:

针对分配对象的不同大小有不同的分配对象

  • (0,16B)且不包含指针的对象:tiny 分配
  • (0,16B)包含指针的对象:正常分配
  • [16B,32KB]:正常分配
  • (32KB,-):大对象分配

小对象是在mcache中分配的,而大对象是直接从mheap分配的;

申请size为n的内存为例子,分配步骤如下:

  1. 获取当前goroutine的私有缓存mcache
  2. 根据size 计算合适class的ID,mcache中有合适大小的span,就分配结束。
  3. 从mcache的alloc[class]链表中查询可用的span
  4. 如果mcache中没有可用的span,则从mcentral申请一个新的span加入mcache中
  5. 如果mcentral中也没有可用的span,则从mheap中申请一个新的span加入mcentral
  6. 从该span中获取空闲对象地址并返回。

内存释放的过程

Go使用垃圾回收收集不再使用的span,调用mspan.scavenge()把span释放还给OS(并非真释放,只是告诉OS这片内存的信息无用了,如果你需要的话,收回去好了),然后交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配;