[C#] .net 内存管理[5]

20 篇文章 0 订阅

Operating System(操作系统)

  到目前为止,我们已经花了很多时间非常接近硬件。 我最初也答应看操作系统。 现在是这样做的最佳时机。 实际上,操作系统的设计者必须非常认真地对待所有先前仅简要介绍的事实。 正如您很快就会看到的那样,它仍然只是更广泛现实的一个片段。

  由于操作系统和硬件架构的不同,物理内存限制从 2 GB 到 24 TB 不等。 现在典型的商品硬件配备了 4 到 8 GB 的内存。 如果给定的程序必须直接使用物理内存,它就需要管理它创建和删除的所有内存区域。 这样的内存管理逻辑不仅复杂,而且在每个程序中都会重复。 此外,从低级编程的角度来看,以这种方式使用内存也很麻烦。 每个程序都必须记住它使用了哪些内存区域,这样程序就不会相互干扰。 分配器将需要与此类区域管理合作以正确管理创建和删除的对象。 从安全的角度来看,这也是相当危险的——没有任何中间层,程序不仅可以访问它自己的内存区域。

Virtual Memory(虚拟内存)

  因此引入了一个非常方便的抽象——虚拟内存。 它将内存管理逻辑移至操作系统,为程序提供所谓的虚拟地址空间。 特别是它意味着每个进程都认为它是系统中唯一运行的进程,并且整个内存都是为它自己的目的。 更。 因为地址空间是虚拟的,所以它可以比物理内存大。 这允许它使用大容量存储硬盘驱动器等二级存储来扩展物理 DRAM 内存。

注意 有没有没有虚拟内存的操作系统? 对于任何商品用途,没有。 但是,是的,有一些特殊用途,主要是针对嵌入式系统的非常小的操作系统和框架。 其中一个例子是(微)Clinux 内核。

  这是操作系统内存管理器发挥作用的地方。 它有两个主要职责:

  • 将虚拟地址空间映射到物理内存——在 32 位机器上有 32 位长的虚拟地址,在 64 位机器上有 64 位长(虽然目前只使用低 48 位,这仍然允许 128 TB 数据的地址 ; 并且都简化了架构并允许我们避免不必要的开销)。

  • 将一些内存区域从 DRAM 内存移动到硬盘驱动器,并在需要或当前不需要时移动回来。 显然,由于总使用内存可能大于物理内存,有时它的某些部分必须临时存储到较慢的介质(如 HDD)中。 存储此类数据的地方称为页面文件或交换文件。

  操作系统内存管理器还有两个主要的附加职责:管理内存映射文件和写时复制内存机制。 然而,我们在这里不涉及它们,因为它们与我们的目的无关。

  从 RAM 中删除一段数据并将其保存在临时存储器中的需要显然与性能的大幅下降有关。 这个过程在不同的系统中被定义为交换或分页主要是由于历史原因。 Windows 有一个称为页面文件的专用文件,用于存储内存中的数据,因此称为分页。 对于 Linux,此类数据存储在称为交换分区的专用分区中。 因此,类 Unix 系统上的术语交换。

  虚拟内存在 CPU 中实现(借助内存管理单元 - MMU)并与 OS 配合使用。 虚拟内存管理以所谓的页面组织。 由于逐字节将虚拟空间映射到物理空间是不切实际的,而是映射整个页面(连续的内存块)。 因此,从操作系统的角度来看,页面是管理内存的基本构建块。 图 2-14 显示了虚拟内存和物理内存的示意图。

在这里插入图片描述
图 2-14。 虚拟到物理页面映射。 每个进程(A 为浅灰色,B 为深灰色)都有自己的虚拟地址空间,但实际上它们的页面既存储在 RAM 中(实心页面),也被分页(交换)到磁盘(虚线页面)。

  每个进程都有一个由操作系统维护的页面目录,它允许我们将虚拟地址映射到物理地址。 简而言之,页面目录条目指向页面的物理起始地址和其他元数据,如权限。 过去有一个简单的单级映射,其中地址由页选择器和页内偏移组成,如图 2-15 所示。

在这里插入图片描述
图 2-15。 一级页目录 - 虚拟地址由从页目录中选择单个页条目的选择器 (S) 和页内偏移量 (O) 组成

  一级页目录的主要缺点是产生太大的页面或页目录大小太大。 大页面是一个主要问题,因为它会浪费资源——操作系统在分配内存时需要页面对齐。 因此,即使是小数据,也需要分配整个大页面。 另一方面,页目录太大也是一个问题,因为每个进程都将其存储在主内存中,因此会浪费内存。 让我们看看 32 位和 64 位机器上页面大小与页面目录大小的简单计算(参见表 2-4)。

表 2-4。 不同机器上可能的一级页面目录大小

在这里插入图片描述
注意:偏移大小必须足够大以覆盖整个页面大小。 那么Selector size就是整个地址的余数。 页面目录大小是 2^selector * 地址大小。

  在 64 位机器上不可能实现如此巨大的页面目录。 在 4 kB 页面的情况下,每个进程应该存储 512 GB 专用于页面目录,这显然是不可能的。 另一方面,4 MB 的页面大小是一个巨大的开销。 即使一个进程需要几千字节,它也需要从系统中获取一个完整的 4MB 宽的大内存页。 512 MB 的页面目录大小仍然很多。

  此外,进程不会消耗整个可用的虚拟内存。 他们倾向于将使用过的内存分组到逻辑块(栈、堆、二进制文件等)中,因此这样的目录相当稀疏,它们之间有很大的空洞,存储整个目录是一种资源浪费。

  现在常用的做法是引入多层次的指标。 这使我们能够压缩稀疏页面目录数据的存储,同时保持较小的页面大小。 目前在大多数体系结构上,典型的页面大小为 4 kB(包括 x86、x64 和 ARM)和 4 级页面目录(见图 2-16)。
在这里插入图片描述
图 2-16。 页面大小为 4kB 的四级页面目录 - 三级页面选择器允许它表示更稀疏的数据

将虚拟地址转换为物理地址时,需要遍历页面目录:

  • 1 级选择器选择 1 级目录中的一个条目,该条目指向 2 级目录条目之一
  • 2 级选择器选择特定 2 级目录条目中的条目,该条目指向 3 级目录条目之一
  • 3 级选择器选择特定 3 级目录条目中的条目,该条目指向 4 级目录条目之一
  • 最终,第 4 级选择器在特定的第 4 级目录条目中选择一个条目,该条目直接指向物理内存中的某个页面。
  • 偏移指向选定页面内的特定地址

  这种转换需要遍历树,但正如我们所说,页面目录与所有其他数据一样保存在主内存中。 这意味着它也可以通过 L1/L2/L3 缓存进行缓存。 但是,如果每次地址转换(经常执行的操作)都需要访问这些数据(即使使用 L1 缓存),它仍然会带来巨大的开销。 因此,引入了转移后备缓冲区 (TLB),它缓存转移本身。 这个想法很简单——TLB 就像一个映射,其中选择器是一个键,页面的物理地址开始是一个值。 TLB 的构建速度非常快,因此它们在存储方面很小。 它们也是多级的,就像页面目录结构一样。 TLB 未命中(尚未缓存虚拟到物理转换)的结果是执行整页目录遍历,正如我们提到的那样代价高昂。

有趣的注意事项 与缓存一样,TLB 预取是棘手的 - 如果 CPU 本身是触发预取的人(例如,由于分支预测),它可能会导致不必要的页面目录遍历(因为分支预测可能无效) . 因此,正在使用 TLB 的软件预取。

  是否有任何与软件开发相关的 TLB 优化? 它主要意味着一件事:通常减少页数以避免许多 TLB 未命中。 这也将允许我们保持页面目录较小,这是增加它长时间保留在 TLB 中的机会的一种方式。 但是,从 .NET 的角度来看,我们对页面管理没有影响。

有趣的注意事项 通常,L1 在虚拟地址上运行,因为转换为物理地址的成本比快速缓存访问本身要大得多。 这意味着当页面被更改时,所有或部分缓存行必须无效。 因此,页面更改通常也会对缓存性能产生负面影响。

Large Pages(大页面)

  从前面的描述中可以看出,虚拟地址转换的成本很高,最好尽可能避免它。 主要方法是使用大页面大小。 这将需要更少的地址转换,因为许多地址将适合同一页,并且已经有 TLB 缓存的转换。 但正如我们所说,大页面是一种资源浪费。 有一种解决方案——所谓的大(或大)页面。 在硬件支持下,它们允许我们创建一个大的、连续的物理内存块,其中包含许多按顺序放置的普通页面。 这些页面通常比普通页面大两到三个数量级。 它们在程序需要随机访问千兆字节数据的情况下很有用。 数据库引擎是大页面消费者的例子。 Windows 操作系统还使用大页面映射其核心内核映像和数据。 大页面是不可分页的(不能移动到页面文件)并且在 Windows 和 Linux 上都受支持。 不幸的是,由于碎片,很难分配一个大页面,并且可能没有足够的连续物理内存范围。

  .NET 运行时当前不使用大页面,因为它实际上希望页面在大部分可能情况下变小。 然而,使用大页面在 .NET GC 的考虑事项列表中,但尚未给出时间表。 我们也可以在设计自定义 CLR 主机时尝试使用大页面,如第 15 章所述。

Virtual Memory Fragmentation(虚拟内存碎片)

  与往常一样,在分配和取消分配内存时,威胁可能是碎片。 我们在第 1 章讨论堆概念时提到过它。 在虚拟内存的情况下,这意味着操作系统将无法分配给定大小的连续内存块,因为已用内存之间没有足够大的间隙,尽管所有空闲间隙的总大小可能大大超过 所需尺寸。

  对于 32 位应用程序来说,这个问题可能很严重,其中虚拟空间可能太小,无法满足当今的需求。 当进程分配相当大的内存段并运行相当长的时间时,碎片可能会特别严重:这正是我们可能遇到的情况,例如,在 32 位版本的基于 Web 的 .NET 应用程序中处理 (托管在 IIS 上)。 为了防止碎片化,进程必须正确管理内存(对于 .NET 进程,这个进程就是 CLR 本身)。 我们将在第 7-10 章描述垃圾收集算法时深入研究这些细节,因为它需要对 .NET 本身有更深入的了解。

General Memory Layout(一般内存布局)

  了解了基本的内存构建器块,我们现在可以继续讨论更高级别的内存。 出现的第一个问题是程序在内存中的外观。 在描述一个程序的典型内存布局时,通常可以看到如图 2-17 所示的图形。 它显示了整个虚拟内存空间中用 C 或 C++ 布局编写的程序内存的结构。 这就是为什么我们也对此感兴趣。 正如我们将在下一章中看到的那样,CLR 是用 C++ 编写的,因此托管程序在类似的环境中执行。
在这里插入图片描述
图 2-17。 典型的通用进程内存布局

很容易看出,虚拟地址空间分为两个区域:

  • Kernel space(内核空间)——地址的上限范围被操作系统本身占用。 它被称为内核空间,因为它是拥有该区域的内核,并且只有内核才被允许对其进行操作。
  • User space(用户空间)——地址范围的较低部分被分配给进程。 该区域称为用户空间,因为它是有权访问该区域的用户进程。

  从我们的角度来看,当然,最有趣的是用户空间,因为这是 .NET 程序所在的内存区域。 由于存在虚拟内存机制,每个进程都以这种方式看到内存——就好像它是系统中唯一的进程一样。

  关于地址空间,在展示内存布局示意图时,最常见的约定是低地址(从0开始)在底部,然后向上上升。 还记得第 1 章中的栈和堆吗? 通常的约定是在高地址画一个栈,在下面画一个堆。 栈向下增长,堆向上增长。 这可能表明堆栈可能会遇到堆; 但实际上,如果只是因为对大小施加限制,它永远不会发生。

以下是图 2-17 中剩余的内存段描述:

  • 数据段包括已初始化和未初始化的全局变量和静态变量。
  • 包含应用程序二进制文件和字符串文字的文本段。 由于历史原因,它被如此命名是因为根据定义,它只包含只读数据。

  这样的方案其实对实现内存的总体布局还是很有用的。 但是,一旦我们看到,现实就会更加复杂。 并且从 .NET 的角度在两大操作系统的上下文中更好地描述 - Windows 和 Linux 环境

Windows Memory Management (Windows 内存管理)

  Microsoft Windows 操作系统无疑是最流行的.NET 平台环境。 所以当我们想在操作系统的上下文中研究内存管理时,显而易见的选择是从 Microsoft Windows 开始。

  由于系统设计,虚拟地址空间受限于系统版本。 表 2-5 中提供了这些限制的摘要。

表 2-5。 Windows 上的虚拟地址空间大小限制(用户/内核)
在这里插入图片描述

注意 有一种称为地址窗口扩展 (AWE) 的机制允许我们分配比此处列出的更多的物理内存,然后通过“AWE 窗口”仅将其中的一部分映射到虚拟地址空间。 这在 32 位环境中特别有用,可以克服每个进程 2 或 3 GB 的限制。 然而,这与我们无关,因为 CLR 不使用这种机制。

  在 32 位系统统治的末期,单个进程的虚拟内存大小的限制变得令人痛苦。 在大型企业应用程序中,将内存限制在 2GB(或在扩展模式下为 3GB)可能会出现问题。 典型示例是在 Windows Server 32 位计算机的 IIS 上托管的 ASP.NET Web 应用程序。 如果要用尽此限制,除了重新启动整个 Web 应用程序之外别无选择。 这迫使大型 Web 系统进行水平扩展,创建多个处理较少流量的服务器实例,从而消耗较少的内存。 当今世界以64位系统为主导,限制虚拟内存不再是问题。 我们还没有看到标准程序需要数十 TB RAM 的日子。 但请注意,即使在 64 位 Windows 服务器上,32 位编译程序的虚拟内存限制也为 4 GB。

Windows 中的内存管理子系统由两个主要层公开:

  • 虚拟 API - 这是一个在页面粒度上运行的低级 API。 您可能听说过 VirtualAlloc 和 VirtualFree 函数,它们是属于该层的函数示例。
  • 堆 API - 更高级别的 API,为小于页面大小的分配提供分配器(回忆一下第 1 章)。 该层包括 HeapAlloc 和 HeapFree 函数等。

  堆 API(公开堆管理器)通常由内存管理的 C/C++ 运行时实现使用。 您可能熟悉 C/C++ 中流行的运算符 new 和 delete 或 malloc 和 free。 由于 CLR 有自己的 Allocator 实现来创建 .NET 对象(我们将在第 6 章中详细介绍),因此它主要使用 Virtual API。 简而言之,CLR 向操作系统请求额外的页面,而这些页面中对象的适当分配由它自己处理。 堆 API 也被 CLR 用来创建许多更小的内部数据结构。

  在 Windows 上,了解与进程关联的不同内存类别很重要。 这并不像看起来那么微不足道。 同时,如果没有这些知识,我们将很难理解最重要的问题之一——我们观察到的进程实际消耗了多少内存?

  为了回答这个问题,我们需要了解有关在 Windows 中管理页面的更多知识。 页面可以处于下面列出的四种不同状态:

  • Free——尚未分配给任何进程或系统本身。
  • Committed (private) - 分配给一个进程。 它们也被称为私有页面,因为它们只能由该特定进程使用。 当一个提交的页面第一次被进程访问时,它被零初始化。 提交的页面可以分页到磁盘并返回。
  • Reserved -(保留) - 保留给进程。 内存预留是指在不实际分配内存的情况下获得连续范围的虚拟地址。 这允许我们提前保留一些空间,然后才在需要时实际提交其中的某些部分。 这在物理上不消耗内存,只是一些内部数据结构的轻量级准备。 当程序知道此时需要多大的内存块时,它们也可以立即保留和提交内存。
  • Shareable(可共享) - 为进程保留,但可以与其他进程共享。 这通常意味着系统范围库 (DLL) 和资源(字体、翻译)的二进制图像和内存映射文件。

  此外,私有页面可以被锁定,这使得它们保留在物理内存中(不会被移动到页面文件)直到明确解锁或应用程序结束。 锁定对于程序中的性能关键路径可能是有益的。 我们将在第 15 章中看到一个在自定义 CLR 主机中使用页锁定的示例。

  保留页面和提交页面由进程在上述 VirtualAlloc/VirtualFree 和 VirtualLock/VirtualUnlock 方法调用的帮助下进行管理。 还值得注意的是,尝试访问空闲或保留内存将导致访问冲突异常,因为该内存还不能映射到物理内存。

注意 为什么有人要发明这样一种获取内存的双向过程呢? 如前所述,顺序内存访问模式的好处有很多。 由连续页面序列组成的空间可防止碎片化,从而优化 TLB 的使用并避免页面目录遍历。 当然,连续内存也有利于高速缓存的利用。 因此,最好提前预留一些更大的空间,即使我们现在不需要它。

  了解页面状态后,我们可以查看 Windows 进程内存分为哪些类别(图 2-18 以图形方式将这些指标之间的关系描述为重叠集):

  • 工作集——这是当前驻留在物理内存中的虚拟地址空间的一部分。 这意味着它可以进一步分为:
    • 专用工作集 - 由物理内存中的已提交(专用)页面组成
    • 可共享工作集 - 由所有可共享页面组成(无论它们是否实际共享)。
    • 共享工作集 - 由实际上与其他进程共享的可共享页面组成。
    • 私有字节 - 所有提交的(私有)页面 - 在物理内存和分页内存中。
    • 虚拟字节 - 提交(私有)和保留内存
    • 分页字节 - 存储在页面文件中的虚拟字节的一部分。

在这里插入图片描述
图 2-18。 Windows 进程中不同内存集之间的关系

  很复杂,不是吗? 也许现在我们意识到“多少内存实际占用了我们的.NET 进程”这个问题的答案并不那么明显。 我们要求哪些指标? 假设最重要的指标是私有工作集,因为它显示了我们的进程对最重要的物理 RAM 消耗的实际影响。 您将在下一章中了解如何监控这些指标。 我们还将了解任务管理器实际上将什么显示为进程的内存列。

  由于其内部结构,当 Windows 为进程保留内存区域时,它会考虑以下限制 - 区域起始及其大小都必须是系统页面大小(通常为 4kB)和所谓的分配的倍数 粒度(通常为 64kB)。 这实际上意味着每个保留区域的起始地址是 64kB 的倍数,大小是 64kB 的倍数。 如果我们要少分配,提醒将无法访问(不可用)。 因此,块的正确对齐和大小对于不浪费内存至关重要。

  让我们通过一个例子来说明它。 用于它的简单代码如清单 2-7 所示。 它从提供的 baseAddress 和指定的 blockSize(以字节指定)开始分配虚拟内存页。 VirtualAlloc 函数返回最终分配的页面的地址 ptr。

清单 2-7。 通过虚拟 API 的页面分配代码,显示页面和分配粒度陷阱的插图

IntPtr ptr = DllImports.VirtualAlloc(new IntPtr(baseAddress),
	 new IntPtr(blockSize),
	 DllImports.AllocationType.Reserve,
	 DllImports.MemoryProtection.ReadWrite);

  在图 2-19 中,我们看到了针对几种不同场景调用此代码的结果。 在图 2-19a 中,有一个尚未使用的页面,它从地址 0x9B0000 开始。 图 2-19b 显示了一个典型的、直观的情况——我们在一个特定的、正确对齐的地址处保留了 64kB 的内存(单页大小)。 结果,我们在该地址下获得了这 64kB 的保留内存(ptr 将为 0x9B0000)。 图 2-19c 显示了非常相似的情况。 当使用适当的基地址保留 4kB 时,整个分配粒度块已被保留,但其余部分(60kB)被标记为不可用。 这段记忆被浪费了。 现在没有办法重用它。 我们可以在 VMMap 工具中发现这种情况,我们将在下一章中学习。

  图 2-19d 说明了块大小不是页大小的倍数的情况——它被四舍五入到最接近的倍数。 因此,即使我们想分配 6kB,8kB 也提供给了我们。 显然,剩下的 56kB 再次无法使用。

  类似的情况说明了图 2-19e,其中基地址移动了 17kB (0x9B4400),我们想要分配 4kB。 因此,理论上,只需要两页。 但在这种情况下,VirtualAlloc 仍然返回整个块的分配粒度舍入起始地址(0x9B0000),而不是我们提供的作为基地址的值考虑到所有这些,最坏的情况是在接近末尾保留内存 分配粒度块,如图 2-19f 所示。 在这里,即使我们只想分配 8kB,两个 64kB 的块也被消耗了,几乎一半的内存是不可用的。

在这里插入图片描述
  图 2-19。 从上到下:(a) 在任何操作之前释放单页,(b) 保留 64 kB,基地址为 0x9B0000(64kB 的倍数),© 保留 4kB(单页),基地址为 0x9B0000(64kB 的倍数) ), (d) 保留 6 kB(超过单页大小),基地址为 0x9B0000(64kB 的倍数),(e) 保留 4 kB(单页),基地址未对齐 2kB (0x9B0800),(f) 保留 8 kB (两页)基地址非常不对齐 2kB (0x9AF000)

  所有这些都是为了向我们展示关注正确的页面对齐是多么重要。 虽然我们不会每天在虚拟 API 级别管理内存,但这些知识可以帮助我们理解 CLR 代码中对对齐的关注。 如果我们将来要编写这样的低级代码,这些知识当然是必需的。

细心的读者可能会问为什么分配粒度是64kB而页面大小是4kB? 微软员工 Raymond Chen 在 2003 年回答过这个问题【为什么地址空间分配粒度是 64K? - https://blogs.msdn。 microsoft.com/oldnewthing/20031008-00/?p=42223]。 和往常一样,在这种情况下,答案非常有趣。 这种分配粒度主要是历史原因造成的。 当今操作系统的整个系列的内核都可以追溯到早期 Windows NT 内核的根源。 它支持多种平台,包括 DEC Alpha 架构。 而正是这种适应的需要,才引入了这样的限制。 并且由于发现它不会对其他平台造成滋扰,因此通用内核基本代码的优势超过了针对其中一个平台进行定制的劣势。 您可以在上述文章中找到在该平台上获得如此价值的详细原因。

Windows Memory Layout(Windows 内存布局)

  现在让我们深入了解在 Windows 上运行并执行 .NET 应用程序的进程。 一个进程包含一个默认进程堆(主要由内部 Windows 函数使用)和任意数量的可选堆(通过堆 API 创建)。 此类可选堆的一个示例是由 Microsoft C 运行时创建的堆,如前所述,由 C/C++ 运算符使用。 有三种主要的堆类型:

  • 普通 (NT) 堆 - 由普通(非通用 Windows 平台 - UWP)应用程序使用。 它提供管理内存块的基本功能。
  • 低碎片堆——普通堆功能之上的附加层,用于管理不同大小的预定义块中的分配。 这可以防止小数据碎片化,此外,由于内部操作系统优化使得这种访问速度稍快。
  • 段堆 - 由通用 Windows 平台应用程序使用,它提供更复杂的分配器(包括类似于上面提到的低碎片分配器)。

  正如在一般进程内存布局的情况下提到的,虚拟地址空间分为两部分,高地址由内核占用,低地址由用户(程序)占用。 如图 2-20 所示(左侧为 32 位,右侧为 64 位)。 在 32 位机器上,根据大地址标志,用户空间是较低的 2 或 3 GB。 在支持 48 位寻址的现代 64 位 CPU 上,用户空间和内核空间都有 128 TB 的可用虚拟内存(以前版本为 8TB - Windows 8 和 Server 2012)。

  通过一些近似,我们可以说 Windows 上 .NET 程序的典型用户空间布局如下:

  • 前面提到的默认堆
  • 大多数图像(exe、dll)都位于高地址
  • 线程堆栈(在上一章中提到)主要位于相当低的地址,但可以位于任何位置。 进程中的每个线程都有自己的线程堆栈区域。 这包括使用本机系统线程机制的 CLR 线程
  • 由 CLR 管理的 GC 堆,用于存储我们创建的 .NET 对象(它们是 Windows 命名法中的常规页面,由 Virtual API 获取)
  • 由 CLR 出于其内部目的管理的各种私有 CLR 堆。 我们将在接下来的章节中更详细地研究它们
  • 当然还有相当多的空闲虚拟地址空间,包括虚拟地址空间中间某处的千兆字节和太字节数量级的巨大块(取决于体系结构)。

在这里插入图片描述
图 2-20。 运行 .NET 托管代码的 Windows 进程的 x86/ARM(32 位)和 x64(64 位)虚拟内存布局

  Windows 上的初始线程堆栈大小(包括保留的和最初提交的)取自可执行文件(通常称为 EXE 文件)标头,但也可以在通过 Windows API 手动创建线程时通过 CreateThread 等方法指定。

  .NET 运行时如何计算栈的默认大小非常复杂。 对于典型的 32 位编译,默认值为 1 MB,对于典型的 64 位编译,默认值为 4 MB。 堆栈数据相当小,调用堆栈通常也很浅(数百个嵌套调用并不常见)。 这使得 1 或 4 MB 成为一个很好的默认值。

  但是,如果您曾经遇到过 StackOverflowException,那么您就是撞到了这个障碍。 即便如此,这很可能是由于我们的无限递归错误,这显然会使用任意大的堆栈空间。 如果我们以某种方式开发我们的程序,出于某种原因想要在栈上存储大量数据,我们可以修改二进制文件的标头。 .NET 可执行文件被解释为常规可执行文件,因此此更改将由操作系统反映出来。 为此,我们将在第 4 章中增加此栈大小限制。

出于安全原因,引入了地址空间布局随机化(ASLR)机制,这使得图 2-20 中显示的所有布局只是示意图,因为所有组件(图像、堆、堆栈)都随机放置在整个地址空间上,不会重复任何 攻击者可以使用的常见模式。

  我希望这样的鸟瞰图能让我们更好地理解 CLR 内存在整个 Windows 生态系统中的位置。 在详细描述 CLR 进程布局时,我们将再次参考这些知识。

Linux Memory Management(Linux内存管理)

  直到不久前,一本关于 .NET 的书中专门介绍 Linux 的章节最多只能作为 Mono 项目的参考。 但时代在变。 随着 .NET Core 环境的出现,不再可能将此平台与非 Windows 系统分开。 此外,您可以预见在非 Windows 机器上运行 .NET 的人气会越来越高。 我们将大量关注 CoreCLR,.NET Core 的运行时实现。 但是,由于 Linux 将成为越来越受欢迎的替代方案,因此我们也需要对这个系统进行一些了解。 由于 Linux 使用相同的硬件技术,包括页面、MMU 和 TLB,因此前面小节的描述涵盖了很多知识。 在这里,我们将只关注我们感兴趣的差异。随着越来越多的人不得不了解这个新的 .NET 环境,我相信至少了解一些 Linux 基础知识也是非常有益的。

  流行和最常用的 Linux 操作系统发行版也使用虚拟内存的概念。 它们每个进程的限制也非常相似,总结在表 2-6 中。

表 2-6。 Linux 上的虚拟地址空间大小限制(用户/内核)
在这里插入图片描述
  与 Windows 一样,Linux 中的基本构建块是页面,通常也是 4kB。 该页面可以处于下面列出的三种不同状态:

  • free - 尚未分配给任何进程或系统本身。
  • allocated - 分配给一个进程。
  • shared - 为进程保留,但可以与其他进程共享。 这通常意味着系统范围的库和资源的二进制图像和内存映射文件。

  与 Windows 操作系统相比,这可以更简单、更清晰地查看进程的内存消耗情况。 如您所见,与 Windows 相比,隐式页面保留阶段缺失,但它仍然显式存在。 Linux 内置了一个惰性分配机制来处理它。 当在 Linux 上分配内存时,它被视为已分配但没有分配物理资源(因此这就像 Windows 上的预留)。 实际资源分配(消耗物理内存)不会发生,直到通过访问此特定内存区域实际需要它。 如果你想在性能关键场景中主动准备这样的页面,你可以通过内存访问“触摸”它们,比如读取其中的至少一个字节。

  知道了可能的页面状态,我们就可以看看Linux上一个进程内存分为哪些类别。 对此有很多困惑。 许多基于 Linux 的工具对此主题的看法略有不同。 这是我能够准备的最通用的分类。 可以根据以下术语测量进程内存利用率:

  • virtual(被某些工具标记为 vsz)- 迄今为止由进程保留的虚拟地址空间的总大小。 在流行的“顶级”工具中,它是一个 VIRT 列。

  • resident 驻留(Resident Set Size,RSS)——当前驻留在物理内存中的页面空间。 一些驻留页面可以在进程之间共享(也有文件支持或匿名的)。 因此,这对应于 Windows 平台上的“工作集”度量。 在“顶部”工具中,这称为 RES 列。 进一步可以拆分为:

    • 私有驻留页面——这些都是为这个进程保留的匿名驻留页面(由 MM_ANONPAGES 内核计数器指示)。 这在某种程度上对应于 Windows 的“私有工作集”度量。
    • 共享驻留页面 - 这些都是文件支持的(由 MM_FILEPAGES 内核计数器指示)和进程的匿名驻留页面。 对应“共享工作集”。 在“顶部”,这被称为 SHR 内存
  • private - 进程的所有私有页面。 在“顶部”工具中,这是一个数据列。 请注意,这是保留内存的指示器,并没有说明其中有多少已经被访问(“接触”)并因此成为常驻内存。 对应于 Windows 上的“私有字节”。

  • swapped - 已存储在交换文件中的虚拟内存的一部分

图 2-21 以图形方式将这些指标之间的关系描述为重叠集。

在这里插入图片描述
图 2-21。 Linux 进程中不同内存集之间的关系

  相当复杂。 就像 Windows 一样,什么在消耗我们的 .NET 进程的内存这个问题的答案并不简单。 最明智的做法是查看“私有驻留页面”测量值,因为它显示了进程对我们宝贵的 RAM 资源的实际使用情况。

在 Windows 上,分配粒度为 64kB; 在 Linux 上,它只是页面大小的限制,在大多数情况下为 4kB。

Linux Memory Layout (Linux内存布局)

  Linux 进程的内存布局与 Windows 非常相似。 对于 32 位版本,用户空间为 3GB,内核空间为 1GB。 可以在内核构建时使用可配置的 CONFIG_PAGE_OFFSET 参数更改此拆分点。 对于 64 位,拆分是在与 Windows 类似的地址处进行的(见图 2-22)。

在这里插入图片描述
图 2-22。 Linux 上进程的 x86/ARM(32 位)和 x64(64 位)虚拟内存布局

  与Windows类似,系统提供了对内存页进行操作的API。 它包含了:

  • mmap - 直接操作页面(包括文件映射、共享映射和普通映射,以及与任何文件无关但用于存储程序数据的匿名映射)。
  • brk/sbrk - 这是最接近 VirtualAlloc 方法的等价物。 它允许我们设置/增加所谓的“程序中断”,这实际上意味着增加堆大小。

  众所周知的 C/C++ 分配器根据分配大小使用 mmap 或 brk。 这个阈值可以通过 mallopt 和 M_MMAP_THRESHOLD 设置来配置。 正如我们稍后将看到的,CoreCLR 使用带有匿名私有页面的 mmap 方式。

  Linux 和 Windows 之间的线程堆栈处理有一个显着差异。 因为没有两级内存预留,栈只是按需扩充。 没有预先保留相应的内存页。 并且由于下一页是根据需要创建的,因此线程栈不是一个连续的内存区域。

Operating System Influence(操作系统影响)

  CoreCLR 中包含的垃圾收集器的跨平台版本是否考虑了内存管理方面的差异? 一般来说,GC 代码非常独立于平台,但由于显而易见的原因,在某些时候它必须到达系统调用。 两个系统中的内存管理子系统以类似的方式工作——它基于虚拟内存、分页和类似的内存分配方式。 当然,虽然调用的系统 API 不同,但从概念上讲,在代码上并没有具体的区别,除了我现在要描述的两种情况。

  第一个区别已经提到了。 Linux 没有分两步分配内存的方法。 在Windows中,我们可以先使用系统调用来预留一个大的内存块。 这将是在不实际占用物理内存的情况下创建适当的系统结构。 仅在必要时,我们才将第二阶段提交我们感兴趣的内存范围。 因为 Linux 没有这种机制,内存只能在没有“保留”的情况下分配。 但是,需要一个系统 API 来模仿这种两步工作方式。 为此目的使用了一个流行的技巧。 在 Linux 上,“保留”是通过使用访问模式 PROT_NONE 分配内存来实现的,这实际上意味着无法访问该内存。 然而,在这样的保留区域中,我们可以再次分配具有正常权限的特定子区域,从而模拟“提交”内存。

  第二个区别是所谓的内存写监视机制。 正如我们将在后面的章节中看到的,垃圾收集器需要跟踪哪些内存区域(页面)已被修改。 为此,Windows 提供了一个方便的 API。 通过分配页面,我们可以设置 MEM_WRITE_WATCH 标志。 然后,使用 GetWriteWatch 系统调用,我们可以检索已修改页面的列表。 在 CoreCLR 上工作时,发现 Linux 系统中没有具有类似 API 的可靠机制。 出于这个原因,这个逻辑必须被移动到一个写屏障(机制在第 5 章中详细解释),它在运行时支持而无需操作系统支持。

NUMA 和 CPU 组

  在硬件和操作系统的上下文中,还有一个更重要的内存管理拼图值得一提。 对称多处理 (SMP) 是指具有连接到共享主内存的多个相同 CPU 的计算机。 它们由单个操作系统控制,该操作系统可能会或可能不会平等对待所有处理器。 正如我们所知,每个 CPU 都有自己的一组 L1 和 L2。 换句话说,每个 CPU 都有一些专用的本地内存,访问速度比其他区域快得多。 运行在不同 CPU 上的线程和程序可能会共享一些数据,这显然不是最佳情况,因为通过 CPU 互连共享数据会导致明显的延迟。 这就是非统一内存架构 (NUMA) 发挥作用的地方。 这意味着从性能角度来看,并非所有共享内存都是相同的。 软件(主要是操作系统,但也可以是程序本身)应该是 NUMA 感知的,以便更喜欢使用那些本地内存而不是更远的内存。 这种配置如图 2-23 所示。

在这里插入图片描述
图 2-23。 简单的 NUMA 配置由八个处理器组成,分为两个 NUMA 节点

  这种访问非本地内存的额外开销称为 NUMA 因子。 因为将每个 CPU 点对点连接起来会非常昂贵,所以 CPU 通常会连接到两个或三个其他 CPU。 在访问远程内存的糟糕情况下,必须在处理器之间进行一些跳跃。 CPU 越多,如果不仅使用本地内存,NUMA 因素就越相关。 还有一些系统采用某种混合方法,其中处理器组有一些共享内存,并且这些组之间的内存不均匀,它们之间有很大的 NUMA 因子。 这实际上是 NUMA 感知系统中最常见的方法。 CPU 被分组为更小的系统,称为 NUMA 节点。 每个 NUMA 节点都有自己的处理器和内存,由于硬件组织,NUMA 系数很小。 NUMA 节点当然是相互连接的,但它们之间的传输意味着更大的开销。

  操作系统和程序代码的 NUMA 感知的主要要求是坚持包含执行它的 CPU 的 NUMA 节点本地 DRAM 上的进程内存。 但如果某些进程消耗的内存比其他进程多得多,这可能会导致不平衡状态。 在 Linux 中,可以控制每个进程的 NUMAawareness 行为 - 它是否应该只坚持使用本地内存(对小进程有利)或尝试更均匀地分配它(对大进程来说很大)。 在 Windows NUMA 上,程序开发时必须考虑意识。

  问题来了,.NET CLR NUMA 感知吗? 简单的答案是肯定的,它是! NUMA 感知理论上可以通过运行时部分配置中的 GCNumaAware 设置禁用,但目前它没有被公开。

  然而,清单 2-8 中显示了另外两个与所谓的处理器组相关的重要应用程序设置。 在具有超过 64 个逻辑处理器的 Windows 系统上,它们被分组到上述 CPU 组中。

  我们可以在基于 Windows 的 .NET 运行时中启用 CPU 组感知(参见清单 2-8),这在具有超过 64 个逻辑处理器的环境中显然很重要。

清单 2-8。 .NET 运行时处理器组的配置

<configuration>
	<runtime>
	<Thread_UseAllCpuGroups enabled="true"/>
	<GCCpuGroup enabled="true"/>
	<gcServer enabled="true"/>
	</runtime>
</configuration>

  GCCpuGroup 设置指定垃圾收集器是否应通过在所有可用组中创建内部 GC 线程来支持 CPU 组,以及它是否在创建和管理堆时考虑所有可用核心。

  Thread_UseAllCpuGroups 指定 CLR 是否应该在所有 CPU 组中分配正常的托管线程(执行我们的代码)。 这两个选项应该与 gcServer 设置同时启用。

概括

  在本章中,我们已经走了很长一段路。 我们已经简要地确定了最重要的硬件和系统内存管理机制。 我希望这些知识,连同上一章的理论介绍,能够让您拥有更广阔的背景:我们在 .NET 中进行内存管理时所处的背景。 我也希望,如果你还没有,你已经对这个话题的复杂性有了一些尊重。 是的,我们所讨论的只是 .NET 中垃圾收集器的基础! 在后续的每一章中,我们将进一步远离一般硬件和理论陈述。 我们将更深入地了解 .NET 环境。

规则 2 - 应避免随机访问,应鼓励顺序访问

适用性:主要是低级的、面向性能的代码。

理由:由于许多级别的内部机制,包括 RAM 和处理器缓存设计,顺序访问肯定是更优化的。 与缓存相比,DRAM 需要更多的 CPU 周期才能到达远程内存。 处理器将数据加载到称为高速缓存行的 64 字节块中。 每次访问小于 64 字节的内存都是一种昂贵的资源浪费。 更重要的是,随机访问模式使得缓存预取机制不太可能起作用。 处理器没有机会通过随机访问内存发现任何可预测的模式。 重要的是,我们所说的随机性并不是指完全随机性,而是指它不是与任何可检测模式兼容的有序访问这一事实。

如何应用:显然,随机访问的对立面是顺序访问,所以尽量使用它。 如果您对大量数据进行操作,您可能需要考虑将它们打包到负责内存连续性的数组中。 迭代双链表可以是典型的非结构化访问的示例。 在第 13 章描述所谓的面向数据的设计时,我们将仔细研究内存访问的这一方面。

规则 3 - 改进空间和时间数据局部性

适用性:主要是低级、面向性能的代码
理由:空间和时间局部性是缓存的支柱。 如果存在,缓存将得到有效使用并有助于实现更好的性能。 相反。 如果我们干扰时间和空间的局部性,我们将导致生产力的显着下降。

如何申请:设计您使用的数据结构,以照顾数据的局部性并及时最大限度地提高其可重用性。 正如我们在给出的示例中看到的那样,分布式、随机访问数据在性能方面非常不利,可能会慢好几倍。 有时,在程序的非常高级和高性能的部分,这意味着应用将在第 13 章面向设计的设计中介绍的非直观更改。有时它只归结为确保我们的数据结构相当小,预分配, 并反复使用。

规则 4 - 消耗更高级的可能性

适用性:极低级、面向性能的代码。

理由:.NET 运行时环境是以最通用的方式编写的。 这是为了确保在各种可能的情况下正常运行。 然而,在编写我们的应用程序时,我们完全了解我们的需求。 我们可能需要编写性能极快的内存相关代码片段。 如果是这样,我们可能会考虑使用一些更高级的操作系统特定机制。 此类机制可能需要全球约 0.0001% 的 .NET 开发人员。 如果您正在编写与内存相关的库,如序列化器、消息缓冲区或任何类型的极快事件处理器 - 也许您可以通过使用此处提到的一些系统的低级 API(如非临时内存访问)而受益。

如何申请:这将需要编写非常困难的代码。 这段代码很难管理,而且可能没有人愿意维护它。 除了你。 因为它会使用操作系统的低级API,所以在更新或更改操作系统版本后也可能会出现问题。 您也不太可能需要这种低级内存管理,因为在编码时需要格外小心。 而且很容易犯错误,这不仅不会提高性能,反而会大大降低性能。

仔细阅读本书。 然后仔细阅读有关其内部结构的特定操作系统书籍。 然后尝试使用高级机制,如大页面、非临时操作以及本章中提到的其他机制。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值