Skip to content

Commit

Permalink
doc:windows memory management and layout
Browse files Browse the repository at this point in the history
  • Loading branch information
MarsonShine committed Feb 22, 2025
1 parent a1d156f commit f7a1b94
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 0 deletions.
90 changes: 90 additions & 0 deletions NET-Memory-Management-For-BCPS/Low-Level-Memory-Management.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,96 @@ public static long DoFalseSharingTest(int threadsCount, int size = 100_000_000)
- 当应用程序启动时,操作系统会将相应的二进制文件从磁盘映射到地址空间。一些只读数据可以在多个进程间共享,例如可执行的程序集代码。
- 这样的图示有助于对地址空间的整体布局进行高层次的理解,但实际情况更为复杂。接下来,让我们深入了解.NET运行时支持的两个主要操作系统:Windows和Linux。

#### Windows内存管理

虚拟地址空间的大小与系统版本有关。相关限制的摘要见表2-4。
表2-4. Windows上的虚拟地址空间大小限制(用户/内核)

| 处理器类型 | Windows(32位) | Windows 8/Server 2012 | Windows 8.1+/Server 2012+ |
| ---------- | --------------- | --------------------- | ------------------------- |
| 32 位 | 2/2 GB | 2/2 GB | 2/2 GB |
| 32 位(*| 3/1 GB | 4 GB/8 TB | 4 GB/128 TB |
| 64 位 | - | 8/8 TB | 128/128 TB |

*大地址感知标志(通常称为/3GB开关)

> 注意:有一种机制称为地址窗口扩展(Address Windowing Extensions,AWE),它允许您访问比这里列出的更多物理内存,并通过“AWE窗口”将部分内存映射到虚拟地址空间。这在32位环境中特别有用,可以克服每个进程2或3 GB的限制。然而,这一特性在这里并不适用,因为CLR并未使用它。
在32位系统末期,单个进程虚拟内存大小的限制已变得非常棘手。在大型企业应用中,限制到2 GB(在扩展模式下为3 GB)会带来问题。一个经典的例子是在IIS上托管的ASP.NET web应用程序,运行在Windows Server的32位机器上。如果达到此限制,唯一的选择就是重启整个web应用程序。这迫使大型web系统进行横向扩展,创建多个处理较少流量的服务器实例,从而消耗更少的内存。如今,64位系统已主导市场,有限的虚拟地址空间不再是问题。但请注意,即使在64位Windows服务器上,32位编译的程序的虚拟地址空间限制仍为4 GB。

Windows内存管理器提供两种主要API:

- 虚拟内存:这是一个低级API,直接操作地址空间以保留和提交页面。`VirtualAlloc``VirtualFree`是CLR使用的函数。
- 堆:这是一个更高级的API,提供分配器(请参考第1章),由C和C++运行时实现`malloc/free``new/delete`函数。此层还包括`HeapAlloc``HeapFree`等函数。

由于CLR有自己的分配器实现来创建.NET对象(详细内容将在第6章中介绍),因此仅使用虚拟API。简单来说,CLR向操作系统请求额外的页面,并自行处理这些页面内对象的分配。CLR通过C++ new操作符(基于Heap API构建)创建自己的内部本机数据结构。

在Windows上,虚拟内存API的工作分为两个步骤。首先,通过调用`VirtualAlloc`并传入`MEM_RESERVE``PAGE_READWRITE`参数,您可以在地址空间中保留一个连续的内存范围(因为您希望能够在此存储和读取数据)。Windows内存管理器会返回所保留内存范围在地址空间中的起始地址。请注意:您尚不能访问这块内存!其次,当你需要在那段内存中读取或写入时,必须从保留的范围中提交所需的页面。无需提交整个范围,仅需提交必要的页面。这正是CLR的操作方式:在地址空间中一次性保留大范围,然后在需要分配更多对象时提交页面。当首次访问已提交的页面时,内存管理器会确保其被初始化为零。

当对象被收集并检测到足够的空闲空间时,调用带有`MEM_DECOMMIT`参数的`VirtualFree`来取消相应页面的提交,将内存返还给Windows内存管理器。具体细节将在第10章中阐述。

接下来你可能想要了解的是如何测量应用程序的“内存”消耗。根据之前的解释,存储对象的内存称为进程的私有已提交内存,而共享已提交内存则计入可执行文件中存储的只读数据,如程序集代码,在此情境下并不重要。

此外,私有页面也可以被锁定,这使得它们保持在物理内存中(不会转移到页面文件),直到被显式解锁或应用程序结束。锁定可以提高程序中性能关键路径的效率。我们将在第15章中看到一个在自定义CLR主机中利用页面锁定的示例。

保留和提交的页面由进程通过上述的`VirtualAlloc/VirtualFree``VirtualLock/VirtualUnlock`方法调用进行管理。值得注意的是,尝试访问空闲或保留的内存将导致访问违规异常,因为这些内存尚未映射到物理内存。

> 注意:为什么会有人发明这样的双向获取内存的过程?如前所述,顺序内存访问模式因多种原因而优越。连续页面组成的空间可防止碎片化,从而优化TLB的使用并避免页面目录的遍历。连续内存对缓存利用也有益。因此,提前保留一些较大的空间是明智的,即使我们现在不需要。这通常是操作系统实现线程栈的方式:整个栈在地址空间中被保留,而在执行更深层次的方法调用时,页面一个接一个地被提交,以满足更多参数和局部变量的需求。
图2-17形象地描绘了不同“内存集合”之间的关系,表现为重叠的集合:

- 工作集(Working set):这是当前驻留在物理内存中的虚拟地址空间的一部分。它可以进一步细分为
- 私有工作集:由物理内存中的已分配(私有)页面组成
- 共享工作集:由与其他进程共享的页面组成
- 私有字节:所有已分配(私有)页面——包括物理内存和分页内存
- 虚拟字节:在地址空间中保留的内存(包括已分配和未分配的)
- 分页字节:存储在页面文件中的虚拟字节的一部分

![](asserts/2-17.png)

图2-17. Windows中一个进程内不同内存集合之间的关系

这很复杂,不是吗?也许现在你应该意识到,关于“我们的.NET进程实际占用多少内存”的问题并不那么明显。你应该关注哪些指标呢?人们错误地认为最重要的指标是私有工作集,因为它显示了该进程对物理RAM消耗的实际影响。然而,在内存泄漏的情况下,私有字节可能会增长,而私有工作集却未必会增长。你将在下一章了解如何监控这些指标。你还将理解任务管理器中进程的内存列实际显示了什么。

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

虽然你在日常工作中并不直接管理虚拟API级别的内存,但这些知识可以帮助你理解CLR代码中对齐的重要性。细心的读者可能会问,**为什么分配粒度是64 kB,而页面大小是4 kB**。微软员工Raymond Chen在2003年对此问题作出了回应[为什么地址空间分配粒度是64K? – https://devblogs.microsoft.com/oldnewthing/20031008-00/?p=42223]。如同在这种情况下的常见情况,答案非常有趣。分配粒度主要是出于历史原因。今天所有操作系统家族的内核可以追溯到早期Windows NT内核的根源。它支持多个平台,包括DEC Alpha架构。正是由于支持平台的多样性,才引入了这样的限制。由于发现这对其他平台并没有造成困扰,因此更倾向于使用通用的内核基础代码。你将在前述文章中找到更详细的解释。

#### Windows内存布局

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

- 正常(NT)堆:用于普通(非通用Windows平台 - UWP)应用程序,提供基本的内存块管理功能。
- 低碎片堆(Low-fragmentation heap):在正常堆功能之上增加的一层,管理预定义大小的分配块,防止小数据的碎片化,并由于内部操作系统优化,使访问速度稍快。
- 段堆(Segment heap):用于通用Windows平台应用程序,提供更复杂的分配器(包括前面提到的低碎片分配器)。

如前所述,进程地址空间布局分为两部分,上部地址由内核占用,底部地址由用户(程序)占用。这在图2-18中显示(左侧为32位,右侧为64位)。在32位机器上,根据大地址标志的值,用户空间位于下部的2或3 GB。在支持48位寻址的现代64位CPU上,用户和内核空间各自都有128 TB的虚拟内存可用(在之前的版本 - Windows 8和Server 2012中为8 TB)。
大致而言,Windows上.NET程序的典型用户空间布局如下:

- 前面提到的默认堆。
- 大多数映像(exe,dll)位于高地址。
- 线程栈(在前一章中提到)位于任意位置。每个线程在进程中都有自己的线程栈区域。这包括CLR线程,它们只是包装了本地系统线程。
- 由CLR管理的GC堆,用于存储您创建的.NET对象(在Windows术语中,它们是常规页面,由Virtual API保留和提交)。
- 各种用于内部目的的私有CLR堆。我们将在后续章节中更详细地研究它们。
- 当然,在虚拟地址空间的中间还有大量的自由虚拟地址空间,包括几个GB和TB数量级的巨大块(具体取决于架构)。

![](asserts/2-18.png)

图2-18. Windows上运行.NET托管代码的进程的x86/ARM(32位)和x64(64位)虚拟地址空间布局

Windows上初始线程栈大小(包括保留和初始提交)来源于可执行文件(通常称为EXE文件)的头部。对于手动创建的线程,栈大小也可以通过手动调用`Windows CreateThread API`进行指定。

.NET运行时计算默认栈大小的方式相当复杂。对于典型的32位编译,默认值为1 MB,对于典型的64位编译,默认值为4 MB。栈数据相对较小,调用栈通常也相对较浅(数百层嵌套调用相对不常见),因此1或4 MB是一个合适的默认值。
然而,如果您曾遇到过StackOverflowException,您就碰到了这个障碍。即便如此,这很可能是由于编程错误,例如无限递归。如果出于某种原因您编写了一个需要在栈上存储大量数据的程序,您可以修改二进制文件的头部。.NET可执行文件被解释为常规可执行文件,因此此更改将被操作系统反映。我们将在第4章中为此目的增加栈大小限制。

> 出于安全原因,引入了地址空间布局随机化(ASLR)机制,使图2-18中显示的所有布局仅为示意图。该机制使所有组件(二进制映像)在整个地址空间内随机放置,以避免重复任何可能被攻击者利用的常见模式。
我们希望这样的鸟瞰视图能让您更好地理解CLR在整个Windows生态系统中的内存管理。我们将在详细描述CLR内存布局时再次提到这些知识。

#### Linux内存管理

#### Linux内存布局

## NUMA 和 CPU 组


Expand Down
Binary file added NET-Memory-Management-For-BCPS/asserts/2-17.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added NET-Memory-Management-For-BCPS/asserts/2-18.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit f7a1b94

Please sign in to comment.