去分配机制

当我第一次尝试了解Go中的内存分配工具如何工作时,我想处理的东西似乎就像一个神秘的黑匣子。 与其他任何技术一样,这里最重要的事情隐藏在许多抽象层的后面,您需要通过这些抽象层来理解某些内容。



该材料的作者(我们正在翻译的翻译)决定深入探讨Go中的内存分配方法,并对其进行讨论。

物理和虚拟内存


所有分配内存的方法都必须使用虚拟内存的地址空间,该地址空间由操作系统控制。 让我们看一下内存的工作原理,从最低级别开始-从内存单元开始。
这是如何想象RAM单元的方法。


内存单元布局

如果非常简单地想象一个存储单元及其周围是什么,那么我们得到以下信息:

  1. 地址线(晶体管充当开关)是访问电容器(数据线)的通道。
  2. 当信号出现在地址线(红线)中时,数据线允许您将数据写入存储单元,即,对电容器充电,从而可以在其中存储对应于1的逻辑值。
  3. 当地址线(绿线)中没有信号时,电容器被隔离并且其电荷不变。 要写入单元0,必须选择其地址并通过数据线提交逻辑0,即,将数据线与负号相连,从而使电容器放电。
  4. 当处理器需要从内存中读取值时,信号将沿着地址线发送(开关闭合)。 如果电容器已充电,则信号通过数据线(读取1),否则信号不通过数据线(读取0)。


物理内存与处理器交互的方案

数据总线负责在处理器和物理内存之间传输数据。

现在让我们谈谈地址线和可寻址字节。


处理器和物理内存之间的总线地址线

  1. RAM中的每个字节都分配有一个唯一的数字标识符(地址)。 应该注意的是,存在于存储器中的物理字节的数量不等于地址线的数量。
  2. 每条地址线可以指定一个1位的值,因此它表示某个字节的地址中的一位。
  3. 我们的电路有32条地址线。 结果,每个可寻址字节使用32位数字作为其地址。 [00000000000000000000000000000000]-最低的内存地址。 [11111111111111111111111111111111111111]-最高的内存地址。
  4. 由于每个字节都有一个32位地址,因此我们的地址空间由2 32个可寻址字节(4 GB)组成。

结果,可寻址字节的数量取决于地址线的总数。 例如,如果有64条地址线(x86-64处理器),则可以寻址2 个64字节(16艾字节)的内存,但是大多数使用64位指针的体系结构实际上都使用48位地址线(AMD64)和42位地址线(英特尔),理论上允许计算机配备256 TB的物理内存(Linux允许在x86-64体系结构上使用4级地址页时,为进程分配最多128 TB的地址空间,Windows允许您分配最多192 TB)。
由于物理RAM的大小受到限制,因此每个进程都在其自己的“沙盒”中运行-在所谓的“虚拟地址空间”(称为虚拟内存)中。

虚拟地址空间中的字节地址与处理器用来访问物理内存的地址不匹配。 结果,我们需要一个允许我们将虚拟地址转换为物理地址的系统。 看一下虚拟内存地址的外观。


虚拟地址空间表示

结果,当处理器执行引用存储器地址的指令时,第一步是将逻辑地址转换为线性地址。 该转换由存储器管理单元执行。


虚拟内存和物理内存之间关系的简化表示

由于逻辑地址太大而无法方便地单独使用它们(这取决于各种因素),因此内存被组织为称为页的结构。 在这种情况下,虚拟地址空间被分成小区域,页面,在大多数操作系统中,页面大小为4 KB,尽管通常可以更改此大小。 这是虚拟内存中内存管理的最小单位。 虚拟内存不存储任何内容,它只是设置程序的地址空间与物理内存之间的对应关系。

进程仅看到虚拟内存地址。 如果程序需要更多的动态内存(也称为堆内存或“堆”),会发生什么? 这是一个简单的汇编代码示例,其中从系统请求其他动态分配的内存:

_start:        mov $12, %rax #    brk        mov $0, %rdi # 0 -  ,            syscall b0:        mov %rax, %rsi #  rsi    ,           mov %rax, %rdi #     ...        add $4, %rdi # ..  4 ,           mov $12, %rax #    brk        syscall 

这是如何以图表形式表示的方法。


增加动态分配的内存

该程序使用brk系统调用(sbrk / mmap等)请求额外的内存。 内核会更新有关虚拟内存的信息,但是物理内存中尚未出现新页面,因此虚拟内存和物理内存之间存在差异。

内存分配器


概括地说,在讨论了使用虚拟地址空间的问题之后,讨论了如何请求额外的动态内存(堆上的内存)之后,我们将更容易讨论分配内存的方法。

如果堆具有足够的内存来满足我们的代码请求,则内存分配器可以执行这些请求而无需访问内核。 否则,他必须使用系统调用(例如,使用brk)来增加堆的大小,同时请求较大的内存块。 对于malloc,“大”表示由MMAP_THRESHOLD参数描述的大小,默认情况下为128 Kb。

但是,内存分配器比简单分配内存承担更多责任。 他最重要的职责之一是减少内部和外部内存碎片,并尽快分配内存块。 假设我们的程序使用malloc(size)形式的函数顺序执行分配内存连续块的请求,然后使用free(pointer)形式的函数释放该内存。


外部碎片演示

在上一个图中,在步骤p4,尽管可用内存总量允许这样做,但我们没有足够的顺序定位的存储块来满足分配六个这样的块的请求。 这种情况导致内存碎片。

如何减少内存碎片? 这个问题的答案取决于特定的内存分配算法,该算法使用哪种基础库来处理内存。

现在,我们来看一下Go内存分配机制所基于的TCMalloc内存分配工具。

TCM分配


TCMalloc基于将内存划分为多个级别以减少内存碎片的想法。 在TCMalloc内部,内存管理分为两部分:使用线程内存和使用堆。

▍线程记忆


存储器的每一页都分为一定大小的片段序列,这些片段根据大小级别进行选择。 这减少了碎片。 结果,每个线程都可以使用小对象的缓存,从而可以为小于或等于32 KB的对象非常有效地分配内存。


流缓存

▍束


TCMalloc托管堆是页面的集合,其中一组连续页面可以表示为页面范围(跨度)。 当您需要为大于32 KB的对象分配内存时,将使用堆来分配内存。


堆和处理页面

当没有足够的空间将小对象放置在内存中时,它们将转向堆以获取内存。 如果堆没有足够的可用内存,则从操作系统请求其他内存。

结果,提出的使用内存的模型支持用户空间内存池;其使用显着提高了分配和释放内存的效率。

应该注意的是,Go内存分配工具最初是基于TCMalloc的,但是与之略有不同。

转到内存分配器


我们知道Go运行时计划在逻辑处理器上运行goroutine。 类似地,Go使用的TCMalloc版本将内存页面分为多个块,这些块的大小对应于存在67个特定大小类。

如果您不熟悉Go计划程序,则可以在此处阅读有关它的信息


围棋班

由于Go中的最小页面大小为8192字节(8 Kb),因此,如果将这样的页面划分为1 KB的块,那么我们将获得8个这样的块。


8 KB的页面大小分为与1 KB的类大小相对应的块

Go中类似的页面序列是使用称为mspan的结构来控制的。

ms结构mspan


mspan结构是一个双向链接列表,该对象包含页面的起始地址,有关页面大小和其中包含的页面数的信息。


Mspan结构

▍mcache结构


与TCMalloc一样,Go为每个逻辑处理器提供了一个本地线程缓存,称为mcache。 结果,如果goroutine需要内存,则可以直接从mcache获取它。 为此,您无需执行锁定,因为在任何给定时间,一个逻辑处理器上仅执行一个goroutin。

mcache结构以高速缓存的形式包含各种大小类的mspan结构。


Go中逻辑处理器,mcache和mspan之间的交互

由于每个逻辑处理器都有自己的mcache,因此从mcache分配内存时不需要锁。

每个大小类都可以由以下对象之一表示:

  • 扫描对象是包含指针的对象。
  • Noscan对象是没有指针的对象。

这种方法的优势之一是,在执行垃圾回收时,无需绕过noscan对象,因为它们不包含为其分配内存的对象。

什么进入mcache? 大小不超过32 KB的对象将使用相应大小类的mspan直接转到mcache。

如果mcache没有空闲单元会怎样? 然后,它们从称为mcentral的mspan对象列表中获得所需大小级别的新mspan。

▍中心结构


中心结构收集特定大小级别的所有页面范围。 每个中心对象包含两个mspan对象列表。

  1. 没有空闲对象或mcache中的mspan的mspan对象的列表。
  2. 具有可用对象的mspan对象列表。


中心结构

每个中心结构都存在于堆结构中。

▍堆结构


mheap结构由在Go中处理堆管理的对象表示。 只有这样的全局对象拥有一个虚拟地址空间。


堆结构

从上图可以看到,mheap结构包含一组mcentral结构。 该数组包含所有大小类的中心结构。

 central [numSpanClasses]struct { mcentral mcentral   pad     [sys.CacheLineSize unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte } 

由于每个大小类都有一个中心结构,因此当mcache向mcentral请求mspan结构时,将在单个mcentral级别上应用锁,结果是,可以同时处理来自其他mcache的请求,该请求将具有其他大小的mspan结构。

对齐(填充)可确保中心结构彼此分隔开对应于CacheLineSize值的字节数。 结果,每个mcentral.lock都有自己的缓存行,从而避免了与错误的内存共享相关的问题。

如果中心列表为空会怎样? 然后,mcentral从mheap接收一系列页面,以分配所需大小级别的内存片段。

  • free[_MaxMHeapList]mSpanListfree[_MaxMHeapList]mSpanList的数组。 每个spanList中的mspan结构由1〜127(_MaxMHeapList-1)页组成。 例如,free [3]是包含3个页面的mspan结构的链接列表。 在这种情况下,“空闲”一词表示我们正在谈论的是未分配内存的空列表。 与空列表相反,列表可以是分配了内存(忙)的列表。
  • freelarge mSpanList是可用的mspan结构的列表。 每个元素(即mspan)的页面数大于127。为了支持此列表,使用了mtreap数据结构。 繁忙的mspan结构的列表称为busylarge。

大于32 Kb的对象被视为大对象,它们的内存直接从mheap分配。 使用锁定执行为此类对象分配内存的请求,因此,在给定的时间点,仅可以从一个逻辑处理器处理类似的请求。

为对象分配内存的过程


  • 如果对象的大小超过32 Kb,则认为它很大,它的内存直接从mheap分配。
  • 如果对象的大小小于16 Kb,则使用称为微小分配器的mcache机制。
  • 如果对象的大小在16-32 Kb的范围内,则表明要使用哪个大小类(sizeClass),然后在mcache中分配合适的块。
  • 如果sizeClass中没有与mcache相对应的块,则调用mcentral。
  • 如果mcentral没有空闲块,则它们调用mheap并搜索最合适的mspan。 如果事实证明应用程序所需的内存大小大于可能分配的内存大小,则将处理请求的内存大小,以便有可能返回程序所需要的尽可能多的页面,形成新的mspan结构。
  • 如果应用程序的虚拟内存仍然不足,则将访问操作系统以获取一组新的页面(请求至少1 MB的内存)。

实际上,在操作系统级别,Go要求分配更大的内存(称为arenas)。 同时分配大块内存使您可以在分配给应用程序的内存量和对性能的高成本访问之间找到折衷方案。

堆上请求的内存是从竞技场分配的。 考虑这种机制。

虚拟内存去


用一个用Go编写的简单程序看一下内存使用情况:

 func main() {   for {} } 


程序流程信息

即使是这样简单的程序,虚拟地址空间也大约为100 MB,而RSS索引仅为696 Kb。 首先,让我们尝试找出造成这种差异的原因。


地图和地图信息

在这里,您可以看到存储区,其大小大约等于2 MB,64 MB,32 MB。 这是什么样的记忆?

▍竞技场


事实证明,Go中的虚拟内存由一组竞技场组成。 用于堆的初始内存大小对应一个域,即-64 MB(与Go 1.11.5有关)。


各种系统中当前的竞技场规模

结果,程序的当前需求所需的存储器被一小部分地分配。 此过程从一个64 MB的竞技场开始。

我们在这里谈论的那些数字指标不应该用于某些绝对值和不变值。 他们可以改变。 例如,在之前的Go中,Go预先预留了一个连续的虚拟空间,在64位系统上,竞技场大小为512 GB(想想如果实际内存需求如此之大以至于mmap会拒绝相应的请求会发生什么,那会很有趣?)

实际上,我们将一堆竞技场称为一堆。 在Go中,竞技场被视为内存的碎片,分为8192字节(8 Kb)大小的块。


一个64 MB的竞技场

Go具有更多其他类型的块-span和bitmap。 它们的内存在堆外部分配,它们存储竞技场元数据。 它们主要用于垃圾收集。
这是内存分配机制在Go中的工作原理概述。


Go中的内存分配机制概述

总结


通常,应该指出的是,在本材料中,我们以非常笼统的术语描述了使用Go内存的子系统。 Go中的内存子系统的主要思想是使用各种结构和不同级别的缓存来分配内存。 这考虑了为其分配内存的对象的大小。

由于这种方法避免了阻塞,因此以多层结构的形式表示从操作系统接收到的连续内存地址的单个块可以提高内存分配机制的效率。 资源分配,考虑到需要存储在内存中的对象的大小,可以减少碎片,并且在释放内存之后,可以加快垃圾回收的速度。

亲爱的读者们! 您是否遇到过由于Go编写的程序中的内存故障而引起的问题?

Source: https://habr.com/ru/post/zh-CN442648/


All Articles