当前位置:   article > 正文

[C#] .net 内存管理[9] - .NET 基础知识(1)_net编译neicun

net编译neicun

.NET 基础知识

  虽然我们只进入了第四章,但关于内存管理的各个方面,我们已经经历了相当长的旅程。 对它们进行了一般性讨论,以便对该主题进行更具理论性的介绍。 对 .NET 的具体引用非常罕见,毕竟这就是本书的主题。 是时候改变这个频率了。 从本章到本书结束,.NET 将一直伴随着我们。 在本章中,我们将从更广泛的角度来看待它,我们将学习它背后的一些机制,并且我们将开始深入研究与它如何管理内存相关的主题。 我强烈建议您在继续阅读本章之前先从前三章中获取知识,但将其视为一种可选方法。 从现在开始,随着我们越来越深入地了解 .NET,我还将假设一些有关 x86/x64 平台汇编语言的基本知识。 如果您需要更新一些知识,请阅读一本优秀的书,例如 Daniel Kusswurm 所著的《现代 X86 汇编语言编程》(Apress,2014 年)。

  如果.NET Framework是一个男人,他现在就已经上初中了,几年后慢慢开始准备高考。 换句话说,它是一个已经开发和使用了大约15年的产品。 在此期间,丰富的附带库集合和运行时环境本身都发生了显着的发展。 所有 .NET 开发人员都必须熟悉基础知识 - C# 的标准库和语法知识 - C# 是 .NET 环境中使用的主要编程语言(或其他语言,例如 VB.NET 不断失去人气,而 F# 不断获得流行)。 这是我们的“日常面包”。 然而,随着年龄的增长,或者随你的喜好,随着经验的增长,常常会反思值得了解更多。 那么让我们多了解一点吧!

  请注意,本书重点关注内存管理,仅简要提及其他与 .NET 相关的主题。 因此,例如,不要期望详细描述 C# 语言功能或解决多线程问题。 还有许多其他专门针对它们的优秀书籍和在线材料。

.NET 版本

  .NET 环境并不像乍一看那样同质。 它通常与最流行的 .NET Framework 版本相关联,该版本从 1.0 版运行,经过 2.0、3.5 或 4.0 等版本,一直到当前版本 4.7.2。 但是,当我们谈论 .NET 环境时,您现在确实可以想到它的版本和实现的许多丰富性。 实现如此丰富的一个重要方法是标准化。 从一开始,整个 .NET 概念就基于称为公共语言基础设施 (CLI) 的规范。 这一基本技术标准(2003 年标准化为 ECMA 335 和 ISO/IEC 23271)描述了代码和运行时环境的概念,允许其在不同的机器上使用而无需重新编译。 我将在本章中多次提到它,因为没有比这更好的事实来源了。

  描述 CLI 的所有组件,包括所有实现变化以及它们之间的差异,是非常诱人的。 然而,我们将主要关注它们如何影响我们所关注的主题。 现在让我们看一下内存管理和垃圾收集上下文中的各种 .NET 变体:

  • .NET Framework 1.0 - 4.7.2 - 自 2002 年开发以来,是我们众所周知的商业且最成熟的产品。 它已经存在多年,因此垃圾收集器的核心已经在各个版本中得到开发和改进。 多年来,这个主题被视为一个黑匣子,在发布新的 .NET 版本时或多或少随意地描述过。 由于.NET Framework的商业运行时代码是封闭的,这些机制到底是如何运作的,我们主要可以通过微软自己提供的信息来了解。 这些信息非常详细,使我们能够理解和诊断应用程序中的内存问题。 但开发人员仍然有点不满意,特别是当你面对源代码的开放性时,例如 Java。

  • 共享源 CLI(也称为 Rotor)- 于 2002 年(版本 1.0)和 2006 年(版本 2.0)发布,用于教育和学术目的的运行时实现。 它从来没有打算运行生产代码。 它让您了解 CLR 的众多实现细节。 甚至还有一本很棒的书,即由 David Stutz、Ted Neward 和 Geoff Shilling 合着的《Shared Source CLI Essentials》(O’Reilly Media,2003 年),其中详细介绍了此版本。 然而,首先,它并没有完全实现一个“成熟”的.NET 2.0框架。 其次,不幸的是,它的实现有时与正确的 CLR 非常不同,尤其是在内存管理领域。 那里只实现了一个非常简化的垃圾收集器。

  • .NET Compact Framework - 自 Windows CE/Mobile 和 Xbox 360 时代以来的 .NET“移动”版本。 它的垃圾收集器与主版本有很大不同,并且更加简化,例如,它不包括生成概念(我们将在下一章中了解)。 不过,这已经是一个历史制度了,或许我们不必再担心了。 但在该框架的开发过程中我们吸取了很多经验教训,特别是因为针对运行 Windows CE 设备的各种处理器等平台的移植。 这就是我们所知的 CoreCLR 的概念起点。\

  • Silverlight - 一个 Web 浏览器插件,允许您像普通窗口应用程序一样运行应用程序。 由于 Microsoft 在 .NET 2.0 时代开始构建它,因此它基于该时期的运行时副本。 如果您仍然使用它,许多有关当前 .NET 的信息也将适用于此处。 只不过这必须是有关 .NET 2.0 较旧运行时版本的信息。 这是移植到 OSX 平台的运行时,为当前的 CoreCLR (.NET Core) 运行时提供了代码库。

  • .NET Core(其运行时称为 CoreCLR) - 开源版本的出现。 NET 已经发生了很大的变化。 从现在开始,就有了可用于生产的运行时代码,我们可以自己深入研究。 更重要的是,垃圾收集器代码实际上是从商业运行时代码复制而来的。 看来 .NET Core 可以慢慢开始超越 .NET Framework 的功能,而 .NET Framework 的更改将陆续“合并”回来。 .NET Core也是官方支持的跨平台解决方案。 它适用于 Windows、Linux 和 MacOS

  • Windows Phone 7.x、Windows Phone 8.x 和 Windows 10 Mobile - 系统的旧版本基于 .NET Compact Framework 3.7 中已知的简单内存管理。 Windows Phone 8.x 引入了内部 .NET 运行时的重大增强,它基于成熟的 .NET Framework 4.5 版本,继承了其垃圾收集器。

  • .NET Native - 一种允许 CIL 代码直接编译为机器代码的技术。 它基于名为 CoreRT(以前称为 MRT)的轻量级运行时。 他们与 .NET Core 共享垃圾收集器代码。

  • .NET Micro Framework - 针对小型设备的单独实现,具有开源代码。 最流行的应用程序是 .NET Gadgeeter,它包含自己的垃圾收集器的简化版本。 由于该解决方案的利基和业余爱好性质,我们不会在本书中讨论它。

  • WinRT - 一种向开发人员公开操作系统功能的新方法,它设置了用于构建以 JavaScript、C++、C# 和 VB.NET 语言提供的 Metro 风格应用程序的 API,并将取代 Win32。 它是用 C++ 编写的,实际上根本不是 .NET 实现。 但它是面向对象的,并且基于 .NET 元数据格式,因此它可能看起来像普通的 .NET 库(尤其是在 .NET 内部使用时)。

  • Mono - CLI 的完全独立的跨平台实现,具有自己的内存管理。 了解它对于理解 .NET 的主题并没有多大帮助。 然而,至少有两种非常流行的基于该技术的解决方案:Xamarin,用于编写移动应用程序的框架; 以及流行的游戏引擎Unity3D。 由于这些项目的受欢迎程度,我们有时会通过比较来看待 Unity。

  从上面的列表中可以看到一幅非常积极的图景 - 内存管理机制与当前使用的所有主要 .NET 平台非常相似(不是说 - 几乎相同) - .NET Framework、.NET Core 和 用于.NET Native。

本书基于.NET Core 2.1源代码,对.NET中垃圾收集器的内部机制进行了全面的解释。 正如我们提到的,此实现与 .NET Framework 的主要变体和移动变体有很大的融合。 因此,依赖 .NET Core 的源代码是一种非常有价值且全面的信息获取形式。 在下文中,当显示 .NET 源代码示例时,除非另有说明,我默认指的是 .NET Core 2.1 源代码。 我还参考了与运行时本身并行开发的所谓“运行时之书”开源文档,可从 https://github.com/dotnet/coreclr/blob/master/Documentation/botr/README.md 获取。 它包含有关运行时实现的许多有价值的信息。

  我们应该了解一些 .NET 内部结构才能充分理解内存管理主题。 然而,我们现在将通过省略本文中不需要的许多信息来研究它们。 还有许多其他有价值的资源,您可以在其中找到更多信息,包括 Jeffrey Richter 撰写的《CLR via C#》一书(Microsoft Press,2012 年); Pro .NET Performance,作者:Sasha Goldshtein(Apress,2012 年); 或编写高性能 .NET 代码,作者:Ben Watson(Ben Watson,2014 年)。

.NET Internals (.Net 内部结构)

  当用 C 或 C++ 编写程序时,编译器将其编译为可执行文件。 然后它可以直接在目标机器上执行,因为除了与操作系统配合的库之外,它还包含由处理器直接执行的二进制代码。

  另一方面,.NET 运行时环境有很多重要的职责,这些职责共同使整个事情完成它应该做的事情 - 执行我们编写的应用程序。 与用 C 或 C++ 编写的程序不同,当您用 C#、F# 或任何其他 .NET 兼容语言编写程序时,它会被编译为所谓的 CIL(通用中间语言)。 然后,公共语言运行时 (CLR) 使用此代码。 CLR 是所有托管魔法发生的地方。 在 CLR 之上,有整个 .NET 框架的更一般概念 - 包括所有标准库和工具(因此我们有各种 .NET 框架版本,可能包括也可能不包括运行时更改)。 CLR有几个职责,其中我们主要可以区分:

  • 即时编译器(JIT编译器)——它的功能是将CIL代码转换为机器代码。 这种执行托管代码的方式实际上是对本机系统机制的巧妙封装 - 例如内存管理包括线程堆栈和堆等等。
  • 类型系统- 负责类型控制和兼容性机制。 它由通用类型系统 (CTS) 和元数据(由反射机制使用)组成。
  • 异常处理 - 它负责用户程序级别和运行时本身的异常处理。 此外,这里还使用了 Windows SEH(结构化异常处理)机制中内置的本机机制和 C++ 异常
  • 内存管理(通常称为垃圾收集器) - 这是运行时的整个部分,用于管理运行时和我们的应用程序使用的内存。 显然,它的主要职责之一是负责自动释放不再需要的对象。

我们经常将这些职责分为两个主要部分:

  • 执行引擎 - 负责上面包含的大部分运行时职责,例如 JIT 编译和异常处理。 它在 ECMA-335 中被命名为虚拟执行系统 (VES),并被描述为“负责加载和运行为 CLI 编写的程序”。 它提供执行托管代码和数据所需的服务,使用元数据在运行时将单独生成的模块连接在一起。”
  • 垃圾收集器 - 负责内存管理、对象分配和回收不再使用的内存区域。 ECMA-335 将其描述为“分配和释放托管数据内存的过程”。

  所有这些元素一起工作,就像在一台充满大大小小的块的折叠良好的机器中一样。 移除其中一个并期望整台机器继续工作是很困难的。 内存管理也是如此。 我们可以讨论内存管理机制,但最好认识到其他组件与其密切合作。 例如,JIT 编译器生成变量的生命周期信息,然后供垃圾收集器使用。 类型系统提供做出关键决策所需的信息 - 例如,类型是否具有所谓的终结器。 异常处理必须以了解内存回收机制的方式编写 - 例如,在垃圾收集发生时停止。 CLR 中各个组件的许多此类功能都是非常有趣的小事实。

  我们可能经常听说 .NET 环境中的托管代码。 它的具体含义是,运行时执行的代码应该能够与其配合来提供上述职责。 正如 ECMA-335 标准所说:

托管代码:包含足够信息以允许 CLI 提供一组核心服务的代码。 例如,给定代码内方法的地址,CLI 必须能够找到描述该方法的元数据。 它还必须能够遍历堆栈、处理异常以及存储和检索安全信息。

  总而言之,让我们看一下执行应用程序的 .NET 运行时的鸟瞰图(见图 4-1)。

在这里插入图片描述
图 4-1。 源代码(文本文件)被编译为通用中间语言(二进制文件)。 然后,在安装了 .NET 运行时的目标计算机上,它由运行时本身运行。 它由两个主要单元组成:执行引擎(EE)和垃圾收集(GC)。 EE 从二进制文件中获取 CIL,并将其在内存中转换为机器代码。

我们可以将这个过程描述为包含以下步骤:

  • 我们在我们选择的编辑器中编写代码 - Visual Studio、Visual Studio Code 或其他任何编辑器。 结果,我们得到一个包含一组源文件的项目。 简而言之,这些是文本文件,其中包含用 C#、VB.NET、F# 或任何其他受支持的语言编写的程序源代码
  • 我们在适当的编译器的帮助下编译我们的项目 - 无论是 Visual Studio 内置编译器(针对 .NET Framework 项目)还是 .NET Core 编译器。 结果,我们得到一组文件(程序集),其中包含表示通用中间语言指令的二进制代码形式的代码。 这段代码将我们的程序表示为在“虚拟”堆栈机上运行的一组低级指令(参见第 1 章)。 可能还有其他程序集包含我们在程序中使用的库。 现在可以将这样的程序集作为 ZIP 包或通过安装程序分发给其他用户。
  • 我们运行应用程序 - 这显然是最重要的部分,随后可以分为以下步骤:
    • 对于 .NET Framework - 可执行文件包含引导代码,该代码在 Windows 操作系统的支持下加载正确版本的 .NET 运行时。
    • 对于.NET Core - 多平台解决方案不依赖于Windows合作。 如果我们想运行托管程序集,我们必须在包含程序的目录中显式使用适当的命令,例如 dotnet run。 这将引导运行时。
    • .NET运行时将从文件中加载当前需要的汇编CIL代码部分并将其传递给JIT编译器。
    • JIT 编译器会将 CIL 代码编译为机器代码,并针对其运行的平台进行优化。 它还会向执行引擎注入不同的调用,从而在代码和 .NET 运行时之间提供协作。
    • 从现在开始,您的代码将像正常的非托管代码一样执行。 不同的是,有和上面提到的runtime的配合。

  现在可能是解释我们可能遇到的与 .NET 环境相关的一些常见误解的好时机:

  • .NET 不是常识中的虚拟机 - .NET 运行时不会创建任何隔离环境,也不会模拟任何特定的体系结构或机器。 事实上,.NET运行时正在重用操作系统内存管理等内置系统资源,包括堆和堆栈、进程和线程等等。 然后在它们之上构建一些附加功能(自动内存管理等)
  • 一台机器上没有运行单一的 .NET 运行时 - 有一个二进制发行版,但它是根据每个运行的 .NET 应用程序加载和执行的。 例如,进程 A 的垃圾收集不会直接影响进程 B 的垃圾收集。显然,在硬件和操作系统级别上存在一些资源共享,但一般来说,每个 .NET 运行时不知道任何其他托管应用程序正在运行其资源。 拥有 .NET 运行时实例。 事实上,我们可以在非托管应用程序中托管 .NET 运行时(SQL Server CLR 功能就是这种情况)。 更重要的是,我们可以在单个进程中托管多个 .NET 运行时,尽管这种行为有一些实际用途。

深度示例程序

  现在让我们逐步了解编译和运行一个简单的 Hello world 应用程序(参见清单 4-1),以更好地理解一些 .NET 内部结构。 这将使我们熟悉稍后需要的一些基本概念。 每个学习过 C# 的人可能都认识这个示例,其唯一目的是在控制台上显示简短的文本。 我们将使用它作为在 Windows 上的 .NET Core 2.1 运行时下运行的游乐场。 显然,我们不会在这里太深入,因为我们最感兴趣的是内存管理的东西。 如果您确实对 .NET 运行时如何加载自身、管理其类型和类似主题感兴趣,那么我再次推荐之前介绍的优秀书籍。

清单 4-1。 用 C# 编写的示例 Hello World 程序

using System;
namespace HelloWorld
{
	class Program
	{
		static void Main(string[] args)
		{
			Console.WriteLine("Hello world!");
		}
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

  清单 4-1 中的示例代码在由 C# 编译器(此处使用 Roslyn,此处使用 Visual Studio 2017)编译时,将生成一个 DLL 文件,在我的例子中,该文件称为 CoreCLR.HelloWorld.dll。 该文件包含运行此类程序所需的所有数据。 我们可以详细地看到它,例如,通过dnSpy工具打开它。 完成此操作后,我们可以浏览文件的各个解码部分(见图 4-2):

  • 描述自身的元数据(就 Windows 或 Linux 二进制文件描述而言)——在 Windows 二进制文件可见的情况下称为 DOS 和 PE 头,如图 4-2 所示;
  • 描述其.NET 相关内容的元数据 - 包括在我们的程序集中声明的所有类型、它们的方法和其他属性(显示为名为 #~ 的存储流 #0);
  • 其他所需文件的引用列表;
  • 声明的类型及其方法的二进制流编码为表示通用中间语言的字节。
    在这里插入图片描述
    图 4-2。 CoreCLR.HelloWorld.dll 二进制文件的内容 - 编译清单 4-1 中的程序的结果

  每个方法或类型都有其唯一的标识符(称为令牌),并且由于上面提到的元数据流,它的位置在文件中是可识别的。 因此,我们可以识别包含每个方法主体的文件区域。 例如,要查看 Main 方法主体,请从 Assembly Explorer 中选择它,然后使用其上下文菜单中的 Show Method Body in Hex Editor 选项(参见图 4-3)。
在这里插入图片描述
图 4-3。 包含 Program.Main 方法的通用中间语言指令的几个字节(为了清楚起见,添加了箭头)

  当然,查看原始字节,确实很难理解它们的含义。 但是,借助第 3 章中提到的反编译,我们可以将每个方法的 CIL 解码为更易读的形式。为此,只需在 Assembly Explorer 中选择 Main 方法,然后从 dnSpy 菜单中选择 IL 作为反编译语言。

  从 CoreCLR.HelloWorld.dll 反编译 Program 类型的结果如清单 4-2 所示(为了清楚起见,已删除构造函数)。 在注释中,我们可以看到给定指令的原始字节码(例如,字节 2A 代表 ret CIL 指令),因此现在我们可以完全理解图 4-3 中突出显示的 7201000070280C00000A2A 字节。

  如果我们看一下 Main 方法的简单 CIL 代码(参见清单 4-2),我们将看到它是如何编译成堆栈机器代码的:

  • ldstr “Hello World!” - 对字符串文字的引用被推送到计算堆栈上;
  • call System.Console::WriteLine - 调用静态方法,从计算堆栈中获取第一个参数;
  • ret - 方法返回(没有返回值,因为计算堆栈上没有任何内容)。

清单 4-2。 清单 4-1 中的示例程序已转换为通用中间语言。 输出来自 dnSpy 工具。

// Token: 0x02000002
.class private auto ansi beforefieldinit CoreCLR.HelloWorld.Program
 extends [System.Runtime]System.Object
{
	 // Token: 0x06000001
	 .method private hidebysig static
	 void Main (
	 string[] args
	 ) cil managed
	 {
	 // Header Size: 1 byte
	 // Code Size: 11 (0xB) bytes
	 .maxstack 8
	 .entrypoint
	 /* 7201000070 */ IL_0000: ldstr "Hello World!"
	 /* 280C00000A */ IL_0005: call void [System.Console]System.
	Console::WriteLine(string)
	 /* 2A */ IL_000A: ret
	 } // end of method Program::Main
} // end of class CoreCLR.HelloWorld.Program
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

如果仔细观察清单4-2的代码,可以看到一条.maxstack 8指令,这似乎与程序执行有关。 然而,这不是 CIL 指令。 各种工具可以使用此类元数据描述来验证代码安全性。 maxstack 告诉由于方法执行可以在计算堆栈上分配多少个最大字节。 对于 Main 方法,字符串文字引用需要八个字节。 像 PEVerfiy 这样的工具可以使用此信息来对抗方法的 CIL 代码想要执行的操作。 这使得 .NET 代码可验证且安全,因为多种缓冲区溢出是计算机环境中最危险的威胁。

  在考虑 .NET 堆栈计算机时,我们应该提到一个重要的位置概念。 当考虑存储程序执行所需的各种值时,存在一些逻辑位置:

  • 方法中的局部变量;
  • 方法的参数;
  • 另一个值的实例字段;
  • 静态字段(类、接口或模块内部);
  • 本地内存池;
  • 暂时位于计算栈上。

  如何将每个位置映射到特定的计算机体系结构是 JIT 编译器的唯一责任,我们将深入探讨这一点。

注意 目前 .NET 生态系统中可用的 JIT 编译引擎很少:

  • .NET Runtime(直到版本 4.5.2)和 .NET Core 1.0/1.1 用于 x86 架构(32 位版本)的旧版 x86 JIT
  • .NET Runtime 使用旧版 x64 JIT,直至版本 4.5.2
  • .NET Core 2.0(及更高版本)和 .NET Framework 4.6(及更高版本)使用新的 RyuJIT 进行 32 位和 64 位编译。
  • 适用于 x86 和 x64 平台的 Mono JIT

  由于替换旧引擎是一项持续的工作,因此我在这里仅关注新的 RyuJIT 引擎。

  现在,如果我们想看看在 64 位 Windows 下我们的程序如何通过 JIT 翻译成机器代码,我们可以使用 WinDbg。 显然,我们需要运行我们的应用程序,因为它会触发引导运行时和必要方法的 JIT 编译。

  假设我们使用作为通用 Windows 应用程序分发的最新 WinDbg,我们可以从“文件”面板中选择“启动可执行文件(高级)”并提供以下参数(假设我们的解决方案位于 C:\Projects):

  • 可执行文件:C:\Program Files\dotnet\dotnet.exe
  • 参数:\CoreCLR.HelloWorld.dll
  • 启动目录:C:\Projects\CoreCLR.HelloWorld\bin\Release
    netcoreapp2.1

许多人喜欢从命令行启动 WinDbg 来调试程序。 在我们的例子中,要启动调试会话,您可以使用以下命令:windbgx C:\ Program Files\dotnet\dotnet.exe C:\Projects\CoreCLR.HelloWorld\ bin\x64\Release\netcoreapp2.1\CoreCLR.HelloWorld .dll

  单击“确定”后,Hello world 应用程序将启动,并且其执行将立即中断。 我们现在需要设置一个断点,在程序终止之前(在打印 Hello World! 消息之后)停止程序。 我们可以指定以下命令:

bp coreclr!EEShutDown

  现在点击 Go 并稍等片刻,直到到达该断点。 之后我们应该加载一个 SOS 扩展(在第 3 章中提到)并使用命令查找 Main 方法:

.loadby sos coreclr
!name2ee *!CoreCLR.HelloWorld.Program.Main

  第二个应该产生以下输出 - 表示 Main 方法的 JITted 代码位于地址 00007ffbca3e06b0 下:

Module: 00007ffbca284d78
Assembly: CoreCLR.HelloWorld.dll
Token: 0000000006000001
MethodDesc: 00007ffbca285d30
Name: CoreCLR.HelloWorld.Program.Main(System.String[])
JITTED Code Address: 00007ffbca3e06b0

  我们可以使用 !U 00007ffbca3b0480 命令来查看发出的汇编代码,结果如清单 4-3 所示。 我们看到执行步骤如下:

  • sub rsp,28h - 将堆栈指针移动 40 个字节;
  • mov rcx,24D6CCA3068h - 将地址 24D6CCA3068h 存储到 rcx 寄存器中(这是我们的“Hello World!”字符串文字的句柄,这里使用它是因为稍后解释的字符串驻留机制);
  • mov rcx,qword ptr [rcx] - 取消引用 rcx 寄存器中存储的地址,该地址指向具有字符串文字值的字符串;
  • call 00007ffb`ca3b0330 - 调用静态 Console.WriteLine 方法传递要在 rcx 寄存器中显示的文本;
  • nop,add rsp,28h 和 ret - 结束函数调用。

清单 4-3。 由清单 4-2 中的 JITting 代码生成的机器代码

Normal JIT generated code
CoreCLR.HelloWorld.Program.Main(System.String[])
Begin 00007ffbca3b0480, size 1c
00007ffbca3b0480 4883ec28 sub rsp,28h 00007ffbca3b0484 48b96830ca6c4d020000 mov rcx,24D6CCA3068h
00007ffbca3b048e 488b09 mov rcx,qword ptr [rcx] 00007ffbca3b0491 e89afeffff call 00007ffbca3b0330 (System. Console.WriteLine(System.String), mdToken: 0000000006000083) 00007ffbca3b0496 90 nop
00007ffbca3b0497 4883c428 add rsp,28h 00007ffbca3b049b c3 ret

  这就是我们简单的 C# 程序通过 CIL 转换为可执行代码的方式。 ldstr 和 call CIL 指令使用的计算堆栈位置已被 JIT 编译器用作 CPU 寄存器 rcx。 Main 方法内部没有栈或堆分配 - 但请记住,运行时本身和框架程序集已经进行了一些分配。

  由于在函数调用期间使用寄存器和内存的可能方法有很多,因此存在称为调用约定的标准化方法。 它们定义如何在方法调用期间传递参数和管理堆栈以及如何返回值。 在本书中说明汇编代码时,我假设采用 Microsoft x64 调用约定。 为了我们的目的,这套规则进行了简化,规定:

  • 前四个整数和指针参数被传递到寄存器 RCX、RDX、R8 和 R9 中;
  • 前四个浮点参数通过 XMM3 寄存器传递到 XMM0 中;
  • 额外的参数被压入堆栈;
  • 如果 64 位或更少,整数返回值将在 RAX 中返回。

  请注意,Linux x64 调用约定有所不同,因此如果需要,请随时阅读此内容。

  我希望这个非常短暂但可能且有点令人难以承受的旅程向您展示什么是 .NET 运行时。 最后,所有方法都被 JIT 编译为常规汇编代码,可选地利用运行时的一些“托管”部分。

程序集和应用程序域

  .NET 环境中的基本功能单元称为程序集。 1 它可以被视为一堆可以由 .NET 运行时执行的存储的 CIL 代码。 程序至少由一个或多个程序集组成。 例如,当我们编译清单 4-1 中的代码时,我们生成了由 CoreCLR.HelloWorld.dll 文件表示的单个程序集。 此类程序还使用各种其他程序集,从基本类库(称为 mscorlib,包括 System.IO、System.Collections.Generic 等重要的命名空间)等开始。 复杂的 .NET 应用程序可能由许多不同的程序集组成,其中包含我们的代码。 在源项目管理方面,存在简单的对应关系 - 我们的解决方案中的一个项目被构建到单个程序集中。 还可以在程序执行期间创建动态程序集(通常用于将动态创建的代码发送到此类动态程序集中),这是各种序列化程序经常使用的功能。

  换句话说,程序集可以被视为托管代码的部署单元,它通常与某些 DLL 或 EXE 文件一一对应(此类文件称为模块)。

  .NET Framework 提供了隔离托管应用程序代码(程序集)的不同部分的可能性,将它们分成所谓的应用程序域(通常缩写为 BCL 类型名称的 AppDomains)。 由于安全性、可靠性或版本控制的需要,可能需要这种分离。 要从程序集执行代码,我们必须将其加载到某个应用程序域(这同样适用于动态创建的程序集)。

程序集和 AppDomain 之间存在相当复杂但有详细记录的关系。 请参阅这个优秀的 .NET Framework 文档:https://docs.microsoft.com/en-us/dotnet/framework/appdomains/application-domains 了解详细信息。

  保持 .NET Core 较小需要删除一些功能,AppDomains 就是其中之一。 对于它们提供的功能和它们所需的功能来说,它们太重了。 因此,.NET Core 中没有公开与应用程序域处理相关的 AppDomain API。 然而,负责它们的代码片段在 CoreCLR 中仍然可用,因为运行时本身在内部使用它们。 对于开发人员,微软建议使用普通的旧进程或闪亮的新容器来隔离 .NET Core 应用程序。 至于程序集的动态加载,您可以查看一个新的 AssemblyLoadContext 类。

  AppDomain 符合我们的兴趣,因为它们影响 .NET 进程的内存结构。 一般来说,运行时可以创建几个不同的应用程序域:

  • 共享域 - 域之间共享的所有代码都在此处加载。 它包括基本类库程序集、系统命名空间中的类型等等。
  • 系统域 - 它曾经负责创建和初始化其他域,因为核心运行时组件在这里加载。 它还保留进程范围的实习字符串文字(我们将在本章后面讨论实习)
  • 默认域(例如,称为域 1)- 用户代码加载到此类默认域。
  • 动态域 - 在运行时的帮助下,.NET Framework 应用程序可以根据需要创建(并随后删除)任意数量的附加 AppDomain。 例如,通过 AppDomain.CreateDomain 方法(但如上所述,.NET core 在设计上缺少该功能,并且不太可能提供该功能)。

  对于 .NET Core,显然没有动态创建的域。 所有共享代码都有共享域责任。 所有用户代码都有一个默认的 AppDomain。 系统域在进程内存中物理上不可见,但也包括其结构和逻辑。

Collectible Assemblies(可收藏组件)

  我们加载的程序集包含一个清单,描述它们需要哪些其他程序集。 标准 CLR 行为包括将所有必需的程序集加载到主应用程序域中 - 该域将在整个程序执行过程中存在。 这对于大多数情况来说都很好,但在某些情况下我们希望对程序集的生命周期有更多的控制:

  • 脚本 - 如果我们允许它在我们的应用程序中执行用户定义的脚本(例如,在 Roslyn API 的帮助下编译),那么最好将此类脚本编译到一些临时程序集中,并在脚本消失后立即将其删除更需要。
  • 对象关系映射 (ORM) - 我们可能希望将一些数据库数据映射到 .NET 对象,但不一定在整个应用程序生命周期中都需要它 - 特别是如果我们的应用程序足够具体,可以临时连接到许多不同的源。 清理创建的 ORM 数据(分为程序集)将是一个很好的功能。
  • 序列化器 - 就像上面一样,我们可能需要序列化/反序列化许多不同的实体(无论是文件还是 HTTP 请求),因此如果我们已经这样做了很多次,那么清理不再需要的创建的临时程序集会很好。 出于性能原因,此类程序集是由序列化程序创建的 - 创建专用于具体数据序列化的类型以省略任何不必要的“通用”处理方式。
  • 插件 - 我们的应用程序可以通过加载用户提供的插件来提供可扩展性功能。 如果需要加载它们并卸载它们显然会很棒。

  对于 .NET Framework,可以通过卸载其加载到的整个应用程序域来间接卸载程序集。 因此,例如,处理用户定义脚本的典型场景包括创建动态应用程序域、使用已编译脚本发出程序集、将其加载到临时应用程序域中、执行代码,以及最终卸载此类应用程序域。 对于 .NET Core,由于 AppDomain 的 API 不可用,此类场景目前不可用(在撰写本文时,使用 .NET Core 2.1)。

  虽然在 .NET Framework 情况下,它是一个完美工作的解决方案,但它有自己的注意事项 - 特别是应用程序域之间的远程通信的成本。

正是由于提到的开销,大多数情况下,即使需要创建动态程序集,它们也只是简单地加载到主应用程序域中 - 即使这意味着它们之后无法卸载(因为这需要卸载应用程序本身)。 这是我们在 .NET 中遇到的流行 XmlSerializer 的情况,这可能会导致本章后面的场景 4-4 中描述的内存泄漏。

  因此,出现了一种更轻质、可收藏的组件的想法。 可收集程序集是可以卸载的动态程序集,而无需卸载它所在的应用程序域。 它在上述所有场景中都非常有意义。 但是,它们目前在两个 Microsoft .NET 运行时中均不可用。 请继续关注 .NET Core 公告,因为有关可卸载 AssemblyLoadContext 的工作正在进行中

在 .NET Framework 中,可收集程序集已实现,但仅部分实现,以防在 Reflection.Emit 的帮助下手动发出代码。 正如 MSDN 文档所述:“ Reflection Emit 是唯一支持加载可收集程序集的机制。 通过任何其他形式的组件加载方式加载的组件都无法卸载。”

Process Memory Regions(进程内存区域)

  正如第 2 章中提到的和图 2-20 所示,进程内的 .NET 运行时管理多个内存区域。 当我们考虑 .NET 进程的内存使用情况时,我们应该考虑其中的每一个。 让我们一一研究这些领域,以了解 .NET 流程的剖析。 我们将使用优秀的 VMMap 工具,它向我们显示我们所附加的进程中使用的内存区域。 下面显示的内存区域来自清单 4-1 中退出应用程序之前的那一刻。

  当我们查看 Hello World 应用程序内部时,我们将看到如图 4-4 所列的内存区域。 为了解释这样的 VMMap 输出,有必要回顾一下第 2 章中介绍的虚拟内存区域的描述。正如我们所看到的,该进程有近 128 TB 的空闲内存(对应于 64 上的 128 TB 虚拟地址空间) 位平台)。

在这里插入图片描述
图 4-4。 VMMap 工具中显示的内存区域,用于清单 1.64 位 .NET Core 2.0 运行时中正在运行的应用程序

让我们从 .NET 角度看一下所有这些项目以及简要描述和含义:

  • 可共享(大约 2 GiB) - 我们不是特别感兴趣的可共享内存 - 仅提交了 32 MiB,并且仅 20 KiB 驻留在物理内存中。 这些区域专用于与 .NET 完全无关的系统管理目的。
  • 映射文件(大约 4 MiB)——如第 2 章所述,这些区域包含字体和本地化文件等映射文件。 尽管它们由 .NET 运行时使用,使用各种本地化 API,但这些区域不应在我们的应用程序中造成任何问题。

在这里插入图片描述

  • 图像(大约 37 MiB)- 二进制图像,包含各种二进制文件的图像,包括 .NET 运行时本身和带有 .NET 程序集的库。 请注意,此空间大部分是共享的,只有 772 KiB 是私人工作集。 这些是应用程序启动期间从磁盘读取的文件。在这里插入图片描述

  • 堆栈(大约 4.5 MiB) - 我们的 Hello World 应用程序中有三个线程,因此有三个专用于它们的栈区域。
    在这里插入图片描述

  • 堆和私有数据(大约 9 MiB) - 这些是 .NET 运行时出于其内部目的而管理的各种本机内存区域。 它们主要存储与我们无关的东西(甚至在没有深入的 CoreCLR 源分析的情况下不知道)。 但是,我们可能会注意到这里存储了一些供执行引擎和垃圾收集器使用的基本数据结构,例如:

    • 标记表和卡片表,我们将在第 5、8 和 11 章中熟悉它们。
    • 字符串实习生招生就在这些地区。
    • 另请注意,最后两个内存区域标有执行/读/写保护标志。 这些是 JIT 编译器在编译 CIL 代码时发出机器代码的区域。 这就是为什么它们被标记为执行标志,因为它们必须像任何其他程序代码一样可以正常调用。 这些区域实际上构成了应用程序执行用 C# 或其他 .NET 兼容语言编写的代码的核心。 如果由于某种原因我们的应用程序经常进行 JIT,我们可能会观察到此类执行/读/写私有内存区域的不断增长。
    • JIT 编译期间所需的各种临时内存区域也将在此处可见。

在这里插入图片描述

  • 托管堆(大约 384 MiB) - .NET 内存管理的核心部分是由垃圾收集器维护的托管堆和运行时使用的其他堆。 由于这对于我们来说绝对是最重要的内存区域,所以我们一会儿单独来看一下。

在这里插入图片描述

  • 页表(36 KiB 的小区域)——第 2 章中描述的页表目录结构就存在于此。
  • 不可用(几乎 2 MiB) - 由于第 2 章中描述的页面分配粒度,内存的某些部分已变得不可用。

我们可以将上面表示为托管堆的组进一步分为以下几类:

  • GC 堆 - 迄今为止对我们来说最重要的堆,由垃圾收集器管理。 我们的应用程序创建的大多数类型都在那里,因此它是我们应该理解的最重要的地方,也是任何问题最可能的根源。 从第5章到本书结尾的所有章节都将描述GC如何管理这个堆。 就我们目前所知,这是一个由垃圾收集器机制及其分配器管理的免费商店。 但请注意,到目前为止,在我们到达这个内存区域之前,我们已经看到了多少有趣的事实! 许多章节将专门对其进行详细描述。
  • 其他域堆 - 每个 AppDomain 都有自己的一组堆,因此可以有共享域、系统域、默认域和任何其他动态加载域的堆。 每个可能有多个子区域:
    • 高频堆 - 用于存储 AppDomain 出于内部目的而频繁访问的任何数据。 正如 CoreCLR 的评论所述,这些是“用于分配在 AppDomain 生命周期内持续存在的数据的堆”。 经常分配的对象应该分配到 HighFreq 堆中,以便更好地进行页面管理。” 因此,例如,共享域的高频堆包含最常用的类型相关数据,例如详细的方法和字段描述。 这里也是原始静态数据所在的地方。
    • 低频堆 - 包含不常用的类型相关数据。 对于类型系统,它们包括 EEClass 以及 JITting、反射和类型加载机制所需的其他数据。
    • 存根堆 - 正如文档所述,它“托管有利于代码访问安全性 (CAS)、COM 包装器调用和 P/Invoke 的存根。
    • 虚拟调用存根 - 包含用于接口分派的虚拟存根分派 (VSD) 技术(使用虚拟方法调用的存根而不是传统的虚拟方法表)所使用的数据结构和代码。 随后将它们分为以下类型的堆:Cache Entry Heap、Dispatch Heap、Indcell Heap、Lookup Heap 和 Resolve Heap。 所有这些仅包括 VSD 所需的各种类型的数据。 即使对于我们应用程序中的数千个接口来说,这些堆也非常小(数百千字节)。
    • 高频堆、低频堆、存根集线器和各种虚拟调用存根堆统称为加载器堆类型,因为它们负责存储类型系统所需的数据(从而加载类型)。 与我们有时听到的相反,不存在作为内存区域创建的加载器堆(Loader Heap)之类的东西。 这只是将提到的区域进行分组的概念。

注意 默认情况下,这些堆非常小,相当于单页的数量级 - 通常约为 64 KiB。 我们可以在 CoreCLR 默认大小定义中看到这一点:
#define LOW_FREQUENCY_HEAP_RESERVE_SIZE (3 * GetOsPageSize())
#define LOW_FREQUENCY_HEAP_COMMIT_SIZE (1 * GetOsPageSize())
#define HIGH_FREQUENCY_HEAP_RESERVE_SIZE (10 * GetOsPageSize())
#define HIGH_FREQUENCY_HEAP_COMMIT_SIZE (1 * GetOsPageSize())
#define STUB_HEAP_RESERVE_SIZE (3 * GetOsPageSize())
#define STUB_HEAP_COMMIT_SIZE (1 * GetOsPageSize())
请记住,任何类型一旦加载到 Loader Heap 区域,在整个相应的 AppDomain 被卸载之前都不会被卸载。 如果我们不断加载大量类型(例如,动态加载或生成程序集),最终可能会占用大量内存。 此外,默认的 AppDomain 在程序停止之前不会被卸载。

  正如第 2 章中提到的,可以更改程序线程的默认堆栈大小。 这可以借助 Visual Studio 附带的 dumpbin 命令行程序来实现。 通过发出以下命令,它会适当地编辑所提供的可执行文件的二进制头:

editbin DotNet.HelloWorld.exe /stack:8000000

  对于基于 .NET Framework 的可执行文件(如上所述),它当前可以工作,但应被视为不受支持的方法 - 不能保证将来 .NET Framework 在创建线程时不会忽略这些值。 对于基于 .NET Core 的构建,可执行文件本身就是运行时启动器,通常位于 C:\Program Files\dotnet\dotnet.exe。 我们需要借助 editbin 来编辑此文件,以更改 .NET Core 应用程序中线程的堆栈大小,这在大多数情况下显然是不可接受的。 因此,尽管以所描述的方式操纵堆栈大小是可能的,但我们根本不应该依赖它。

  现在让我们转向本书的重要部分之一——我们的第一个场景。 一如既往,它由一些情况描述以及如何分析和解决问题的描述组成。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/木道寻08/article/detail/974060
推荐阅读
相关标签
  

闽ICP备14008679号