深入浅出Golang内存分配模型

深入浅出 Golang 内存分配模型

本文深入Golang Runtime 源码层面分析 Golang 内存分配模型,从最基础的出发为什么要关注堆上内存开始进行分析,到最终的内存分配模型总结,以及在日常工作中如何利用掌握的内存分配基础知识优化程序性能来更加深入的理解 Golang 内存分配工作原理。通过本文你讲会收获以下内容:

  • 为什么我们要关心变量分配到堆上

  • 我们怎么知道变量什么时候分配到堆上,什么时候分配到栈上

  • Golang Runtime 针对系统调用下如何高效的分配内存

  • Golang Runtime 针对多线程环境下如何高效分配内存

  • Golang Runtime 核心组件基础介绍,理解各个核心组件的工作原理

  • Golang Runtime 大对象、中对象、小对象内存分配基本工作原理

  • 如何在日常工作中体现出有阅读过 Golang Runtime 内存分配的行为

  • Golang Runtime 内存分配模型总结

一、为什么我们要关注堆上内存分配

1.1 关注高性能,从而理解内存分配

为什么我们要关注堆上内存分配呢?这个问题显然是和 GC 相关的。我们都知道 Golang 在内存管理上采取了和 C/C++ 不同的管理机制,其实现了了 Runtime 管理和维护内存的申请和释放,并在此基础上实现了逃逸分析和 GC,通过 Runtime 维护内存的管理从而将开发者从这一部门中释放出来,让开发者更加有精力去关注软件本身,而不是底层的内存问题。如果我们想要写出更加高性能的代码,那么我们对于内存分配基本的工作原理是非常有必要掌握的。提前说一下结果:对于 Go 堆来说,其所标志的内存对于 Runtime 来说就是要进行 GC 的,而在 Golang 执行 GC 的时候并不是所有阶段都是异步执行的,其中是会存在 STW 的,同时其执行 GC 也会占用额外的 CPU。

1.2 堆内存分配和非堆内存分配性能分析比较

现在,我们通过下面的两个例子来简单分析一下,为什么我们要关注堆上内存的分配。我们来看下下面的测试代码:这里我们通过控制变量在堆上分配和不在堆上分配来进行比较看一下其在性能上的区别:(什么情况下在堆上分配,什么情况下在栈上分配在第二部分即可揭晓,如果感兴趣,可以跳转过去。)

type BigStruct struct {
 A, B, C int
 D, E, F string
 G, H, I bool
}

//go:noinline
func CreateCopy() BigStruct {
 return BigStruct{
  A: 123, B: 456, C: 789,
  D: "ABC", E: "DEF", F: "HIJ",
  G: true, H: true, I: true,
 }
}

//go:noinline
func CreatePointer() *BigStruct {
 // 逃逸到堆上,在堆上进行分配
 return &BigStruct{
  A: 123, B: 456, C: 789,
  D: "ABC", E: "DEF", F: "HIJ",
  G: true, H: true, I: true,
 }
}

对于上面的测试代码,我们通过 //go:noinline 来禁止编译器函数内联来对我们的代码进行优化,同时我们通过下面的命令查看下具体逃逸分析(如果存在逃逸则会被分配到堆上,如果不存在逃逸分析则则会被分配到栈上,随着函数栈帧的出栈而被清空内存,如果不存在逃逸也是有可能被分配到堆上的,那就是分配的内存太大导致):

 go build -gcflags '-m -l'

我们可以看到如下输出,在函数 CreatePointer 内部申请的内存被分配到堆上,而在 CreateCopy 申请的内存则被分配到栈上(因为其没有明确说明其被逃逸到堆上),同时我们还可以基于基准测试参数 -betchmem 来查看堆内存分配次数来验证是否存在堆内存分配。

PS F:codegogocodegodemointernalmemorytest> go build -gcflags '-m -l'
# gitee.com/xmopen/godemo/internal/memorytest
.main.go:23:9: &BigStruct{...} escapes to heap   // CreatePointer
.main.go:31:13: ... argument does not escape
.main.go:31:14: "hello word" escapes to heap

下图是不在堆上(栈)分配的函数 CreateCopy 的 trace:

go test -run TestCreateCopy -trace=copy_trace
go tool trace copy_trace
# 输出:
PS F:codegogocodegodemointernalmemorytest> go test -run TestCreateCopy -trace=copy_trace
PASS
ok      gitee.com/xmopen/godemo/internal/memorytest     0.086s
深入浅出Golang内存分配模型
image-20240129012421466

下面图是在堆上分配的函数 CreatePointer 的trace:

go test -run TestCreatePoint -trace=point_trace
go tool trace point_trace
# 输出:
PS F:codegogocodegodemointernalmemorytest> go test -run TestCreatePoint -trace=point_trace
PASS
ok      gitee.com/xmopen/godemo/internal/memorytest     0.521s

从这里的go test执行时间对比我们就可以看到堆上内存分配要快的多的多,从而也可以确信的验证堆上内存分配在后台一定在利用CPU做一些我们不知道的事情。

深入浅出Golang内存分配模型
image-20240129004215565

我们可以从对比图中看到,对于触发堆上内存分配的,可以直观的看到其中很多 goroutine 在处理 GC 相关的内容,其很多逻辑核心P都被利用来进行 GC,并且堆、线程操作也比上面多的多。

现在我们可以从整体图上大概可以得知到在堆上分配的函数性能一定是要比在栈上分配的函数性能是低的,具体低在哪里呢? 我们来看一下具体 goroutine 的时间占用分配:

  • 堆上分配

    深入浅出Golang内存分配模型
    image-20240129014400839
  • 栈上分配:

    深入浅出Golang内存分配模型
    image-20240129015117607

同时我们也可以看看GC占用CPU和应用进程占用CPU分配:竖轴为服务进程可用CPU百分比

  • 堆上分配:

    深入浅出Golang内存分配模型
    image-20240129014941793
  • 栈上分配:

    深入浅出Golang内存分配模型
    image-20240129014923596

通过上面的几组对比,我们可以得知在堆上进行内存分配的函数所对应的goroutine抢占CPU时会应为GC而导致等待,而且等待比率还非常高,同时我们通过第二组对比可以得出,在堆上进行内存分配的函数其服务可利用的CPU百分比要远远小于栈上分配。

尽管 Go 的 GC 已经非常高效了,但是这个过程对于开发者来说并不是免费的,可能是免费的,但是其免费的逻辑背后一定藏用不为人知的秘密,其一定会占用多的 CPU 核心来处理其特殊的逻辑。

OK,我想既然大家都已经看了上面的三组对比图,那么对于结论我想大家都应该心知肚明了。哈哈哈哈哈哈,结论已经在上面加粗标识了。

二、如何确定变量是在堆上分配

Go 编译器将会在函数的本地栈帧中存储该函数局部的本地变量。但是如果编译器无法证明当前函数声明的变量在返回后是否被使用,则编译器必须在 Go 堆上分配该变量以避免指针悬空错误。此外,如果局部变量非常大,也会将其存储在堆上而不是栈中。

在第一部分我们已经知道了变量在堆上分布所触发的GC是多么的恐怖,如果我们在编写 Golang 代码的时候如果能提前知道代码分配在堆上环时分配在栈上,那么我们在编写应用程序代码时就能够运筹帷幄,在编码的过程中就做到代码的极致性能。那么我们怎么确定呢?那我们首先应该知道 Golang 有哪几种内存分配方式:

  • new
  • make
  • struct{}
  • &struct{}
  • 逃逸分析
package main

type Object struct {
 Age int64
}

func newObject() {
 _ = new(Object) // 栈上分配
}

func newObjectReturn() *Object {
 return new(Object) // 发生逃逸:堆上分配
}

func makeObject() {
 _ = make([]*Object, 0// 栈上分配
}

// 指针一定会逃逸
func makeObjectReturn() []*Object {
 return make([]*Object, 0// 逃逸到堆
}

func symbolPoint() *Object {
 return &Object{ // 堆上分配
  Age: 10,
 }
}

func main() {
 newObject()
 newObjectReturn()
 makeObject()
 makeObjectReturn()
 symbolPoint()
}

我们看下这几种情况下内存具体是在堆上还是在栈上分配:

PS F:codegogocodegodemointernalmemorytest> go build -gcflags '-N -m -l' .main.go
# command-line-arguments
.main.go:8:9new(Object) does not escape
.main.go:12:12new(Object) escapes to heap
.main.go:16:10make([]*Object, 0) does not escape
.main.go:21:13make([]*Object, 0) escapes to heap
.main.go:25:9: &Object{...} escapes to heap

通过上面输出,我们可以看到如果是发生在堆上,其在申请内存哪一行会说明逃逸到堆上,否则就是在栈上分配。通过测试和一些非官方的文档(文档在本文末尾已经声明),我们可以明确如果当内存发生逃逸的话,那么该变量就会分配到堆上,否则就会在栈上分配。

而我们可以通过下面的命令来判断变量是否逃逸到堆上就可以诊断分析我们自己的程序中是否存在可优化的逻辑了。

栈上分配和堆上分配的区别:操作系统本身有栈和堆两个概念,对于 Golang 来说,我们所说的 Golang 的堆栈其实对于操作系统来说都是堆,只不过,Golang 开发者为了管理 Goroutine 的运行机制,将 Goroutine 栈所对应的内存分配到堆上,同时,Golang 在 Runtime 层又声明了一个 Go 堆。当我们的变量发生逃逸时将会逃逸到堆就是 Go 堆。

三、Golang 内存分配核心组件

这里我们直接将Golang内存分配器的核心组件先介绍给大家,这样的话,去看下面的具体源码的时候才能更加理解其工作原理,否则直接看下面的源码则并不是很清晰,所以这里我会直接给出结论。

Golang 内存分配核心组件有三个,分别是如下:

  • mcache
  • mcentral
  • mheap
  • mspan

3.1 mcache

我们先来看看 mcache 在 Golang Runtime 包中的定义:我们看mcache时会一同看一下 p 的结构定义:

type p struct {
 id          int32
 status      uint32   // one of pidle/prunning/...
 
 mcache      *mcache   // mcache
 pcache      pageCache
 raceprocctx uintptr

 deferpool    []*_defer // pool of available defer structs (see panic.go)
 deferpoolbuf [32]*_defer
}

通过上面的定义我们看到在 Golang 中每一个 P 都拥有一个 mcache,这样做的目的是为了降低锁的粒度,对于多核心CPU来说,针对每一个核心都预先分配一个mcache一定的内存,当程序运行过程中,小于 32KB 的对象将会直接在mcache上进行分配,此时由于一个 P 只会在一个 CPU 核心运行 goroutine 所表示的用户代码时才会被占用,所以CPU在操作P时是线程安全的,不需要加锁,而对于频繁分配的小对象则在很大程度上降低资源竞争锁的粒度。

那我们接着在来看看 mcache 对应的结构体:

type mcache struct {
 _ sys.NotInHeap

 // The following members are accessed on every malloc,
 // so they are grouped here for better caching.
 nextSample uintptr // trigger heap sample after allocating this many bytes
 scanAlloc  uintptr // bytes of scannable heap allocated

 // 小于16bit的直接在tiny这里根据offset+size来进行分配
 tiny       uintptr
 tinyoffset uintptr
 tinyAllocs uintptr // tiny已经分配的次数
 
    // alloc 是mcache中mspan对应的内容
    // 也就是大于16字节小于32K
 // The rest is not accessed on every malloc.
 alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass
}

mcache 结构体相对来说比较简单,针对小于16字节的对象则直接在 mcache.tiny 上进行分配,大于16字节且小于32K的结构体则直接在 mcache.alloc上分配,numSpanClasses 其对应的是一定规则的mspan,可以理解为为了降低内存碎片,Golang 针对每次申请的内存都会将其规约一定大小的空间,并不是其实际的内存,其本次实际的内存是一定小于 numSpanClasses 对应的 mspan 内存的,这样做也是为了方便后续不需要直接申请内存,可以直接服用mspan链表中的剩余内存。

3.2 mcentral

我们已经知道,Golang 为了降低锁的粒度,针对每一个逻辑核心 P 内置了一定大小的 mcache,这样一来中小型对象可以直接在mcache上进行无锁分配。对于内存硬件历史发展规则来说,越靠近CPU的硬件速度越快,但是容量也会变得越小,同理对于 Golang 来说,P.mcache 对应的内存对于 Golang Runtime 来说是最接近 CPU 核心的,那么其容量一定不会太大,那么当其容量耗尽之后该怎么办呢?

Golang 为了降低频繁的系统调用来申请内存,其会在 Go 堆和 mcache 之间新增加一个 mcentral 的控制缓存,其规格比mcache大,多个 CPU 核心共享,降低了操作系统频繁系统调用的难点,一个CPU核心申请的内存同时也可以供给给其他CPU核心使用。具体结构如下:

type mcentral struct {
 _         sys.NotInHeap
 spanclass spanClass

 // spanSet 存储了 mspan链表
 partial [2]spanSet // list of spans with a free object
 full    [2]spanSet // list of spans with no free objects
}


// spanSet 一个存储了mspans的
// 其通过index和spine来获取对应的数据信息
// index: 高32位为头节点,低32位为尾节点
type spanSet struct {
 spineLock mutex
 spine     atomicSpanSetSpinePointer // *[N]atomic.Pointer[spanSetBlock]
 spineLen  atomic.Uintptr            // Spine array length
 spineCap  uintptr                   // Spine array cap, accessed under spineLock
 index atomicHeadTailIndex
}
// 针对spanSet.pop其实就是很简单, 从对应的spanSetBlock中获取一个mspan并同时cas修正index

// spanSet中存储的是spanSetBlock
type spanSetBlock struct {
 ...
 // spans is the set of spans in this block.
    // spans 就是一个存储*mspan的数组(是数组不是切片)
    // atomicMSpanPointer其实就是*mspan
 spans [spanSetBlockEntries]atomicMSpanPointer
}

上面就是mcentral的基本结构了,但是初次之外,还有一个点是要和大家同步的,那就是全局变量mheap.mcentral是一个数组,而每一个元素中的mcentral中对应的mspan其规格大小是固定的,所以说一个mcentral对应的mspan规格大小是相同的。而heap.mcentral一共有136个长度,那是因为现在一共有对应136种类型的spanClass。

3.3 mheap

通过上面的内容,我们可以知道有两种情况下会直接从 Go 堆上直接分配:

  • 大于32K的对象直接从堆上分配
  • mcentral 内存不足时从堆上分配

Go 堆已经是 Golang Runtime 最后的一道内存防线了,如果其内存不足,则会向操作系统发起系统调用申请内存,如果申请不到则OOM。我们直接来看下其数据结构

type mheap struct {
 _ sys.NotInHeap
 lock mutex
 pages pageAlloc // page allocation data structure
 sweepgen uint32 // sweep generation, see comment in mspan; written during STW

 allspans []*mspan // all spans out there

 pagesInUse         atomic.Uintptr // pages of spans in stats mSpanInUse
 pagesSwept         atomic.Uint64  // pages swept this cycle
 pagesSweptBasis    atomic.Uint64  // pagesSwept to use as the origin of the sweep ratio
 
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena // 真正的堆区
 
 // mcentral 内存缓存
 // central free lists for small size classes.
 // the padding makes sure that the mcentrals are
 // spaced CacheLinePadSize bytes apart, so that each mcentral.lock
 // gets its own cache line.
 // central is indexed by spanClass.
 central [numSpanClasses]struct {
  mcentral mcentral
  pad      [(cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize) % cpu.CacheLinePadSize]byte
 }
 
    // 各种alloc
 spanalloc              fixalloc // allocator for span*
 cachealloc             fixalloc // allocator for mcache*
 specialfinalizeralloc  fixalloc // allocator for specialfinalizer*
 specialprofilealloc    fixalloc // allocator for specialprofile*
 specialReachableAlloc  fixalloc // allocator for specialReachable
 specialPinCounterAlloc fixalloc // allocator for specialPinCounter
 speciallock            mutex    // lock for special record allocators.
 arenaHintAlloc         fixalloc // allocator for arenaHints

 // 虚拟内存
 userArena struct {
  // arenaHints is a list of addresses at which to attempt to
  // add more heap arenas for user arena chunks. This is initially
  // populated with a set of general hint addresses, and grown with
  // the bounds of actual heap arena ranges.
  arenaHints *arenaHint

  // quarantineList is a list of user arena spans that have been set to fault, but
  // are waiting for all pointers into them to go away. Sweeping handles
  // identifying when this is true, and moves the span to the ready list.
  quarantineList mSpanList

  // readyList is a list of empty user arena spans that are ready for reuse.
  readyList mSpanList
 }

 unused *specialfinalizer // never set, just here to force the specialfinalizer type into DWARF
}

值的一提的是,Golang 堆对于一个进程来说只需要一个实例就可以了,所以 mheap是全局唯一的。

3.4 mspan

我们先来看看 mspan 数据结构:

type mspan struct {
 _    sys.NotInHeap
 next *mspan     // next span in list, or nil if none
 prev *mspan     // previous span in list, or nil if none

 // 当前mspan起始地址
 startAddr uintptr // address of first byte of span aka s.base()
 // 当前mspan包含多少个page(一个page8k)
 npages uintptr // number of pages in span

 manualFreeList gclinkptr // list of free objects in mSpanManual spans

 // freeindex 当前空闲Object下标
 freeindex uintptr
 // span中对象的数量
 nelems uintptr // number of object in the span.

 // Cache of the allocBits at freeindex. allocCache is shifted
 // such that the lowest bit corresponds to the bit freeindex.
 // allocCache holds the complement of allocBits, thus allowing
 // ctz (count trailing zero) to use it directly.
 // allocCache may contain bits beyond s.nelems; the caller must ignore
 // these.
 allocCache uint64

 // GC标志相关
 allocBits  *gcBits
 gcmarkBits *gcBits
 pinnerBits *gcBits // bitmap for pinned objects; accessed atomically

    // 省略
    (....)
}

每一个 mspan都是一个同等类型大小的双向链表,其是用于进行细化mheap中的page,一个mspan可能包含多个不同的page,其中通过freeindex来表示当前可用的内存地址,计算方式:address n*elemsize + (start << pageShift) 由此可以得出下个可用的内存地址。

四、TCMalloc 内存分配模型

Golang 既然要高效的分配内存,那么针对多核CPU场景下,必然要解决以下两个问题:

  • 多核CPU资源竞争则必然涉及锁:如何降低锁的粒度
  • 频繁向操作系统申请内存则必然涉及系统调用:如何降低系统调用的频度

在 Golang 诞生之前就已经有了一种设计去解决这两个难题,那就是 C/C++ 中的 TCMalloc 内存分配器,而 Golang 的内存分配模型也是参考的 TCMalloc。所以我们先简单了解一下 TCMalloc 工作模型,这对我们后续理解 Golang 内存分配很有帮助:

深入浅出Golang内存分配模型
image-20240201210839766

我们简单理解一下 TCMalloc 模型:

TCMalloc 模型针对每一个线程都有一个本地内存缓存,针对一定大小的内存块通过链表来缓存,直接降低了小对象内存分配时竞争锁的情况,如果ThreadCache内存不足则会向CentralCache申请对应大小的内存,如果CentralCache内存不足,则CentralCache会向PageHeap申请内存,如果PageHeap内存不足则会通过系统调用来向操作系统申请内存。TCMalloc通过多级分层的模式来降低锁竞争以及系统调用的开销情况,极大的提升了内存分配效率。

Page:操作系统管理内存的基本单位,TCMalloc也是以Page为管理单位,但是并不是和操作系统一一对应,x64下Page为8K。

Span:一组连续的Page被称为Span,为了更加方便的管理堆内存。

ThreadCache:每一个线程私有的内存Cache,其包含不同大小类型的链表,避免多线程资源竞争,加快内存分配效率。

CentraCache:所有线程共享的缓存,当ThreadCache不足时,现成会直接向CentraCache申请内存,CentraCache 是所有线程共享,访问需要加锁。

PageHeap:PageHeap是对堆内存的抽象,当其资源不足时会通过系统调用向操作系统申请更多的内存。

五、Golang 内存分配基本工作原理

栈上分配的变量都会去进行栈上分配或者栈上扩容,这个并不是本章节要去讨论分析的内容,而本章节要讨论的内容为堆上分配的核心组件。那么既然我们要去分析堆上分配的工作原理,那么我们就必须找到堆上分配入口点:这里我们找到一个堆上分配实例通过反编译来具体查看下堆上内存分配入口点:

func newObjectMemory() *Object {
 // Object 初始化Object在堆上分配
 return &Object{
  Array: make([]byte1<<10),
 }
}

func main() {
 newObjectMemory()
}

我们通过下面命令来查看具体汇编来确定内存分配入口:

PS F:codegogoresourcegotestopenxm> go build -gcflags '-N -l -m' .main.go 
# command-line-arguments
.main.go:27:9: &Object{...} escapes to heap

PS F:codegogoresourcegotestopenxm> go tool objdump -s "newObjectMemory" -S .main.exe

        return &Object{
  0x45bc26              488d05d37e0000          LEAQ type:*+31488(SB), AX
  0x45bc2d              e86ef2faff              CALL runtime.newobject(SB)
  0x45bc32              4889442420              MOVQ AX, 0x20(SP)           4889442420              MOVQ AX, 0x20(SP)

通过上面的例子我们可以看到堆上内存分配的入口点为:runtime.newobject(SB) ,所以我们接下来我们重点分析一下该函数的实现逻辑:

func newobject(typ *_type) unsafe.Pointer {
 return mallocgc(typ.Size_, typ, true)
}

上面函数入口点直接调用了函数 mallocgc 我们直接跳转到该函数内部去看具体的实现逻辑:这里我省略了很多和本文分析不相关的代码

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
 // Set mp.mallocing to keep from being preempted by GC.
 // 获取当前g属于的m
 mp := acquirem()
 if mp.mallocing != 0 {
  throw("malloc deadlock")
 }
 mp.mallocing = 1

 //  获取当前g所属的的M上的mcache缓存
 c := getMCache(mp)

 var span *mspan
 var x unsafe.Pointer
 // 分配分类为空或者不是指针,则不需要进行GC扫描
 noscan := typ == nil || typ.PtrBytes == 0
 // 1、小对象:小于32K
 if size <= maxSmallSize {
  // 非指针切小于16字节
  // 1、小于16字节
  // 2、16字节到32K
  if noscan && size < maxTinySize {
   off := c.tinyoffset
   // Align tiny pointer for required (conservative) alignment.
            // (...) 内存对齐,这里省略掉了
   if off+size <= maxTinySize && c.tiny != 0 {
    // The object fits into existing tiny block.
    // x表示当前申请内存的起始地址
    // 具有多少空间由下一次申请决定
    // 而下一次申请多少由tinyoffset决定
    x = unsafe.Pointer(c.tiny + off)
    c.tinyoffset = off + size
    c.tinyAllocs++
    mp.mallocing = 0
    releasem(mp)
    return x
   }
            // 当前p.mcache中不存在足够多maxTinySize的mspan,这里重新获取
   // Allocate a new maxTinySize block.
   // span 这里为tinySpanClass类型的Class
   span = c.alloc[tinySpanClass]
   v := nextFreeFast(span)
   if v == 0 {
    // 如果p.mcache中没有可用的spanClass对应的可用内存,从mcentral中获取新的内存
                // 如果mcentral内存也不足
    v, span, shouldhelpgc = c.nextFree(tinySpanClass)
   }
            // 本次新申请内存的指针起始地址
   x = unsafe.Pointer(v)
  } else {
   var sizeclass uint8
            // 找到当前申请的内存的对应大小的sizeClass(将本次申请的内存大小size归类到一定区间),避免内存碎片
            // 最大到32K
   if size <= smallSizeMax-8 {
    sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
   } else {
    sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
   }
            // size 找到该区间对应的内存
   size = uintptr(class_to_size[sizeclass])
            // 针对当前sizeclass区间和是否为指针声明一个spanCalss
   spc := makeSpanClass(sizeclass, noscan)
            // 从mcache中找到对应规则大小的mspan,后续内存分配在该mspan上
   span = c.alloc[spc]
            // 从当前mspan中找到空闲的ObJect
   v := nextFreeFast(span)
   if v == 0 {
                // 如果不存在可用的空闲的Object,则直接分配一个新的同等类型的mspan
                // nextFree 在该函数内部会去调用函数c.refill(spc)用来从mcentral中获取新的内存
                // 如果mcentral中内存
    v, span, shouldhelpgc = c.nextFree(spc)
   }
   x = unsafe.Pointer(v)
  }
 
 } else {
  // 大于32K
  shouldhelpgc = true
  // 直接从堆上分配一个mspan
        // 同时将mspan也放入到mcentral中,将其剩下的内存供给给其他小对象使用
  span = c.allocLarge(size, noscan)
  // 刚刚初始化的span
  // 初始化freeindex
  span.freeindex = 1
  span.allocCount = 1
  size = span.elemsize
  x = unsafe.Pointer(span.base())
        (...)
 }
    
    // 省略掉了很多
    (...)

    // 指针后续处理
 if !noscan {
  var scanSize uintptr
  heapBitsSetType(uintptr(x), size, dataSize, typ)
  if dataSize > typ.Size_ {
   // Array allocation. If there are any
   // pointers, GC has to scan to the last
   // element.
   if typ.PtrBytes != 0 {
    scanSize = dataSize - typ.Size_ + typ.PtrBytes
   }
  } else {
   scanSize = typ.PtrBytes
  }
  c.scanAlloc += scanSize
 }


 return x
}

通过上面的内存分配函数入口的理解,我们可以知道 Golang 结构体内存分配的基本工作原理,现在我们就直接来看看在 Golang 中大对象、中对象、小对象内存分配的基本工作原理:

  • 大小小于16字节且不是指针:直接分配在 tiny 内存分配预留的tiny缓存中,通过offset确定分配内存位于该tiny中的那一块,如果tiny无法满足,则从mcache中获取可用的内存。
  • 小于32K的指针或者大于16字节的小于32K的非指针:直接从当前 P 逻辑核心中获取对应的 mcache 空闲的对象,然后再mcache上进行内存分配,如果mcache内存不足则从mcentral所有P共享的缓存上分配内存,如果mcentral仍然获取不到足够的内存则从meap上获取,同理如果meap获取不到内存则通过系统调用获取内存。
  • 大于32K:直接在Go堆上进行内存分配。同时会将Go堆上分配的mspan加入到mcentral,以让其他小对象来分配使用。

我们通过具体分配函数入口已经知道了大概得分配逻辑,下面我们来具体看看在分配上的具体细节实现:

从mcache获取内存:

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
 s = c.alloc[spc]
 shouldhelpgc = false
 freeIndex := s.nextFreeIndex()
    // 空闲内存已占满
 if freeIndex == s.nelems {
  // span 已经占用满
  // The span is full.
  if uintptr(s.allocCount) != s.nelems {
   println("runtime: s.allocCount=", s.allocCount, "s.nelems=", s.nelems)
   throw("s.allocCount != s.nelems && freeIndex == s.nelems")
  }
  // 从mcentral中获取新的mspan
  c.refill(spc)
  shouldhelpgc = true
  s = c.alloc[spc]

  freeIndex = s.nextFreeIndex()
 }

 v = gclinkptr(freeIndex*s.elemsize + s.base())
 s.allocCount++
 return
}

mcache内存不足,从mcentral获取内存:

func (c *mcache) refill(spc spanClass) {
 // 如果mcentral中也没有足够的mspan,这个时候向mheap申请, 那么向mheap申请在哪里看呢?
 // Return the current cached span to the central lists.
 s := c.alloc[spc]
 
    (...)
    
 // Get a new cached span from the central lists.
 // 如果mcentral中没有足够的mspan,则会从mheap中申请对应的内存
 // 找到对应spc规格的链表然后从链表上分配内存
 // 如果mcentral中内存不足则会向heap进行申请
 s = mheap_.central[spc].mcentral.cacheSpan()

 // Indicate that this span is cached and prevent asynchronous
 // sweeping in the next sweep phase.
 s.sweepgen = mheap_.sweepgen + 3

 // Store the current alloc count for accounting later.
 s.allocCountBeforeCache = s.allocCount
 
    // 本次申请新的内存大小(并不是本次申请结构体的内存,而是该结构体临近的内存块)
 // more details.
 usedBytes := uintptr(s.allocCount) * s.elemsize
 gcController.update(int64(s.npages*pageSize)-int64(usedBytes), int64(c.scanAlloc))
 c.scanAlloc = 0

 c.alloc[spc] = s
}

func (c *mcentral) cacheSpan() *mspan {
 // 本次要从mcentral中获取的内存
 spanBytes := uintptr(class_to_allocnpages[c.spanclass.sizeclass()]) * _PageSize


 var s *mspan
 var sl sweepLocker

 // Try partial swept spans first.
 sg := mheap_.sweepgen
    // mcentral空闲内存足够当前分配
 if s = c.partialSwept(sg).pop(); s != nil {
  goto havespan
 }

    (...)

    // 当当前mcentral找不到对应的可用内存时,直接通过grow从堆上获取内存 
 // We failed to get a span from the mcentral so get one from mheap.
 s = c.grow()
 if s == nil {
  return nil
 }

 // At this point s is a span that should have free slots.
havespan:
 n := int(s.nelems) - int(s.allocCount)

 freeByteBase := s.freeindex &^ (64 - 1)
 whichByte := freeByteBase / 8
 // Init alloc bits cache.
 s.refillAllocCache(whichByte)

 // Adjust the allocCache so that s.freeindex corresponds to the low bit in
 // s.allocCache.
 s.allocCache >>= s.freeindex % 64

 return s
}

mcentral从meap上获取内存(大对象直接在堆上分配内存也是走的该逻辑)

func (c *mcentral) grow() *mspan {
    // 通过计算本次要申请的page个数和大小
 npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
 size := uintptr(class_to_size[c.spanclass.sizeclass()])
 // 从meap上申请内存
 s := mheap_.alloc(npages, c.spanclass)
 if s == nil {
  return nil
 }

 // Use division by multiplication and shifts to quickly compute:
 // n := (npages << _PageShift) / size
 n := s.divideByElemSize(npages << _PageShift)
 s.limit = s.base() + size*n
 s.initHeapBits(false)
 return s
}

// alloc meap堆上内存分配
// 通过g0系统栈来执行堆上内存分配
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
 var s *mspan
 systemstack(func() {
  // To prevent excessive heap growth, before allocating n pages
  // we need to sweep and reclaim at least n pages.
  if !isSweepDone() {
   h.reclaim(npages)
  }
        // 在堆上分配内存
  s = h.allocSpan(npages, spanAllocHeap, spanclass)
 })
 return s
}

到了这里,Golang 内存分配源码层的分析就已经完全梳理完了。现在我们回头解释以下几个问题:

  • Golang 如何高效的进行系统调用分配内存:通过多级缓存机制来降低系统调用次数
  • Golang 如何针对多线程环境高效分配内存:通过多级缓存机制来降低多线程之间锁的竞争粒度,只有当P.mcache上内存不足时才会进行去竞争mcentral上的锁来进行内存分配

六、性能优化总结

结合 Golang 函数栈帧以及内存分配分析总结.

通过上面的分析我们已经知道,只有当变量发生逃逸时才会将其内存分配到 Go 堆上,而 Go 堆在 Golang 中的还有另一个含义就是 gc 堆。所以我们可以利用 Golang 在栈上分配的工作原理来避免变量在堆上分布造成的GC影响。

Golang 函数栈帧原理:Golang 分配在栈上的内存随着函数栈帧的出栈而被清理,而 Golang GC回收机制也不会对栈上内存进行标记、清理等过程,自然而然的就避免了GC,如果我们能控制我们的变量内存尽量保证在栈上分配则就可以避免进程因为GC而造成的STW或者资源占用等情况。

既然我们知道了原理,那么我们还需要知道 Golang 一个函数栈帧包含什么,只有理解了函数栈帧包含什么才能进行很好的规避GC。我们现在有这么一个例子:

func main(){
    A()
}

func A() {
 obj := &Object{}
 B(obj)
}

func B(x *Object) any {
    i := 0
 fmt.Println(i)
 return nil
}

我们具体分析一下AB函数各自的栈帧:

函数A的栈帧:

----main BP
----obj
----B.return.any(函数B返回参数地址)

函数B的栈帧:

----A bp
----i

函数A的栈帧包含函数A本地的局部变量,同时作为调用函数则其函数栈帧保存被调函数B的参数和返回值。而函数B的栈帧由于入参和出参都保存在上一个调用方函数的栈帧中,所以函数B的栈帧中只有函数局部变量。

而我们又知道 Golang 函数的入参和出参如果不是指针类型,则其用的是拷贝类型,所以说对于函数返回值,如果我们用的是非指针,那么B中的函数返回值则不会在GC上分配,因为其在函数A中没有发生逃逸,所以被分配在栈上。

深入浅出Golang内存分配模型
image-20240130222658819

针对以上我们对 Golang 内存分配工作原理的理解以及对函数栈帧的理解,我们在进行编码时可以按照以下来通过避免GC提高程序性能:

  • slice、map 提前分配好内存,扩容需要占用CPU资源
  • 对于函数入参尽量使用指针,避免内存拷贝
  • 对于函数出参尽量不适用指针,避免逃逸在堆上分配
  • 函数内部变量尽量不发生逃逸,使其在堆上分配

七、Golang Runtime 内存分配模型总结

我们通过一张图来总结Golang内存分配模型,其实 Golang 内存分配模型和 TCMalloc 还是很相似的,现在我们来具体看一下其分配模型:

深入浅出Golang内存分配模型
image-20240202003947230

最后我们总结一下具体分配流程:

  • 小于16字节且不是指针
    • 优先tiny分配器进行分配,如果tiny分配器已经占满,则从mcache中分配
  • 小于32K的指针或者大于16字节小于32K的非指针
    • 直接从mache上进行分配
  • 大于32K
    • 直接从meap上分配

mcache分配流程:

  • 如果mcache中存在空闲对象,则直接分配到空闲对象
  • 如果mcache中不存在空闲对象,则从mcentral中申请新的mspan
  • mcentral中优先已经分配好的mspan,如果没有已经预先分配好的mspan,则从mheap上进行分配
  • meap上如果已经缓存的内存不足,则直接通过系统调用从操作系统重新获取内存

Golang 内存分配到了这里已经结束,希望各位能有所收获。

参考:

  • https://medium.com/eureka-engineering/understanding-allocations-in-go-stack-heap-memory-9a2631b5035d
  • https://github.com/coldnight/go-memory-allocator-visual-guide?tab=readme-ov-file
  • https://golang.design/under-the-hood/zh-cn/part2runtime/ch07alloc/basic/
  • https://zhuanlan.zhihu.com/p/76802887
  • https://goog-perftools.sourceforge.net/doc/tcmalloc.html


原文始发于微信公众号(社恐的小马同学):深入浅出Golang内存分配模型

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/269698.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!