当我刚开始尝试了解 go 的内存分配器时,这真令人发疯。 一切似乎都像一个神秘的黑匣子。 由于几乎所有技术巫术都隐藏在抽象之下,因此您需要逐一剥离这些层才能理解它。
在此博客文章中,我们将完全做到这一点。 您是否想了解有关Go内存分配器的所有知识? 您正在阅读正确的文章。
每个内存分配器都需要使用由底层操作系统管理的虚拟内存空间。 让我们看看它是如何工作的。
单个存储单元的简化视图:
· 地址线(作为开关的晶体管)是访问 电容器 (数据到数据线)的通道。
· 当地址线中有电流流动时(显示为红色),数据线可能会写入电容器,因此电容器已充电,并且存储的逻辑值为” 1″。
· 当地址线没有电流流动(显示为绿色)时,数据线可能不会写入电容器,因此电容器未充电,并且存储的逻辑值为” 0″。
· 当CPU需要从 RAM 中”读取”一个值时,会沿着”地址线”发送电流(闭合开关)。 如果电容器保持电荷,则电流流经” DATA LINE”(值1); 否则,没有电流流过数据线,因此电容器保持未充电状态(值为0)。
数据总线:在CPU和物理内存之间传输数据。
让我们还讨论一下地址行和可寻址字节。
1. DRAM中的每个” BYTE”都分配有唯一的数字标识符(地址)。 “存在物理字节!=地址行数”。 (例如16位Intel 8088,PAE)
2.每条”地址线”都可以发送1位值,因此它在给定字节的地址中指定”单位”。
3.在图中,我们有32条地址线。 因此,每个可寻址字节都将” 32位”作为地址。
- [00000000000000000000000000000000]-低内存地址。
- [11111111111111111111111111111111111]-高内存地址。
4.由于每个字节都有一个32位地址,所以我们的地址空间由”²”可寻址字节(4 GB)组成(在上述说明中)。
因此,可寻址字节取决于总地址线,因此对于64位地址线(x86–64 CPU)”-“字节可寻址字节(16艾字节),但是大多数使用64位指针的体系结构实际上使用48位地址线(AMD64 )和42位地址线(英特尔)理论上允许256 TB的物理RAM(Linux在x86-64上具有4级页面表,Windows 192TB的情况下,每个进程允许128TB的地址空间)
由于物理RAM的大小受到限制,因此每个进程都在其自己的内存沙箱中运行-“虚拟地址空间”,称为虚拟内存。
该虚拟地址空间中字节的地址不再与处理器在地址总线上放置的地址相同。 因此,必须建立转换数据结构和系统,以将虚拟地址空间中的字节映射到物理字节。
该虚拟地址的外观如何?
因此,当CPU执行引用内存地址的指令时。 第一步是将VMA中的逻辑地址转换为线性地址。 此翻译由MMU完成。
由于此逻辑地址太大而无法实际管理(取决于各种因素),因此将根据页面进行管理。 激活了必要的分页构造后,虚拟内存空间将被划分为称为页面的较小区域(大多数OS上的4kB可以更改)。 它是虚拟内存中用于 内存管理 的最小数据单位。 虚拟内存不存储任何内容,它只是将程序的地址空间映射到基础物理内存上。
单个进程仅将此VMA视为其地址。 因此,当我们的程序请求更多”堆内存”时会发生什么。
程序要求更多的内存。 通过brk(sbrk / mmap等)系统调用。内核仅更新堆VMA并将其称为可用。
有了”虚拟地址空间”的基本概述以及增加堆的含义之后,内存分配器现在变得更容易推论了。
如果堆中有足够的空间来满足我们代码中的内存请求,则内存分配器可以在不涉及内核的情况下满足该请求,否则它会通过系统(brk)调用扩大堆,通常会请求较大的内存块。 (默认情况下,对于 malloc 大平均值> MMAP_THRESHOLD字节-128 kB)。
但是,内存分配器具有的责任不仅仅是更新brk地址。 主要问题之一是如何减少内部和外部碎片以及如何快速分配此块。 考虑使用malloc(size)函数从程序中请求连续内存块,并使用free(pointer)函数以从p1到p4的顺序释放该内存。
即使在第4步,即使我们有足够的内存块,我们也无法满足对6个连续内存块的请求,从而导致内存碎片。
那么如何减少内存碎片呢? 这个问题的答案取决于特定的内存分配算法,底层库的使用。
我们将研究TCMalloc内存分配器的概述,在该内存分配器上严格模拟Go内存分配器。
TCMalloc(线程高速缓存malloc)的核心思想是将内存划分为多个级别,以减少锁的粒度。 在TCMalloc内部,内存管理分为两部分:线程内存和页面堆。
每个内存页面分为多个空闲可分配大小类的”空闲列表”,这有助于减少碎片。 因此,每个线程都会有一个无锁的小对象缓存,这使得在并行程序下分配小对象(<= 32k)非常有效。
TCMalloc管理的堆由一组页面组成,其中一组连续页面可以用span表示。 当分配的对象大于32K时,将使用页面堆进行分配。
如果没有足够的内存来分配小对象,请转到页面堆以获取内存。 如果没有足够的内存,页面堆将从操作系统中请求更多内存。
由于这种分配模型可维护用户空间的内存池,因此极大地提高了内存分配和释放的效率。
注意:尽管go内存分配器最初是基于tcmalloc的,但是现在有很大的不同。
我们知道Go运行时会将Goroutine(G)调度到逻辑处理器(P)上执行。 同样,TCMalloc Go还将内存页分为67个不同类Size的块。
如果您不熟悉Go调度程序,则可以获取概述(Go计划程序:Ms,Ps和Gs povilasv.me/go-scheduler/),在此之前,我将在这里等您。
如果Go将该页面划分为1kB的块大小,则Go以8192B的粒度管理页面,例如,在该页面中,我们总共获得8个这样的块。
Go 中这些页通过 mspan 结构体进行管理
简单的说, mspan 是一个包含页起始地址、页的 span 规格和页的数量的双端链表。
与TCMalloc一样,Go为每个逻辑处理器(P)提供了一个称为mcache的本地内存缓存,因此,如果Goroutine需要内存,则可以直接从mcache获取它,而不会涉及任何锁,因为在任何时间点只有一个Goroutine将被使用。 在逻辑处理器(P)上运行。
mcache包含所有类大小的mspan作为缓存。
由于有mcache Per-P,因此从mcache进行分配时无需持有锁。
对于每种班级人数,有两种类型。
· scan —包含指针的对象。
· noscan-不包含指针的对象。
采用这种方法的好处之一就是进行垃圾回收时 noscan 对象无需进一步扫描是否引用其他活跃的对象。
mcache的作用是什么??
<=32K 字节的对象直接使用相应大小规格的 mspan 通过 mcache 分配
当mcache没有可用插槽时会发生什么?
从 mcentral 的 mspans 列表获取一个新的所需大小规格的 mspan 。
mcentral 对象收集所有给定规格大小的 span。每一个 mcentral 都包含两个 mspan 的列表:
- empty mspanList — 没有空闲对象或 span 已经被 mcache 缓存的 span 列表
- nonempty mspanList — 有空闲对象的 span 列表
当从mcentral请求新的Span时,它将从mspanList的非空列表中获取(如果有)。 这两个列表之间的关系如下:当请求新的跨度时,该请求从非空列表中满足,并且该跨度被放入空列表中。 释放跨度后,将根据跨度中空闲对象的数量将其放回非空列表。
每个中心结构都保持在堆结构内部。
mheap是在Go中管理堆的对象,只有一个全局对象。 它拥有虚拟地址空间。
从上面的插图中我们可以看到,mheap具有多个mcentral。 该数组包含每个span类的中心。
central [numSpanClasses]struct {
mcentral mcentral
pad [sys.CacheLineSize unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}
由于我们对每个span类都有mcentral,因此当mcache从mcentral请求一个mspan时,锁涉及单个mcentral级别,因此也可以同时请求其他任何大小不同的mspan的mcache。
对齐填充(Padding)用于确保 mcentrals 以 CacheLineSize 个字节数分隔,所以每一个 MCentral.lock 都可以获取自己的缓存行(cache line),以避免伪共享(false sharing)问题。
那么,当该中心列表为空时会发生什么? mcentral从mheap获取一连串的页面,以用于所需大小级别的跨度。
· free [_MaxMHeapList] mSpanList:这是一个spanList数组。 每个spanList中的mspan包含1〜127(_MaxMHeapList — 1)页。 例如,free [3]是包含3个页面的mspan的链接列表。 Free表示未分配的空闲列表。 对应于忙碌列表。
· freelarge mSpanList:mspan的列表。 每个元素的页数(即mspan)大于127。它保持为mtreap数据结构。 对应繁忙。
大小> 32k的对象是一个大对象,直接从mheap分配。 这些较大的请求需要中央锁定,因此在任何给定时间点只能满足一个P的请求。
•大小> 32k是一个大对象,直接从mheap分配。
•大小<16B,使用mcache的微小分配器分配
•大小在16B〜32k之间,计算要使用的sizeClass,然后在mcache中使用相应的sizeClass的块分配
•如果与mcache对应的sizeClass没有可用的块,则将其应用于mcentral。
•如果没有可用于mcentral的块,则应用mheap并使用BestFit查找最合适的mspan。 如果超出了应用程序的大小,则会根据需要进行划分,以返回用户所需的页面数。 其余页面构成一个新的mspan,并返回无堆内存列表。
•如果没有可用的跨度,请向操作系统申请一组新的页面(至少1MB)。
但是Go在OS级别分配的页面甚至更大(称为竞技场)。 分配大量页面将分摊与操作系统进行对话的成本。
堆上请求的所有内存都来自竞技场( arena )。 让我们看看这个竞技场的样子。
让我们看一下简单的go程序的内存。
func main() {
for {}
}
因此,即使对于一个简单的go程序,虚拟空间也约为100 MB,而RSS仅为696kB。 让我们尝试首先找出这种差异。
因此,有些内存区域的大小约为2MB,64MB和32MB。 这些是什么?
事实证明,go中的虚拟内存布局由一组竞技场组成。 初始堆映射是一个竞技场,即64MB(基于go 1.11.5)。
因此,当前内存按照我们的程序需要以较小的增量进行映射,并且它以一个区域(〜64MB)开头。
请把这些号码和一粒盐一起服用。 可调整的。 在64位系统上,竞技场大小为512 GB,可以用来预先保留连续的虚拟地址。 (如果分配足够大并且被mmap拒绝,会发生什么?)
这套竞技场就是我们所谓的堆。 在Go中,每个竞技场的管理粒度为8192B。
Go还具有两个跨度和位图的块。 它们都在堆外分配,并包含每个竞技场的元数据。 它主要在垃圾收集过程中使用(因此我们暂时将其保留)。
我们刚刚讨论过的Go语言中的分配策略类别,只是在内存分配的奇妙多样性中摸索了一下。
但是,Go内存管理的总体思路是 使用不同的内存结构为不同大小的对象使用不同的缓存级别内存来分配内存 。 从操作系统接收的单个连续地址块分成多级缓存,方法是减少锁,从而提高内存分配效率,然后根据指定的大小分配内存分配,从而减少内存碎片,并在内存达到已发布。
现在,我将向您提供此Go Memory Allocator的直观概述。
好了,现在就这些了。 谢谢您到目前为止的阅读。
· 加入时事通讯
· 开始:完整的Bootcamp课程
· ❤️你想帮忙吗? 请鼓掌并分享文章。 也让其他人从本文中学习。
(本文翻译自Ankur Anand的文章《A visual guide to Go Memory Allocator from scratch (Golang)》,参考: