Golang 内存模型与分配机制

0 前言

未来两周,想和大家探讨的主题是 Golang 内存管理机制.

本系列会分为两篇,第一篇谈及 Golang 内存模型以及内存分配机制,第二篇会和大家讨论 Golang 的垃圾回收机制. 本文是其中第一篇.

我个人比较推崇”基于源码支撑原理“的信念,所以本文在阐述原理的基础上,会伴有大量源码走读的过程,作为理论的支撑论证. 走读的 Go 源码版本为 1.19.

内存管理与垃圾回收都属 Go 语言最复杂的模块,受限于笔者个人水平,文章内容可能有不足或纰漏之处,很欢迎大家添加我的个人 VX:xingyun19951212 进行批评指正.

 

1 内存模型

1.1 操作系统存储模型

Golang 内存模型与分配机制

 

本文既然要聊到 Golang 的内存模型设计,就让我们首先回顾操作系统中经典的多级存储模型设计.

观察上图,我们可以从中捕捉到的关键词是:

  • • 多级模型

  • • 动态切换

 

1.2 虚拟内存与物理内存

Golang 内存模型与分配机制

操作系统内存管理中,另一个重要概念是虚拟内存,其作用如下:

  • • 在用户与硬件间添加中间代理层(没有什么是加一个中间层解决不了的)

  • • 优化用户体验(进程感知到获得的内存空间是“连续”的)

  • • “放大”可用内存(虚拟内存可以由物理内存+磁盘补足,并根据冷热动态置换,用户无感知)

 

1.3 分页管理

操作系统中通常会将虚拟内存和物理内存切割成固定的尺寸,于虚拟内存而言叫作“页”,于物理内存而言叫作“帧”,原因及要点如下:

  • • 提高内存空间利用(以页为粒度后,消灭了不稳定的外部碎片,取而代之的是相对可控的内部碎片)

  • • 提高内外存交换效率(更细的粒度带来了更高的灵活度)

  • • 与虚拟内存机制呼应,便于建立虚拟地址->物理地址的映射关系(聚合映射关系的数据结构,称为页表)

  • • Linux 页/帧的大小固定,为 4KB(这实际是由实践推动的经验值,太粗会增加碎片率,太细会增加分配频率影响效率)

 

1.4 Golang 内存模型

前几小节的铺垫,旨在从“内存模型设计”这件事情中收获一些触类旁通的设计理念.

下面步入正题,聊聊 Golang 的内存模型设计的几个核心要点:

 

  • • 以空间换时间,一次缓存,多次复用

由于每次向操作系统申请内存的操作很重,那么不妨一次多申请一些,以备后用.

Golang 中的堆 mheap 正是基于该思想,产生的数据结构. 我们可以从两个视角来解决 Golang 运行时的堆:

I 对操作系统而言,这是用户进程中缓存的内存

II 对于 Go 进程内部,堆是所有对象的内存起源

 

  • • 多级缓存,实现无/细锁化

Golang 内存模型与分配机制

 

堆是 Go 运行时中最大的临界共享资源,这意味着每次存取都要加锁,在性能层面是一件很可怕的事情.

在解决这个问题,Golang 在堆 mheap 之上,依次细化粒度,建立了 mcentral、mcache 的模型,下面对三者作个梳理:

  • • mheap:全局的内存起源,访问要加全局锁

  • • mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内

  • • mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁

这些概念,我们在第 2 节中都会再作详细展开,此处可以先不深究,注重于宏观架构即可.

 

  • • 多级规格,提高利用率

Golang 内存模型与分配机制

首先理下 page 和 mspan 两个概念:

(1)page:最小的存储单元.

Golang 借鉴操作系统分页管理的思想,每个最小的存储单元也称之为页 page,但大小为 8 KB

(2)mspan:最小的管理单元.

mspan 大小为 page 的整数倍,且从 8B 到 80 KB 被划分为 67 种不同的规格,分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间.

于是,我们回头小节多规格 mspan 下产生的特点:

I 根据规格大小,产生了等级的制度

II 消除了外部碎片,但不可避免会有内部碎片

III 宏观上能提高整体空间利用率

IV 正是因为有了规格等级的概念,才支持 mcentral 实现细锁化

 

  • • 全局总览,留个印象

Golang 内存模型与分配机制

上图是 Thread-Caching Malloc 的整体架构图,Golang 正是借鉴了该内存模型. 我们先看眼架构,有个整体概念,后续小节中,我们会不断对细节进行补充.

 

2 核心概念梳理

2.1 内存单元 mspan

Golang 内存模型与分配机制

分点阐述 mspan 的特质:

  • • mspan 是 Golang 内存管理的最小单元

  • • mspan 大小是 page 的整数倍(Go 中的 page 大小为 8KB),且内部的页是连续的(至少在虚拟内存的视角中是这样)

  • • 每个 mspan 根据空间大小以及面向分配对象的大小,会被划分为不同的等级(2.2小节展开)

  • • 同等级的 mspan 会从属同一个 mcentral,最终会被组织成链表,因此带有前后指针(prev、next)

  • • 由于同等级的 mspan 内聚于同一个 mcentral,所以会基于同一把互斥锁管理

  • • mspan 会基于 bitMap 辅助快速找到空闲内存块(块大小为对应等级下的 object 大小),此时需要使用到 Ctz64 算法.

Golang 内存模型与分配机制

 

mspan 类的源码位于 runtime/mheap.go 文件中:

type mspan struct {
    // 标识前后节点的指针 
    next *mspan     
    prev *mspan    
    // ...
    // 起始地址
    startAddr uintptr 
    // 包含几页,页是连续的
    npages    uintptr 


    // 标识此前的位置都已被占用 
    freeindex uintptr
    // 最多可以存放多少个 object
    nelems uintptr // number of object in the span.


    // bitmap 每个 bit 对应一个 object 块,标识该块是否已被占用
    allocCache uint64
    // ...
    // 标识 mspan 等级,包含 class 和 noscan 两部分信息
    spanclass             spanClass    
    // ...
}

 

 

2.2 内存单元等级 spanClass

mspan 根据空间大小和面向分配对象的大小,被划分为 67 种等级(1-67,实际上还有一种隐藏的 0 级,用于处理更大的对象,上不封顶)

 

下表展示了部分的 mspan 等级列表,数据取自 runtime/sizeclasses.go 文件中:

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 24 8192 341 8 29.24%
4 32 8192 256 0 21.88%





66 28672 57344 2 0 4.91%
67 32768 32768 1 0 12.50%

对上表各列进行解释:

(1)class:mspan 等级标识,1-67

(2)bytes/obj:该大小规格的对象会从这一 mspan 中获取空间. 创建对象过程中,大小会向上取整为 8B 的整数倍,因此该表可以直接实现 object 到 mspan 等级 的映射

(3)bytes/span:该等级的 mspan 的总空间大小

(4)object:该等级的 mspan 最多可以 new 多少个对象,结果等于 (3)/(2)

(5)tail waste:(3)/(2)可能除不尽,于是该项值为(3)%(2)

(6)max waste:通过下面示例解释:

 

Golang 内存模型与分配机制

以 class 3 的 mspan 为例,class 分配的 object 大小统一为 24B,由于 object 大小 <= 16B 的会被分配到 class 2 及之前的 class 中,因此只有 17B-24B 大小的 object 会被分配到 class 3.

最不利的情况是,当 object 大小为 17B,会产生浪费空间比例如下:

    ((24-17)*341 + 8)/8192 = 0.292358  29.24%

 

除了上面谈及的根据大小确定的 mspan 等级外,每个 object 还有一个重要的属性叫做 nocan,标识了 object 是否包含指针,在 gc 时是否需要展开标记.

在 Golang 中,会将 span class + nocan 两部分信息组装成一个 uint8,形成完整的 spanClass 标识. 8 个 bit 中,高 7 位表示了上表的 span 等级(总共 67 + 1 个等级,8 个 bit 足够用了),最低位表示 nocan 信息.

 

代码位于 runtime/mheap.go

type spanClass uint8


// uint8 左 7 位为 mspan 等级,最右一位标识是否为 noscan
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}


func (sc spanClass) sizeclass() int8 {
    return int8(sc >> 1)
}


func (sc spanClass) noscan() bool {
    return sc&1 != 0
}

 

2.3 线程缓存 mcache

Golang 内存模型与分配机制

要点:

(1)mcache 是每个 P 独有的缓存,因此交互无锁

(2)mcache 将每种 spanClass 等级的 mspan 各缓存了一个,总数为 2(nocan 维度) * 68(大小维度)= 136

(3)mcache 中还有一个为对象分配器 tiny allocator,用于处理小于 16B 对象的内存分配,在 3.3 小节中详细展开.

 

代码位于 runtime/mcache.go:

const numSpanClasses = 136
type mcache struct {
    // 微对象分配器相关
    tiny       uintptr
    tinyoffset uintptr
    tinyAllocs uintptr
    
    // mcache 中缓存的 mspan,每种 spanClass 各一个
    alloc [numSpanClasses]*mspan 
    // ...
}

 

2.4 中心缓存 mcentral

Golang 内存模型与分配机制

要点:

(1)每个 mcentral 对应一种 spanClass

(2)每个 mcentral 下聚合了该 spanClass 下的 mspan

(3)mcentral 下的 mspan 分为两个链表,分别为有空间 mspan 链表 partial 和满空间 mspan 链表 full

(4)每个 mcentral 一把锁

 

代码位于 runtime/mcentral.go

type mcentral struct {
    // 对应的 spanClass
    spanclass spanClass
    // 有空位的 mspan 集合,数组长度为 2 是用于抗一轮 GC
    partial [2]spanSet 
    // 无空位的 mspan 集合
    full    [2]spanSet 
}

 

2.5 全局堆缓存 mheap

要点:

  • • 对于 Golang 上层应用而言,堆是操作系统虚拟内存的抽象

  • • 以页(8KB)为单位,作为最小内存存储单元

  • • 负责将连续页组装成 mspan

  • • 全局内存基于 bitMap 标识其使用情况,每个 bit 对应一页,为 0 则自由,为 1 则已被 mspan 组装

  • • 通过 heapArena 聚合页,记录了页到 mspan 的映射信息(2.7小节展开)

  • • 建立空闲页基数树索引 radix tree index,辅助快速寻找空闲页(2.6小节展开)

  • • 是 mcentral 的持有者,持有所有 spanClass 下的 mcentral,作为自身的缓存

  • • 内存不够时,向操作系统申请,申请单位为 heapArena(64M)

 

代码位于 runtime/mheap.go

type mheap struct {
    // 堆的全局锁
    lock mutex


    // 空闲页分配器,底层是多棵基数树组成的索引,每棵树对应 16 GB 内存空间
    pages pageAlloc 


    // 记录了所有的 mspan. 需要知道,所有 mspan 都是经由 mheap,使用连续空闲页组装生成的
    allspans []*mspan


    // heapAreana 数组,64 位系统下,二维数组容量为 [1][2^22]
    // 每个 heapArena 大小 64M,因此理论上,Golang 堆上限为 2^22*64M = 256T
    arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena


    // ...
    // 多个 mcentral,总个数为 spanClass 的个数
    central [numSpanClasses]struct {
        mcentral mcentral
        // 用于内存地址对齐
        pad      [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
    }


    // ...
}

 

2.6 空闲页索引 pageAlloc

与 mheap 中,与空闲页寻址分配的基数树索引有关的内容较为晦涩难懂. 网上能把这个问题真正讲清楚的文章几乎没有.

所幸我最后找到这个数据结构的作者发布的笔记,终于对方案的原貌有了大概的了解,这里粘贴链接,供大家自取:https://go.googlesource.com/proposal/+/master/design/35112-scaling-the-page-allocator.md

 

要理清这棵技术树,首先需要明白以下几点:

(1)数据结构背后的含义:

I 2.5 小节有提及,mheap 会基于 bitMap 标识内存中各页的使用情况,bit 位为 0 代表该页是空闲的,为 1 代表该页已被 mspan 占用.

II 每棵基数树聚合了 16 GB 内存空间中各页使用情况的索引信息,用于帮助 mheap 快速找到指定长度的连续空闲页的所在位置

III mheap 持有 2^14 棵基数树,因此索引全面覆盖到 2^14 * 16 GB = 256 T 的内存空间.

 

(2)基数树节点设定

Golang 内存模型与分配机制

基数树中,每个节点称之为 PallocSum,是一个 uint64 类型,体现了索引的聚合信息,包含以下四部分:

  • • start:最右侧 21 个 bit,标识了当前节点映射的 bitMap 范围中首端有多少个连续的 0 bit(空闲页),称之为 start;

  • • max:中间 21 个 bit,标识了当前节点映射的 bitMap 范围中最多有多少个连续的 0 bit(空闲页),称之为 max;

  • • end:左侧 21 个 bit,标识了当前节点映射的 bitMap 范围中最末端有多少个连续的 0 bit(空闲页),称之为 end.

  • • 最左侧一个 bit,弃置不用

 

(3)父子关系

Golang 内存模型与分配机制

 

  • • 每个父 pallocSum 有 8 个子 pallocSum

  • • 根 pallocSum 总览全局,映射的 bitMap 范围为全局的 16 GB 空间(其 max 最大值为 2^21,因此总空间大小为 2^21*8KB=16GB);

  • • 从首层向下是一个依次八等分的过程,每一个 pallocSum 映射其父节点 bitMap 范围的八分之一,因此第二层 pallocSum 的 bitMap 范围为 16GB/8 = 2GB,以此类推,第五层节点的范围为 16GB / (8^4) = 4 MB,已经很小

  • • 聚合信息时,自底向上. 每个父 pallocSum 聚合 8 个子 pallocSum 的 start、max、end 信息,形成自己的信息,直到根 pallocSum,坐拥全局 16 GB 的 start、max、end 信息

  • • mheap 寻页时,自顶向下. 对于遍历到的每个 pallocSum,先看起 start 是否符合,是则寻页成功;再看 max 是否符合,是则进入其下层孩子 pallocSum 中进一步寻访;最后看 end 和下一个同辈 pallocSum 的 start 聚合后是否满足,是则寻页成功.

 

 

Golang 内存模型与分配机制

 

 

Golang 内存模型与分配机制

 

代码位于 runtime/mpagealloc.go

const summaryLevels = 5


type pageAlloc struct {
    // 共有五层基数树,第一层有 2^14 个节点,因此共用 2^14棵基数树
    // 总空间大小为 2^14*16GB = 256T
    // 接下来每层的节点数为上层的 8 倍
    summary [summaryLevels][]pallocSum
    
    // ...
    // 类似于 tiny offset,小于此值的地址无锁检索,必然没有空间可用
    searchAddr offAddr


    // ...
}

 

基数树节点

const(
    logMaxPackedValue = 21
    maxPackedValue    = 1 << logMaxPackedValue
)


type pallocSum uint64


// 基于 start、max、end 组装成一个基数树节点 pallocSum
func packPallocSum(start, max, end uint) pallocSum {
    // ...
    return pallocSum((uint64(start) & (maxPackedValue - 1)) |
        ((uint64(max) & (maxPackedValue - 1)) << logMaxPackedValue) |
        ((uint64(end) & (maxPackedValue - 1)) << (2 * logMaxPackedValue)))
}


// 当前节点对应区域内,首部连续空闲页的长度
// 通过 uint64 最右侧 21 个 bit 标识
func (p pallocSum) start() uint {
    // ...
    return uint(uint64(p) & (maxPackedValue - 1))
}


// 当前节点对应区域内,连续空闲页的最大长度
// 通过 uint64 左数 23~43 个 bit 标识
func (p pallocSum) max() uint {
    // ...
    return uint((uint64(p) >> logMaxPackedValue) & (maxPackedValue - 1))
}


// 当前节点对应区域内,尾部连续空闲页的长度
// 通过 uint64 左数 2~22 个 bit 标识
func (p pallocSum) end() uint {
    return uint((uint64(p) >> (2 * logMaxPackedValue)) & (maxPackedValue - 1))
}

 

2.7 heapArena

  • • 每个 heapArena 包含 8192 个页,大小为 8192 * 8KB = 64 MB

  • • heapArena 记录了页到 mspan 的映射. 因为 GC 时,通过地址偏移找到页很方便,但找到其所属的 mspan 不容易. 因此需要通过这个映射信息进行辅助.

  • • heapArena 是 mheap 向操作系统申请内存的单位(64MB)

 

代码位于 runtime/mheap.go

const pagesPerArena = 8192


type heapArena struct {
    // ...
    // 实现 page 到 mspan 的映射
    spans [pagesPerArena]*mspan


    // ...
}

 

3 对象分配流程

下面来串联 Golang 中分配对象的流程,不论是以下哪种方式,最终都会殊途同归步入 mallocgc 方法中,并且根据 3.1 小节中的策略执行分配流程:

  • • new(T)

  • • &T{}

  • • make(xxxx)

 

3.1 分配流程总览

Golang 中,依据 object 的大小,会将其分为下述三类:

Golang 内存模型与分配机制

 

不同类型的对象,会有着不同的分配策略,这些内容在 mallocgc 方法中都有体现.

核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.

对于微对象的分配流程:

(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)

(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)

(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)

(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)

(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).

 

对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;

对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.

 

Golang 内存模型与分配机制

 

3.2 主干方法 mallocgc

先上道硬菜,malloc 方法主干全流程展示.

如果觉得理解曲线太陡峭,可以先跳到后续小节,把拆解的各部分模块都熟悉后,再回过头来总览一遍.

代码位于 runtime/malloc.go 文件中:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ...    
    // 获取 m
    mp := acquirem()
    // 获取当前 p 对应的 mcache
    c := getMCache(mp)
    var span *mspan
    var x unsafe.Pointer
    // 根据当前对象是否包含指针,标识 gc 时是否需要展开扫描
    noscan := typ == nil || typ.ptrdata == 0
    // 是否是小于 32KB 的微、小对象
    if size <= maxSmallSize {
    // 小于 16 B 且无指针,则视为微对象
        if noscan && size < maxTinySize {
        // tiny 内存块中,从 offset 往后有空闲位置
          off := c.tinyoffset
          // 如果大小为 5 ~ 8 B,size 会被调整为 8 B,此时 8 & 7 == 0,会走进此分支
          if size&7 == 0 {
                // 将 offset 补齐到 8 B 倍数的位置
                off = alignUp(off, 8)
                // 如果大小为 3 ~ 4 B,size 会被调整为 4 B,此时 4 & 3 == 0,会走进此分支  
           } else if size&3 == 0 {
           // 将 offset 补齐到 4 B 倍数的位置
                off = alignUp(off, 4)
                // 如果大小为 1 ~ 2 B,size 会被调整为 2 B,此时 2 & 1 == 0,会走进此分支  
           } else if size&1 == 0 {
            // 将 offset 补齐到 2 B 倍数的位置
                off = alignUp(off, 2)
           }
// 如果当前 tiny 内存块空间还够用,则直接分配并返回
            if off+size <= maxTinySize && c.tiny != 0 {
            // 分配空间
                x = unsafe.Pointer(c.tiny + off)
                c.tinyoffset = off + size
                c.tinyAllocs++
                mp.mallocing = 0
                releasem(mp)  
                return x
            } 
            // 分配一个新的 tiny 内存块
            span = c.alloc[tinySpanClass]    
            // 从 mCache 中获取
            v := nextFreeFast(span)        
            if v == 0 {
            // 从 mCache 中获取失败,则从 mCentral 或者 mHeap 中获取进行兜底
                v, span, shouldhelpgc = c.nextFree(tinySpanClass)
            }   
// 分配空间      
            x = unsafe.Pointer(v)
           (*[2]uint64)(x)[0] = 0
           (*[2]uint64)(x)[1] = 0
           size = maxTinySize
        } else {
          // 根据对象大小,映射到其所属的 span 的等级(0~66)
          var sizeclass uint8
          if size <= smallSizeMax-8 {
              sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
          } else {
              sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
          }        
          // 对应 span 等级下,分配给每个对象的空间大小(0~32KB)
          size = uintptr(class_to_size[sizeclass])
          // 创建 spanClass 标识,其中前 7 位对应为 span 的等级(0~66),最后标识表示了这个对象 gc 时是否需要扫描
          spc := makeSpanClass(sizeclass, noscan) 
          // 获取 mcache 中的 span
          span = c.alloc[spc]  
          // 从 mcache 的 span 中尝试获取空间        
          v := nextFreeFast(span)
          if v == 0 {
          // mcache 分配空间失败,则通过 mcentral、mheap 兜底            
             v, span, shouldhelpgc = c.nextFree(spc)
          }     
          // 分配空间  
          x = unsafe.Pointer(v)
          // ...
       }      
       // 大于 32KB 的大对象      
   } else {
       // 从 mheap 中获取 0 号 span
       span = c.allocLarge(size, noscan)
       span.freeindex = 1
       span.allocCount = 1
       size = span.elemsize         
       // 分配空间   
        x = unsafe.Pointer(span.base())
   }  
   // ...
   return x
}                               

3.3 步骤(1):tiny 分配

每个 P 独有的 mache 会有个微对象分配器,基于 offset 线性移动的方式对微对象进行分配,每 16B 成块,对象依据其大小,会向上取整为 2 的整数次幂进行空间补齐,然后进入分配流程.

 

Golang 内存模型与分配机制

 

    noscan := typ == nil || typ.ptrdata == 0
    // ...
        if noscan && size < maxTinySize {
        // tiny 内存块中,从 offset 往后有空闲位置
          off := c.tinyoffset
          // ...
            // 如果当前 tiny 内存块空间还够用,则直接分配并返回
            if off+size <= maxTinySize && c.tiny != 0 {
            // 分配空间
                x = unsafe.Pointer(c.tiny + off)
                c.tinyoffset = off + size
                c.tinyAllocs++
                mp.mallocing = 0
                releasem(mp)
                return x
            }
           // ...
        }

 

 

3.4 步骤(2):mcache 分配

          // 根据对象大小,映射到其所属的 span 的等级(0~66)
          var sizeclass uint8
          // get size class ....     
          // 对应 span 等级下,分配给每个对象的空间大小(0~32KB)
          // get span class
          spc := makeSpanClass(sizeclass, noscan) 
          // 获取 mcache 中的 span
          span = c.alloc[spc]  
          // 从 mcache 的 span 中尝试获取空间        
          v := nextFreeFast(span)
          if v == 0 {
          // mcache 分配空间失败,则通过 mcentral、mheap 兜底            
             v, span, shouldhelpgc = c.nextFree(spc)
          }     
          // 分配空间  
          x = unsafe.Pointer(v)

 

在 mspan 中,基于 Ctz64 算法,根据 mspan.allocCache 的 bitMap 信息快速检索到空闲的 object 块,进行返回.

代码位于 runtime/malloc.go 文件中:

func nextFreeFast(*mspan) gclinkptr {
    // 通过 ctz64 算法,在 bit map 上寻找到首个 object 空位
    theBit := sys.Ctz64(s.allocCache) 
    if theBit < 64 {
        result := s.freeindex + uintptr(theBit)
        if result < s.nelems {
            freeidx := result + 1
            if freeidx%64 == 0 && freeidx != s.nelems {
                return 0
            }
            s.allocCache >>= uint(theBit + 1)
            // 偏移 freeindex 
            s.freeindex = freeidx
            s.allocCount++
            // 返回获取 object 空位的内存地址 
            return gclinkptr(result*s.elemsize + s.base())
        }
    }
    return 0
}

 

3.5 步骤(3):mcentral 分配

当 mspan 无可用的 object 内存块时,会步入 mcache.nextFree 方法进行兜底.

代码位于 runtime/mcache.go 文件中:

func (*mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
    s = c.alloc[spc]
    // ...
    // 从 mcache 的 span 中获取 object 空位的偏移量
    freeIndex := s.nextFreeIndex()
    if freeIndex == s.nelems {
        // ...
        // 倘若 mcache 中 span 已经没有空位,则调用 refill 方法从 mcentral 或者 mheap 中获取新的 span    
        c.refill(spc)
        // ...
        // 再次从替换后的 span 中获取 object 空位的偏移量
        s = c.alloc[spc]
        freeIndex = s.nextFreeIndex()
    }
    // ...
    v = gclinkptr(freeIndex*s.elemsize + s.base())
    s.allocCount++
    // ...
    return
}    

 

倘若 mcache 中,对应的 mspan 空间不足,则会在 mcache.refill 方法中,向更上层的 mcentral 乃至 mheap 获取 mspan,填充到 mache 中:

代码位于 runtime/mcache.go 文件中:

func (*mcache) refill(spc spanClass) {  
    s := c.alloc[spc]
    // ...
    // 从 mcentral 当中获取对应等级的 span
    s = mheap_.central[spc].mcentral.cacheSpan()
    // ...
    // 将新的 span 添加到 mcahe 当中
    c.alloc[spc] = s
}

 

mcentral.cacheSpan 方法中,会加锁(spanClass 级别的 sweepLocker),分别从 partial 和 full 中尝试获取有空间的 mspan:

代码位于 runtime/mcentral.go 文件中:

func (*mcentral) cacheSpan() *mspan {
    // ...
    var sl sweepLocker    
    // ...
    sl = sweep.active.begin()
    if sl.valid {
        for ; spanBudget >= 0; spanBudget-- {
            s = c.partialUnswept(sg).pop()
            // ...
            if s, ok := sl.tryAcquire(s); ok {
                // ...
                sweep.active.end(sl)
                goto havespan
            }
            
        // 通过 sweepLock,加锁尝试从 mcentral 的非空链表 full 中获取 mspan
        for ; spanBudget >= 0; spanBudget-- {
            s = c.fullUnswept(sg).pop()
           // ...
            if s, ok := sl.tryAcquire(s); ok {
                // ...
                sweep.active.end(sl)
                goto havespan
                }
                // ...
            }
        }
        // ...
    }
    // ...


    // 执行到此处时,s 已经指向一个存在 object 空位的 mspan 了
havespan:
    // ...
    return
}

 

3.6 步骤(4):mheap 分配

在 mcentral.cacheSpan 方法中,倘若从 partial 和 full 中都找不到合适的 mspan 了,则会调用 mcentral 的 grow 方法,将事态继续升级:

func (*mcentral) cacheSpan() *mspan {
    // ...
    // mcentral 中也没有可用的 mspan 了,则需要从 mheap 中获取,最终会调用 mheap_.alloc 方法
    s = c.grow()
   // ...


    // 执行到此处时,s 已经指向一个存在 object 空位的 mspan 了
havespan:
    // ...
    return
}

 

经由 mcentral.grow 方法和 mheap.alloc 方法的周转,最终会步入 mheap.allocSpan 方法中:

func (*mcentral) grow() *mspan {
    npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
    size := uintptr(class_to_size[c.spanclass.sizeclass()])


    s := mheap_.alloc(npages, c.spanclass)
    // ...


    // ...
    return s
}

 

代码位于 runtime/mheap.go

func (*mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
    var s *mspan
    systemstack(func() {
        // ...
        s = h.allocSpan(npages, spanAllocHeap, spanclass)
    })
    return s
}

 

代码位于 runtime/mheap.go

func (*mheap) allocSpan(npages uintptr, typ spanAllocType, spanclass spanClass) (*mspan) {
    gp := getg()
    base, scav := uintptr(0), uintptr(0)
    
    // ...此处实际上还有一阶缓存,是从每个 P 的页缓存 pageCache 中获取空闲页组装 mspan,此处先略去了...
    
    // 加上堆全局锁
    lock(&h.lock)
    if base == 0 {
        // 通过基数树索引快速寻找满足条件的连续空闲页
        base, scav = h.pages.alloc(npages)
        // ...
    }
    
    // ...
    unlock(&h.lock)


HaveSpan:
    // 把空闲页组装成 mspan
    s.init(base, npages)
    
    // 将这批页添加到 heapArena 中,建立由页指向 mspan 的映射
    h.setSpans(s.base(), npages, s)
    // ...
    return s
}

倘若对 mheap 空闲页分配器基数树 pageAlloc 分配空闲页的源码感兴趣,莫慌,3.8 小节见.

 

3.7 步骤(5):向操作系统申请

倘若 mheap 中没有足够多的空闲页了,会发起 mmap 系统调用,向操作系统申请额外的内存空间.

代码位于 runtime/mheap.go 文件的 mheap.grow 方法中:

func (*mheap) grow(npage uintptr) (uintptr, bool) {
    av, asize := h.sysAlloc(ask)
}

 

func (*mheap) sysAlloc(n uintptr) (unsafe.Pointer, size uintptr) {
       v = sysReserve(unsafe.Pointer(p), n)
}

 

func sysReserve(unsafe.Pointer, n uintptr) unsafe.Pointer {
    return sysReserveOS(v, n)
}

 

func sysReserveOS(unsafe.Pointer, n uintptr) unsafe.Pointer {
    p, err := mmap(v, n, _PROT_NONE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
    if err != 0 {
        return nil
    }
    return p
}

 

3.8 步骤(4)拓展:基数树寻页

核心源码位于 runtime/pagealloc.go 的 pageAlloc 方法中,要点都以在代码中给出注释:

func (*pageAlloc) find(npages uintptr) (uintptr, offAddr) {
    // 必须持有堆锁
    assertLockHeld(p.mheapLock)


    // current level.
    i := 0


    // ...
    lastSum := packPallocSum(0, 0, 0)
    lastSumIdx := -1


nextLevel:
    // 1 ~ 5 层依次遍历
    for l := 0; l < len(p.summary); l++ {
        // ...
        // 根据上一层的 index,映射到下一层的 index.
        // 映射关系示例:上层 0 -> 下层 [0~7]
        //             上层 1 -> 下层 [8~15]
        //             以此类推
        i <<= levelBits[l]
        entries := p.summary[l][: i+entriesPerBlock]
        // ...
        // var levelBits = [summaryLevels]uint{
        //   14,3,3,3,3
        // }
        // 除第一层有 2^14 个节点外,接下来每层都只要关心 8 个 节点.
        // 由于第一层有 2^14 个节点,所以 heap 内存上限为 2^14 * 16G = 256T
        var base, size uint
        for j := j0; j < len(entries); j++ {
            sum := entries[j]
            // ...
            // 倘若当前节点对应内存空间首部即满足,直接返回结果
            s := sum.start()
            if size+>= uint(npages) {               
                if size == 0 {
                    base = uint(j) << logMaxPages
                }             
                size += s
                break
            }
            // 倘若当前节点对应内存空间首部不满足,但是内部最长连续页满足,则到下一层节点展开搜索
            if sum.max() >= uint(npages) {               
                i += j
                lastSumIdx = i
                lastSum = sum
                continue nextLevel
            }
            // 即便内部最长连续页不满足,还可以尝试将尾部与下个节点的首部叠加,看是否满足
            if size == 0 || s < 1<<logMaxPages {
                                size = sum.end()
                base = uint(j+1)<<logMaxPages - size
                continue
            }
            // The entry is completely free, so continue the run.
            size += 1 << logMaxPages
        }
    
    // 根据 i 和 j 可以推导得到对应的内存地址,进行返回
    ci := chunkIdx(i)
    addr := chunkBase(ci) + uintptr(j)*pageSize
    // ...
    return addr, p.findMappedAddr(firstFree.base)
}

 

4 展望

祝贺,到此为止,整个 Golang 内存分配流程已经梳理完毕.

两周内,我会带来新作——Golang 垃圾回收机制.

Go 友们不见不散~


原文始发于微信公众号(小徐先生的编程世界):Golang 内存模型与分配机制

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

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

(0)
小半的头像小半

相关推荐

发表回复

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