• Go runtime剖析系列(一):内存管理


    Go 的内存管理总览

    程序在运行过程中会不断的调用分配器分配堆内存,同时也会通过赋值器更新内存数据,如果我们将内存看做为一个个小的对象集合,那程序的运行其实就是更改这个对象图,其中分配器在这个对象图中创建节点,赋值器更改节点之间的依赖关系,当我们从根节点(全局对象,栈对象)出发遍历对象图时,没有被遍历到的节点就会成为垃圾,等待 gc 的回收。

    以上为 Go 的内存管理组件图,这篇文章主要是分析分配器的具体实现,同时也会对 gc 有所提及。

    Go 的连续内存分配

    在 1.10 及之前,Go 采用的连续内存管理,通过预先 reserve 一定大小的内存提升内存分配的效率,内存模型如下:

    引用[2]

    • arena 这个区域就是讨论的堆区,为 512GB
    • bitmap 每 2bit 对应 arena 区域的 8Byte(一个指针大小),总大小为 512GB/64*2=16GB,存储了对应指针大小内存是否为一个指针以及 GC 扫描时是否需要继续扫描的标记
    • spans 管理内存的 mspan 结构的数组,每一个 mspan 结构管理一页(8KB),所以大小为 512GB/8KB*8BYTE=512MB

    Go 的稀疏内存分配

    在 Go1.11 版本之后,Go 更改了自己的内存分配策略,现在的内存模型如下:

    go 的内存管理思想继承自 tcmalloc,mspan 管理一段连续内存,里面存储着相同大小的对象,每个 P(go 里面控制并发度的对象,一般为 GOMAXPROCS,每个内核线程 M 在运行前都会获取一个空闲的 P)都有自己的 mspan 缓存,其为一个 list,分别管理 8Byte,16Byte,32Byte...等大小对象,大部分内存分配可以直接从线程内维护的 mspan 链表获取空闲内存,不需要加锁,如果没有在本地找到合适的,就加锁并向 mcentral 申请,如果 mcentral 有相同 spanClass 的 mspan 链表不为空,则交割出一部分给 mcache,如果没有,mcentral 会向 mheap 申请,mheap 会检查本地并在没有时向系统申请 heaparena 并分配给 mcentral,接下来详细分析

    mspan,mcache,mcentral,mheap,mheaparena 结构在内存分配中的作用和关系

    mspan 结构

    mspan 为内存分配的基本单元,用来管理连续内存(可能为 1 页,也可能为多页),与内存分配关系紧密的成员变量如下:

    资料领取直通车:Golang云原生最新资料+视频学习路线

    Go语言学习地址:Golang DevOps项目实战

    1. next,prev 这两个变量将 mspan 结构构成了一个链表,在 mcache,mcentral 中通过 mSpanList{first:*mspan,last:*mspan}结构管理

    2. startAddr,nPages 为这个 mspan 管理的连续内存区间[startAddr,startAddr+nPages*8k]

    3. spanclass,elemsize,nelems,allocCount spanclass 为一个枚举,等于 size_class<<1 & noscan,其中 size_class 为 66+1 个,noscan 为一个 bit,表示这个 spanclass 内的对象是否有指针成员变量(因为 gc 扫描时需要对有指针成员变量的类型进行扫描以获取整个内存的对象图,此处分开可以减少扫描的内存大小,增加扫描效率),elemsize 为对象大小,nelems 为对象最大数量,nelems*elemsize=nPages*8k,allocCount 为已经分配的对象数量

    4. allocBits,allocCache,freeIndex allowBits 是一个 bit 数组,大小为 nelems,bit=1 表示对应 index 已经被分配,allocCache 是 allowBits 某一段的映射,其是为了加速在 allocBits 中寻找空闲对象存在的,在 allocCache 中,bit=0 表示对应 index 已经被分配(和 allocBits 相反),freeIndex 是一个和 allocCache 挂钩的变量,其存储了 allocCache 最后一 bit 在 allocBits 的偏移量,对于 mspan 管理的 nelems 个对象,[0,freeIndex)已经全部被分配出去,[freeIndex,nelems)可能被分配,也可能是空闲的,每次 gc 后 freeIndex 置为 0,相关图如下。

    引用[2]

    1. //go:notinheap
    2. type mspan struct {
    3. next *mspan // next span in list, or nil if none
    4. prev *mspan // previous span in list, or nil if none
    5. startAddr uintptr // address of first byte of span aka s.base()
    6. npages uintptr // number of pages in span
    7. // freeindex is the slot index between 0 and nelems at which to begin scanning
    8. // for the next free object in this span.
    9. // Each allocation scans allocBits starting at freeindex until it encounters a 0
    10. // indicating a free object. freeindex is then adjusted so that subsequent scans begin
    11. // just past the newly discovered free object.
    12. //
    13. // If freeindex == nelem, this span has no free objects.
    14. //
    15. // allocBits is a bitmap of objects in this span.
    16. // If n >= freeindex and allocBits[n/8] & (1<<(n%8)) is 0
    17. // then object n is free;
    18. // otherwise, object n is allocated. Bits starting at nelem are
    19. // undefined and should never be referenced.
    20. //
    21. // Object n starts at address n*elemsize + (start << pageShift).
    22. freeindex uintptr
    23. // TODO: Look up nelems from sizeclass and remove this field if it
    24. // helps performance.
    25. nelems uintptr // number of object in the span.
    26. // Cache of the allocBits at freeindex. allocCache is shifted
    27. // such that the lowest bit corresponds to the bit freeindex.
    28. // allocCache holds the complement of allocBits, thus allowing
    29. // ctz (count trailing zero) to use it directly.
    30. // allocCache may contain bits beyond s.nelems; the caller must ignore
    31. // these.
    32. allocCache uint64
    33. // allocBits and gcBits hold pointers to a span's and
    34. // allocation bits. The pointers are 8 byte aligned.
    35. // There are three arenas where this data is held.
    36. // free: Dirty arenas that are no longer accessed
    37. // and can be reused.
    38. // next: Holds information to be used in the next GC cycle.
    39. // current: Information being used during this GC cycle.
    40. // previous: Information being used during the last GC cycle.
    41. // A new GC cycle starts with the call to finishsweep_m.
    42. // finishsweep_m moves the previous arena to the free arena,
    43. // the current arena to the previous arena, and
    44. // the next arena to the current arena.
    45. // The next arena is populated as the spans request
    46. // memory to hold gcBits for the next GC cycle as well
    47. // as allocBits for newly allocated spans.
    48. //
    49. // The pointer arithmetic is done "by hand" instead of using
    50. // arrays to avoid bounds checks along critical performance
    51. // paths.
    52. // The sweep will free the old allocBits and set allocBits to the
    53. // gcBits. The gcBits are replaced with a fresh zeroed
    54. // out memory.
    55. allocBits *gcBits
    56. allocCount uint16 // number of allocated objects
    57. spanclass spanClass // size class and noscan (uint8)
    58. elemsize uintptr // computed from sizeclass or from npages
    59. ...
    60. }

    mcache 结构

    mcache 结构和每个 P 挂钩,通过保存 mspan 的链表作为局部缓存提供不需要加锁就能进行内存分配的能力,与内存分配关系紧密的成员变量如下: 1. alloc mspan 的链表数组,数组大小为 134=(66+1)*2,以 spanclass 作为数组索引 2. tiny,tinyoffset go 用来分配微对象使用的变量,go 会将小于 16Byte 的非指针数据当作微对象,会将其聚合为一个 16Byte 的对象进行统一存储,其中 tiny 为这个 16Byte 对象的首地址,tinyoffset 为已经被使用的字节数

    1. // Per-thread (in Go, per-P) cache for small objects.
    2. // No locking needed because it is per-thread (per-P).
    3. //
    4. // mcaches are allocated from non-GC'd memory, so any heap pointers
    5. // must be specially handled.
    6. //
    7. //go:notinheap
    8. type mcache struct {
    9. // tiny points to the beginning of the current tiny block, or
    10. // nil if there is no current tiny block.
    11. //
    12. // tiny is a heap pointer. Since mcache is in non-GC'd memory,
    13. // we handle it by clearing it in releaseAll during
    14. // termination.
    15. tiny uintptr
    16. tinyoffset uintptr
    17. // The rest is not accessed on every malloc.
    18. alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
    19. ...
    20. }

    mcentral 结构

    mcentral 保存在全局的 mheap 对象中,每一个 spanClass 对应一个 mcentral,mcentral 维护了和自己 spanClass 相等的 mspan 列表,访问 mcentral 需要加锁,与内存分配关系紧密的成员变量如下: 1. lock 锁变量,访问 mcentral 结构需要锁 2. spanClass 等于 mcentral 结构维护的 mspan 链表的 spanClass 3. noempty,empty 两个 mspan 链表,empty 链表里面的 mspan 要么所有对象已经被分配,要么整个 mspan 已经移交给 mcache,noempty 里面保存着还存在空闲对象的 mspan 4. nmalloc 已分配对象数的粗略估计,其假定交割给 mcache 的 mspan 最后都会被完全分配出去

    1. type mcentral struct {
    2. lock mutex
    3. spanclass spanClass
    4. nonempty mSpanList // list of spans with a free object, ie a nonempty free list
    5. empty mSpanList // list of spans with no free objects (or cached in an mcache)
    6. // nmalloc is the cumulative count of objects allocated from
    7. // this mcentral, assuming all spans in mcaches are
    8. // fully-allocated. Written atomically, read under STW.
    9. nmalloc uint64
    10. }

    以上所有数据结构是 mheap 用来给用户程序分配内存涉及到的数据结构,并不会涉及到实际的与操作系统的交互(sysAlloc),下面为 mheap 实际用来管理最多 256TB 堆区内存的数据结构

    heapArena 结构

    由于 Go 在现版本采用了稀疏内存管理,堆区的所有内存可能为不连续的,go 将堆区内存视为一个个内存块,每一个内存块的大小为 64MB,并使用 heapArena 结构进行管理,同时在 mheap 全局结构中维护 heapArena 的指针数组,大小为 4M,这使 go 能管理的内存扩大到 256TB,在 heapArena 结构中,与内存分配关系紧密的成员变量如下: 1. bitmap 作用等于 go 在 1.10 版本之前的 bitmap 区,采用 2bit 管理一个 8Byte(x64 平台下的指针大小),所以 1Byte 可以管理 4 个指针大小数据,低 4 位为这四个指针大小数据是否为指针,高 4 位为 gc 扫描时是否需要继续往后扫描,主要用于 GC 2. spans mspan 数组,go 语言中会为每一页指定一个 mspan 结构用来管理,所以数组大小为 64MB/8KB=8192 3. pageInUse,pages gc 使用的字段,pageInUse 表示相关的页的状态为 mSpanInUse(GC 负责垃圾回收,与之相对的是 goroutine 的栈内存,由栈管理),pages 表示相关页是否存在有效对象(在 gc 扫描中被标记),用来加速内存回收 4. zeroedBase 用来加速第一次分配,在这之后的内存已经是 0,在分配给用户程序程序时不再需要额外置 0,本身单调递增

    1. // A heapArena stores metadata for a heap arena. heapArenas are stored
    2. // outside of the Go heap and accessed via the mheap_.arenas index.
    3. //
    4. //go:notinheap
    5. type heapArena struct {
    6. // bitmap stores the pointer/scalar bitmap for the words in
    7. // this arena. See mbitmap.go for a description. Use the
    8. // heapBits type to access this.
    9. bitmap [heapArenaBitmapBytes]byte
    10. // spans maps from virtual address page ID within this arena to *mspan.
    11. // For allocated spans, their pages map to the span itself.
    12. // For free spans, only the lowest and highest pages map to the span itself.
    13. // Internal pages map to an arbitrary span.
    14. // For pages that have never been allocated, spans entries are nil.
    15. //
    16. // Modifications are protected by mheap.lock. Reads can be
    17. // performed without locking, but ONLY from indexes that are
    18. // known to contain in-use or stack spans. This means there
    19. // must not be a safe-point between establishing that an
    20. // address is live and looking it up in the spans array.
    21. spans [pagesPerArena]*mspan
    22. // pageInUse is a bitmap that indicates which spans are in
    23. // state mSpanInUse. This bitmap is indexed by page number,
    24. // but only the bit corresponding to the first page in each
    25. // span is used.
    26. //
    27. // Reads and writes are atomic.
    28. pageInUse [pagesPerArena / 8]uint8
    29. // pages is a bitmap that indicates which spans have any
    30. // ed objects on them. Like pageInUse, only the bit
    31. // corresponding to the first page in each span is used.
    32. //
    33. // Writes are done atomically during ing. Reads are
    34. // non-atomic and lock-free since they only occur during
    35. // sweeping (and hence never race with writes).
    36. //
    37. // This is used to quickly find whole spans that can be freed.
    38. //
    39. // TODO(austin): It would be nice if this was uint64 for
    40. // faster scanning, but we don't have 64-bit atomic bit
    41. // operations.
    42. pages [pagesPerArena / 8]uint8
    43. // zeroedBase s the first byte of the first page in this
    44. // arena which hasn't been used yet and is therefore already
    45. // zero. zeroedBase is relative to the arena base.
    46. // Increases monotonically until it hits heapArenaBytes.
    47. //
    48. // This field is sufficient to determine if an allocation
    49. // needs to be zeroed because the page allocator follows an
    50. // address-ordered first-fit policy.
    51. //
    52. // Read atomically and written with an atomic CAS.
    53. zeroedBase uintptr
    54. }

    mheap 结构

    mheap 结构在全局具有唯一对象,用来协调整个内存管理,与内存分配关系紧密的成员变量如下: 1. allspans 已经分配的所有 mspan 对象 2. arenas heapArena 数组,在 linux x64 平台下为 4M 大小,总共管理 256TB 的内存 3. central mcentral 数组,大小为 134=(66+1)*2 4. spanalloc,cachealloc... 为分配 mspan,mcache...等固定元信息结构的分配器,类似于 linux 内核对 task_struct 等结构的处理,采用链表串联所有空闲对象,free 时直接加入链表,分配时从链表取出,没有就调用 sysAlloc 函数,分配的内存不受 gc 管理

    内存分配流程

    前面铺垫了这么多,此处我们开始进入了内存分配的流程解析,go 语言在堆上分配内存的函数入口为 runtime.newobject,这可以通过两种方式确定,一是在 src/cmd/compile/internal/gc/ssa.go 的(*state).expr 函数中,可以看到对 ONEWOBJ 的节点类型(go 在逃逸分析后确定要将对象分配在堆上会构建此节点)调用了 newobject 函数,第二种方法更简单,如下所示:

    1. package main
    2. func F() *int {
    3. var x int
    4. return &x
    5. }
    6. func main() {
    7. F()
    8. }

    编写一段这样的代码,然后运行 go tool compile -S xx.go,查看生成的 F 函数的汇编代码,可以明确的确是调用了 runtime.newobject 函数,newobject 函数简单的调用了 mallocgc 函数,mallocgc 函数的流程如下: 1. 检查 gcBlackenEnabled 开关,这个开关会在 gc 处于_GC 阶段时开启,主要是用来限制用户分配内存的速度不能超过 gc 的速度,采用的是一种借贷-还债的策略(称为 gcAssist),略过 2. 根据 size 和 noscan 标记决定走微对象(大小为(0,16)且不含指针)分配,小对象[8,32k)分配,大对象(>32k)分配流程 3. 如果 noscan=false(即对象包含指针数据),需要调用 heapBitsSetType 更新 heapArena 的 bitmap 数组(采用 2bit 管理 8Byte 数据),为 gc 服务,略过 4. 调用 publicationBarrier 推一个内存同步,主要解决多线程模型下的同步问题,略过 5. 如果当前不处于_GCoff 阶段,需要给分配的新对象打上标记,防止被 gc 错误回收,略过 6. 如果 assistG!=nil,更新 gcAssist 相关数据,略过 7. 检查是否需要启动新一轮 gc,略过 下面我们分别分析微对象,小对象,大对象的内存分配

    小对象([8,32k])内存分配

    1. 先根据 size 算出 sizeclass,再和 noscan 标记算出 spanClass,go 中的 sizeclass 总共为 66+1 种,每个 sizeclass 对应不同的对象大小和 mspan 跨越的页数,同时因为可能需要将对象大小向上扩张,所以可能会导致内存浪费,相关数据如下图所示(sizeclass=0 为大对象对应的 sizeclass,其大小不固定):
    classbytes/objbytes/spanobjectstailwastemaxwaste

    2.调用 nextFreeFast 快速分配空闲对象,nextFreeFast 会检查 allocCache 中是否存在非 0 字段(通过 sys.Ctz64 函数计算二进制下的 0 数量),如果不等于 64,说明存在,更新 allocCache 和 freeIndex,否则返回失败

    3.调用 mcache.nextFree 分配空闲对象,mcache.nextFree->mcache.refill->mcentral.cachespan

      1. 调用 deductSweepCredit,里面会检查是否需要清理内存(Go 的内存回收是后台 goroutine+惰性回收策略),先略过
      2. 检查 noempty 链表是否存在合适的 mspan,mspan.sweepgen 是一个和 gc 周期挂钩的变量,mheap.sweepgen 在每次 gc 中增长 2, mspan.sweepgen=mheap.sweepgen-2 表示还未被清扫,mspan.sweepgen=mheap.sweepgen-1 表示已经正处于清扫中,如果在 noempty 链表没找到合适的 mspan,便在 empty 链表寻找
      3. 如果都没找到,调用 mcentral.grow 扩张,mcentral.grow 会先调用 mheap.alloc->mheap.allocSpan 分配 mspan,然后调用 heapBitsForAddr 初始化 heapArena 的 bitmap 信息,对于指针会全部置为 1(用于 gc 扫描)
      4. 无论通过何种方式找到 mspan,都会调用 mspan.refillAllocCache 更新自身的 allocCache 和 freeIndex 变量

    mheap.allocSpan 如果可以会先检查当前 P 的页缓存(p.pcache)是否能提供这次分配(不需要加锁),否则加锁并请求页分配器(mheap.pages)分配一定页数,如果页分配器无法满足此次分配,将调用 mheap.grow 扩展堆之后重新请求 mheap.pages 分配,分配后设置 mspan,mheap 相关字段。

    mheap.grow 里面调用了 sysAlloc 函数,sys*函数是 go 用来管理虚拟内存状态的函数,go 里面把内存的状态分为 Reserved,Prepared,Ready,相关的状态转换所需要的函数为:

    sysAlloc 里面调用了 sysReserve 和 sysMap 函数直接把内存状态转为 Prepared,并将分配得到的内存放入 mheap.pages 里面。

    微对象((0,16)且不含指针)内存分配

    go 将大小小于 16Byte 且不含指针的数据当作微对象,将其聚合在一个 16Byte 中进行存储 spanClass=5(sizeClass=2,nospan=1)。

    1. if noscan && size < maxTinySize {
    2. // 取上次分配的起始offset,并进行对齐
    3. off := c.tinyoffset
    4. if size&7 == 0 {
    5. off = alignUp(off, 8)
    6. } else if size&3 == 0 {
    7. off = alignUp(off, 4)
    8. } else if size&1 == 0 {
    9. off = alignUp(off, 2)
    10. }
    11. // 如果上次分配的tiny block(16Byte)剩余空间足够,直接分配
    12. if off+size <= maxTinySize && c.tiny != 0 {
    13. // The object fits into existing tiny block.
    14. x = unsafe.Pointer(c.tiny + off)
    15. c.tinyoffset = off + size
    16. c.local_tinyallocs++
    17. mp.mallocing = 0
    18. releasem(mp)
    19. return x
    20. }
    21. // 重新分配一个新的tinyblock,并分配
    22. span := c.alloc[tinySpanClass]
    23. v := nextFreeFast(span)
    24. if v == 0 {
    25. v, _, shouldhelpgc = c.nextFree(tinySpanClass)
    26. }
    27. x = unsafe.Pointer(v)
    28. (*[2]uint64)(x)[0] = 0
    29. (*[2]uint64)(x)[1] = 0
    30. if size < c.tinyoffset || c.tiny == 0 {
    31. c.tiny = uintptr(x)
    32. c.tinyoffset = size
    33. }
    34. size = maxTinySize
    35. }

    大对象(>32k)内存分配

    大对象内存分配为调用 largeAlloc 函数,其流程为: 1. 调用 deductSweepCredit 函数,检查是否需要释放内存 2. 调用 mheap.alloc 直接分配对应页数 3. 初始化 mspan 结构

    内存回收流程

    go 的内存回收入口函数为 sweepone->mspan.sweep 函数,其调用方为:

    1. 后台 goroutine(runtime.bgsweep 函数);

    2. 分配新内存时检查是否需要释放内存,入口函数为 deductSweepCredit,在 largeAlloc(分配大对象)和 mcentral.cacheSpan(微对象和小对象在 mcache 中找不到合适的内存时调用)中被调用;

    3. gc 启动时会检查是否上次 gc 还有未被回收的内存,在 runtime.GC 函数(用户手动调用 GC 回收时被调用)和 gcStart(go runtime 检查需要触发新一轮 gc 时被调用)中被调用。 mspan.sweep 的流程为:

    构函数 Finalizer(用户程序可以通过调用 runtime.SetFinalizer 函数设置),如果有调用; 2. 更新 mspan 状态,主要是把 allocBits 设置为 gcBits 表示相关对象已经被释放,然后调用 refillAllocCache 重新初始化 mspan.allocCache 和 mspan.freeIndex 用来加速下一次分配。

    总结

    go 的内存分配采用类似 TCMalloc 的策略,分别对微对象,小对象,大对象进行分别处理,并且通过 mcache,p.pcache 等线程关联缓存减少了锁的请求量,提高了效率。

    go 的内存分配器是 go runtime 的核心组件之一,内存分配器和垃圾回收器之间的联系非常紧密,有大量的代码穿插在两个组件中间,所以要完整的理解内存分配器的需要对 gc 的原理进行深入分析。

  • 相关阅读:
    43、Kinect 深度相机初体验和vs2022测距输出
    【Python】获取变量占用的内存大小
    指针和数组笔试题的透析
    UMC助推Web3.0
    Android 开发RxJava2常用操作符
    ECharts的基本使用
    Vue3 项目中 vue文件出现大面积爆红问题
    Docker 图形化界面管理工具 Portainer | 让你更轻松的管理 Docker
    【教3妹学算法】特殊数组的特征值
    使用Java实现一个简单的贪吃蛇小游戏
  • 原文地址:https://blog.csdn.net/weixin_52183917/article/details/127907567