掘金 后端 ( ) • 2024-05-06 10:09

引言

对《Efficient Go》第五章 How Go Uses Memory Resource 的摘要和补充。

在冯诺依曼架构中,从主内存访问数据会遇到CPU和内存墙问题。为了克服这类问题,发明了很多硬件和软件的机制,这些机制也为开发提供了一层新的抽象,在 Go 中定义一个变量时,不需要考虑需要保留多少内存,存储在哪里,需要适配多少缓存。这对提升开发速度很有帮助,但是在面对大量数据的场景时,需要唤起对内存资源的机械同情来防止可能出现的意外。

本章从内存与实际问题的相关性开始,介绍了内存的硬件机制,然后探讨了操作系统的内存管理,最后介绍了Go程序员在优化内存效率时最重要的Go内存管理:内存布局、Go分配器和垃圾回收。

内存与问题的相关性

对大多数程序来说,CPU是运行的关键,被用来保存、读取、管理、操作和从不同媒介转换数据,而内存资源位于这些交互的核心。内存是计算机的支柱,每一个外部的数据必须加载至内存中才能被CPU访问。如操作系统启动一个新进程的第一件事就是将程序的机器代码和初始数据部分加载到内存中以便CPU执行。

但是在使用内存时必须要时刻注意内存的三个特性

  • RAM的访问速度明显慢于CPU的操作速度
  • 机器中的RAM大小是有限的,必须关心程序的空间效率
  • 常用的DRAM是易失性的,在电源关闭时会丢失所有信息

因为内存的这些特性,如果使用了错误的方式使用内存,会出现效率低下和不必要的计算资源和执行时间浪费。

  • 物理计算机、虚拟机、容器或进程会因为内存不足(OOM)信号而崩溃
  • 内存使用量高于平均水平会使操作系统频繁换页而放慢执行速度
  • 创建过多临时对象会占用CPU资源进行内存分配和释放从而放慢执行速度

为了明确这些问题的成因,从最基本的物理内存基本细节开始,可以更好地意识到内存问题以及优化内存问题。

物理内存

计算机以比特的形式在存储单元中存储信息,对应到物理结构中,就是高电平与低电平,不同的半导体元件有不同的方式保持高电平,不同的方式也有不同的制造成本和效率。因此构建存储器层次结构主要有四种技术(SRAM、DRAM、闪存、磁盘)。其中通俗意义上的物理内存指DRAM,而SRAM主要用于处理器L-cache。

image.png

SRAM不需要刷新,其访问时间与周期时间非常接近,SRAM也是各种存储器技术中访问速度最快的。一个基本的存储单元通常由6~8个晶体管组成。SRAM只需要最小功率来保持电荷,就可以一直保持其中的数据。

DRAM使用电容保存电荷的方式来存储数据。为了对保存的电荷进行读取或写入,使用一个晶体管对电容进行访问,因此DRAM的密度比SRAM高很多,价格也便宜很多。由于电容不能长久保存电荷,必须周期性进行刷新,DRAM采用了两级译码结构,通过在一个读周期后紧跟一个写周期的方式一次刷新一整行。

image.png

DRAM以bank的方式组织,每个bank由多个行组成。发送一条Pre(预充电)命令可以打开或关闭一个bank,使用Act(激活)命令发送一个行地址,将对应的行中的数据传送到一个缓冲器中,这个过程也叫做行地址选择。当一行数据在缓冲器中时,可以通过要传送的数据块大小和在缓冲器中的起始地址的方式传送相邻地址的数据,这个过程叫做列地址访问。

这种可寻址性也是主内存被称为随机访问内存的原因,在这个过程中,即使有将单字节复制到寄存器的指令,在内存中也不会只取1字节,而至少取一个缓存行。操控这个过程对于顶层的应用开发来说过于繁琐,不过现代的程序都不会直接访问物理内存,而是访问由操作系统提供的抽象——虚拟内存。

操作系统内存管理

隐藏物理内存访问的复杂性只是操作系统内存管理的目标之一,多任务操作系统中,更重要的是允许成千上万个进程及内核级线程安全地使用相同的物理内存,操作系统需要做到:

  • 为每个进程分配专用的内存空间:操作系统跟踪地址空间中哪个物理内存插槽属于哪个进程并协调这些预留
  • 避免外部碎片化:避免因低效的打包导致内存碎片化
  • 内存隔离:确保没有进程可以侵入为其他进程保留的物理内存地址,防止越界内存访问导致的进程崩溃
  • 内存安全:不同进程对不同资源有不同的权限,防止恶意进程读取其他进程的数据。
  • 高效内存使用:程序从不同时使用它们请求的所有内存,通过合理调整分配内存的时机提高内存的使用率

为了达成以上目标,操作系统使用三种基本机制来管理内存

分页虚拟内存

虚拟内存的关键思想是每个进程都被赋予了自己的简化的RAM视图,编程语言的设计者和开发者可以有效地管理内存空间,就如同他们拥有整个内存空间。更进一步,即使物理内存只有32GB(2^35个地址),进程也可以使用从 0 到 2^64−1 的完整地址范围来存储其数据。

现在操作系统用于实现虚拟内存的方式是分页。操作系统将物理和虚拟内存划分为固定大小的内存块。虚拟内存块称为页面,而物理内存块称为帧。页面和帧可以分别进行管理。默认的页大小是4 KB。

操作系统可以动态地将虚拟内存中的页面映射到特定的物理内存帧。因为每次通过操作系统转换地址会减慢速度,这个过程实际由CPU中的硬件设备完成。CPU包含用于每次内存访问的内存管理单元(MMU)。MMU根据操作系统页面表条目将CPU指令引用的每个内存地址转换为物理地址。为了避免访问RAM以搜索相关的页面表,进而添加了转换后备缓冲器(TLB)。TLB是一个小型缓存,可以缓存几千个页面表条目(通常为4 KB的条目)。

image.png

TLB(转换后备缓冲器)非常快,但其容量有限。如果内存管理单元(MMU)在TLB中找不到被访问的虚拟地址,也就是TLB未命中。这意味着CPU(硬件TLB)或操作系统(软件TLB)必须在RAM中遍历页面表,这会导致显著的延迟(约一百个CPU时钟周期)

此外,并不是每个分配的虚拟内存页面都有一个背后对应的保留的物理内存页面。事实上,大部分的虚拟内存根本没有由RAM支持。因此,总是可以看到进程使用大量的虚拟内存(在各种Linux工具如ps中称为VSS或VSZ),但实际为此进程保留的物理内存(通常称为RSS或RES,来自“常驻内存”)可能非常少。经常有单个进程分配的虚拟内存超过了整个机器可用的情况。

操作系统默认对试图分配物理内存的进程采取相同的超额分配策略。物理内存只有在我们的程序访问它时才会被分配,而不是在它创建一个大对象时,例如make([]byte, 1024)

超额分配是通过页面和内存映射技术实现的。通常,内存映射指的是通过Linux上的mmap系统调用。

mmap系统调用

mmap的优势在于它提供了一种高效的方式来处理大型文件或数据块的内存映射,而无需将整个文件加载到RAM中。这意味着操作系统可以优化物理内存的使用,并且只有当程序实际访问某个特定部分的数据时,该部分数据才会被加载到物理内存中。通过内存映射,对文件的读写操作可以像对常规内存操作一样简单,使得数据处理变得更加直接和高效。

当进程请求操作系统使用mmap分配任何虚拟内存时,操作系统不会在RAM上分配任何页面,无论其大小如何。相反,操作系统只会给进程一个虚拟地址范围。随后,当CPU执行第一条访问该虚拟地址范围内的内存的指令时,MMU将生成一个缺页错误。缺页错误是由操作系统内核处理的硬件中断,操作系统有多种响应的方式:

  • 分配更多的RAM帧
  • 取消分配未使用的RAM帧并重用
  • 触发内存不足(OOM)

操作系统内存映射

mmap系统调用只是操作系统内存映射技术的一个例子,实际的操作系统内存映射需要面对多种情况

image.png

  1. Page A:代表了一个已经映射到RAM上的匿名文件映射的简单案例。例如,如果进程1从其虚拟空间中0x2000到0x2FFF的地址写入或读取一个字节,MMU将该地址转换为物理RAM地址0x9000,加上所需的偏移量。结果是,CPU将能够作为缓存行将其提取或写入到其L缓存和所需的寄存器中。
  2. Page B:代表了一个基于文件的内存页面映射到一个物理帧。这个帧也与另一个进程共享,因为没有必要为相同的数据保持两份副本,两个映射都映射到磁盘上的同一个文件。
  3. Page C:代表一个尚未被访问的匿名文件映射。例如,如果进程1需要读写这个文件,CPU将生成一个缺页错误硬件中断,操作系统将需要找到一个空闲帧。
  4. Page D:这是一个类似于页面C的匿名页面,但已经有一些数据被写入。尽管如此,操作系统启用了交换,并且因为页面D长时间未被进程2使用,或者系统处于内存压力之下,而从RAM中解除映射。操作系统将数据备份到交换分区中的交换文件中,以避免数据丢失。

这是操作系统内存管理的基础。通过理解操作系统内存管理,可以得出一些对Go或其他语言操作内存的基本认知:

观察虚拟内存的大小不是很有用

由于按需分页,我们总是看到进程的虚拟内存使用量(表示为虚拟集大小,或VSS)大于常驻内存使用量(RSS)。 虽然进程认为它在虚拟地址空间看到的所有页面都在RAM中,但其中大多数可能当前未映射并存储在磁盘上。当评估Go程序使用的内存量时,可以忽略VSS指标。有一些更实用的内存指标:

  • 常驻集大小(RSS): RSS衡量的是进程实际占用的物理内存大小,而不考虑被交换出去(swap out)的内存部分。尽管RSS提供了进程占用物理内存的近似值,但由于操作系统的缓存策略和延迟释放机制,RSS值可能会高于或低于实际使用的内存。

  • 工作集大小(Working Set Size, WSS): WSS尝试衡量在特定时间窗口内实际被进程访问的内存量。虽然这个指标不常直接显示在系统监控工具中,但它可以通过特定的性能分析工具或通过分析mincore()系统调用的输出来间接获取。WSS可以更准确地反映应用程序在运行期间的内存活跃度。

  • 交换空间使用量: 如果系统配置了交换空间,监控交换空间的使用量也可以提供有关内存压力的间接信息。大量的交换活动通常表明物理内存不足,进程的内存被频繁地换入换出。

  • 缺页错误和TLB未命中: 缺页错误次数可以指示进程访问内存时的效率低下,尤其是那些因为数据不在物理内存中而导致的主要页面错误。TLB未命中的频率也可以反映内存访问模式的效率。

RAM的高使用量可能导致程序执行缓慢

当系统执行许多进程,这些进程想要访问接近RAM容量的大量页时,内存访问延迟和操作系统清理程序可能占据了大部分CPU周期。此外,诸如内存抖动、持续的内存交换和页面回收机制等问题会减慢整个系统。因此,如果程序延迟很高,并不一定是因为它在CPU上做了太多工作或执行了慢速操作(例如I/O),它可能只是使用了大量的内存。

Go内存管理

Go程序内存布局

Go使用了一种相对标准的内部进程内存管理模式,这种模式也被其他语言(如C/C++)使用,但Go也有一些独特的元素。当一个新进程启动时,操作系统会创建关于进程的各种元数据,包括一个新的专用虚拟地址空间。操作系统还会根据程序二进制文件中存储的信息为一些起始段创建初始内存映射。一旦进程启动,它就会使用mmap或brk/sbrk在需要时动态分配更多的虚拟内存页面。

image.png

在这张图中,蓝色代表位于内存的页中,粉色代表位于磁盘的文件中。下面是对各部分的介绍:

  • .text、.data 和共享库
    程序代码和所有全局数据(如全局变量)在进程启动时由操作系统自动映射到内存中(无论它占用1MB还是100GB的虚拟内存)。这些数据是只读的,由二进制文件支持。此外,CPU一次只执行程序的一小部分,因此操作系统可以在物理内存中保持最少数量的代码和数据页面。这些页面也被大量共享(更多进程使用相同的二进制文件启动,加上一些动态链接的共享库)。

  • .bss
    当操作系统启动一个进程时,它还会为未初始化的数据(.bss)分配匿名页面。.bss使用的空间量是预先知道的——例如,http包定义了DefaultTransport全局变量。虽然我们不知道这个变量的值,但我们知道它将是一个指针,所以我们需要为其准备八字节的内存。这种内存分配称为静态分配。这个空间一旦分配,由匿名页面支持,且永远不会被释放(如果启用了交换,可以从RAM中取消映射)。


  • 堆是为动态分配预留的内存。动态分配是程序数据(例如,变量)必需的,这些数据必须在单个函数作用域之外可用。因此,这种分配事先未知,必须在内存中存储不可预测的时间。进程启动时,操作系统为堆准备初始数量的匿名页面。此后,操作系统给予进程对该空间的一些控制权。它可以使用sbrk系统调用增加或减少其大小,或者使用mmap和unmmap系统调用准备或移除额外的虚拟内存。
    如何组织和管理堆,不同语言有不同的做法: C语言强迫程序员手动为变量分配和释放内存(使用malloc和free函数)。 C++增加了智能指针,如std::unique_ptr和std::shared_ptr,这些指针提供简单的计数机制来跟踪对象生命周期(引用计数)。 Rust拥有强大的内存所有权机制,但它使得对非内存关键代码区域的编程变得更加困难。 最后,像Python、C#、Java等语言实现了高级堆分配器和垃圾收集器机制。垃圾收集器定期检查是否有未使用的内存可以释放。 在这方面,Go在内存管理上更接近Java而非C。Go隐式地(对程序员透明地)在堆上分配需要动态分配的内存。

  • 手动进程映射
    Go运行时和编写Go代码的开发者都可以手动分配额外的内存映射区域。使用何种类型的内存映射(私有或共享,读或写,匿名或文件支持)取决于进程,这些都在进程的虚拟内存中有专门的空间。


  • 栈是一个简单但快速的结构,允许以后进先出(LIFO)的顺序访问值。编程语言使用它们来存储所有可以使用自动分配的元素(例如,变量)。与由堆完成的动态分配相反,自动分配适用于本地数据,如本地变量、函数输入或返回参数。这些元素的分配可以是“自动的”,因为编译器可以在程序开始前推断出它们的生命周期。
    Go的执行流程是围绕goroutines设计的。因此,Go为每个Go协程维护一个单独的动态大小的栈,可能着有成千上万的栈。每当goroutine调用另一个函数时,可以将其局部变量和参数推入栈帧中的栈。当离开函数时,可以从栈中弹出这些元素(释放栈帧)。如果栈结构需要的空间超过了虚拟内存中预留的空间,Go会要求操作系统为栈段分配更多内存,例如通过mmap系统调用。
    栈没有额外的开销来确定某些元素使用的内存何时必须移除,因此栈非常快。理想情况下编写算法时,应该主要在栈上分配,而不是在堆上。但由于栈的大小限制或变量必须比函数的作用域存在更长的时间,这在许多情况下是不可能的。因此,编译器决定哪些数据可以自动分配(在栈上),哪些必须动态分配(在堆上)。这个过程称为逃逸分析。

在Go中,分配是隐式的,这使得编程变得更容易,但也存在权衡。其中之一是关于内存效率:如果我们看不到明确的内存分配和释放,我们可能会漏掉代码中明显的高内存使用。

Go分配器

Go管理堆内存面临着操作系统管理物理内存时类似的问题:Go运行多个协程,每个协程可能需要不同时间长度的堆内存中的几个段。

Go分配器是由Go团队维护的内部运行时代码,在运行时动态地分配操作对象所需的内存块。在编译期间,Go编译器执行栈逃逸分析,以检测对象的内存是否可以自动分配。如果可以,它会添加CPU指令,将相关内存块存储在内存布局的栈中。然而大多数情况下,编译器无法避免将大部分的内存放在堆上。在这些情况下,它生成不同的CPU指令来调用Go分配器代码。

这里简单列出以下Go分配器的特性:

  • 基于Google的自定义C++ malloc实现,称为TCMalloc。
  • 意识到操作系统的虚拟内存页面,但操作的是8 KB的页面。
  • 通过将内存块分配给某些跨度来减轻碎片化,每个跨度持有一个或多个8 KB页面。每个跨度都是为特定的类内存块大小创建的。例如,在Go 1.18中,有67个不同的大小类(大小桶),最大的是32 KB。
  • 不包含指针的对象的内存块被标记为noscan类型,这使得在垃圾收集阶段跟踪嵌套对象变得更加容易。
  • 超过32 KB内存块的对象(例如,600 MB字节数组)会被特殊处理(直接分配,不使用跨度)。
  • 如果运行时需要从操作系统为堆获取更多的虚拟空间,会一次性分配更大的内存块(至少1 MB),这样可以分摊系统调用的延迟。

垃圾收集

堆管理的另一部分是垃圾收集。垃圾收集器(GC)是一个额外的后台例程,从程序的堆中移除未使用的对象。对于垃圾收集收集来说,收集的频率至关重要:

  • 如果GC运行得较少,会冒着分配大量新RAM空间的风险,而无法重用当前由垃圾(未使用的对象)分配的内存页面。
  • 如果GC运行得太频繁,会冒着将大部分程序时间和CPU用于GC工作而不是推进功能的风险。

GC运行的间隔不是基于时间的。相反,两个独立工作的配置变量定义了节奏:GOGC和从Go 1.19开始的GOMEMLIMIT。

  • GOGC选项代表GC百分比。
    GOGC默认启用,值为100。这意味着下一次GC收集将在堆大小扩展到上一次GC周期结束时大小的100%时进行。GC的节奏算法根据当前堆增长估计何时达到该目标。它也可以通过debug.SetGCPercent函数以编程方式设置。

  • GOMEMLIMIT选项控制软内存限制。
    GOMEMLIMIT选项在Go 1.19中引入。默认情况下它是禁用的(设置为math.MaxInt64),并且在我们接近(或超过)设置的内存限制时提供更频繁地运行GC的选项。它可以与GOGC=off(禁用)一起使用,或与GOGC一起使用。这个选项也可以通过debug.SetMemoryLimit函数以编程方式设置。

除此以外,也可以调用 runtime.GC() 手动触发另一次垃圾收集,可以用于测试或基准测试代码。

Go的垃圾收集实现可以描述为并发的、非代际的、三色标记和清除收集器实现。无论是由程序员调用还是由基于运行时的 GOGC 或 GOMEMLIMIT 选项调用,runtime.GC() 实现包括几个阶段。第一个是标记阶段,必须:

  1. 执行一个“停止世界”(STW)事件,以将一个必要的写屏障(对写入数据的锁定)注入所有协程。尽管 STW 相对较快(平均10-30微秒),但它影响很大——它会暂停我们进程中所有协程的执行。
  2. 尝试使用给定进程的25% CPU容量来并发标记堆中仍在使用的所有对象。
  3. 通过从协程中移除写屏障来结束标记。这需要另一个 STW 事件。

在标记阶段之后,GC 函数通常就完成了,实际上并没有释放任何内存。相反,清扫阶段释放了未标记为正在使用的对象。这是懒惰地完成的:每次协程想通过Go分配器分配内存时,它必须先执行清扫工作,然后再分配。这被计为分配延迟,尽管它从技术上讲是垃圾收集功能。

一般来说,Go分配器和GC构成了一个复杂的分桶对象池实现,其中每个不同大小的槽的池都为即将到来的分配做好了准备。当一个分配不再需要时,它最终会被释放。这个分配的内存空间不会立即释放给操作系统,因为它可能很快就会被分配给另一个即将到来的分配。

分配时需要注意的问题

最后,基于对Go内存的整体认识,可以总结出一些不注意分配的数量和类型时,可能会出现的问题:

CPU开销
首先,GC必须检查堆中存储的所有对象,以判断哪些对象正在使用中。如果堆上存储的对象含有大量的指针类型,会迫使GC遍历这些对象,以检查它们是否指向尚未标记为“正在使用”的对象。由于计算机中的CPU资源有限,GC做的工作越多,能为核心程序功能做的工作就越少,这意味着程序延迟增加。

程序延迟的额外增加
两次执行的STW(停止世界)事件会减慢所有协程的速度。GC必须停止所有协程并注入写屏障然后移除。还需要阻止一些必须在内存中存储一些数据的协程在GC标记的时刻进行任何进一步的工作。

同时,GC收集运行对层次化缓存系统的效率也是具有破坏性的。

内存开销
如果无限制地分配内存。如果GC频率过快或过慢都会产生不同的问题,过快会严重影响CPU的执行效率,过慢不足以处理所有新的分配,甚至有内存泄露的风险。

引用

Bartlomiej Plotka. 2022. Efficient Go
David Patterson, John Hennessy. 2014. Computer Organization and Design: The Hardware/ Software Interface (5th)