大家好!我是大聪明-PLUS!
我们偶尔会遇到平台进程在某些情况下内存消耗过高的情况。遗憾的是,对于如此庞大的应用程序,我们没有简单的方法来确定这是否属实。因此,我们开始寻找能够帮助我们分析应用程序内存消耗的专用工具。
下面我们将讨论我们尝试过的工具,为什么它们对我们不起作用,以及我们最终是如何解决分析内存使用情况的问题的。
我们需要一个工具来分析运行在 Windows 和 Linux 系统上的原生应用的内存消耗情况。
为什么会出现内存泄漏以及如何防止内存泄漏
内存过度消耗的典型来源是内存泄漏,以及导致内存碎片化和/或过度分配的次优算法。
许多 C++ 开发人员都会遇到这些问题,因此市面上有很多工具可供使用
这些工具本身的工作原理截然不同。这种方法的优点是对应用程序性能的影响微乎其微。主要的缺点是这种方法具有侵入性(它会在可执行代码中插入额外的指令),因此不适用于发布版本。但是,它非常适合开发阶段。
从工程角度来看,Valgrind 是另一个知名且独特的工具。它本质上是在沙箱环境中,使用虚拟处理器执行被测应用程序的代码,并收集大量指标。然而,在 Valgrind 下运行应用程序的速度比不使用 Valgrind 时慢得多(有时甚至慢几个数量级),这使得它不仅在生产环境中无法使用,而且在开发和调试过程中也常常无法使用。
有些工具的灵感来源于 Valgrind,但其开发目标是尽可能减少对被分析应用程序性能的影响。HeapTrack 就是这样一款工具,它是 KDE 的一部分。HeapTrack 的一个缺点是它与特定的操作系统绑定。此外,一些内存分析工具不仅与特定的操作系统绑定,还与特定的内存分配器绑定,例如 Google gperftools 套件中的 pprof,它主要用于处理tcmalloc分配器。
GitHub 上有很多开源解决方案,但遗憾的是,它们未能满足我们的许多要求。
鉴于业务应用运行的特殊性,我们的一项关键需求是能够轻松地在实际应用场景下从最终用户那里收集信息。如果用户(或其管理员)认为某个应用占用过多内存,平台开发者必须能够以简便的方式收集所需信息。
因此,我们对该工具的要求是:
-
简单的"事后"分析。应用程序用户没有分析工具,也不知道有这样的工具。用户只向我们发送内存转储文件,我们使用该工具对其进行分析。获取内存转储文件是一项相当简单的任务,我们可以轻松地将其委托给客户的系统管理员。
-
该工具对应用程序没有任何影响(在发布版本中,它不会将任何额外内容链接到应用程序)。
-
不会增加应用程序的资源消耗。
-
该工具可以分析现有应用程序版本(在该工具实施之前构建的版本)。
-
该工具显示对象在转储内存中的地址(并能够获取对象类型)。
-
该工具可在Windows和Linux系统上运行。
我们确信市场上没有合适的解决方案,于是开始开发我们自己的内存消耗分析工具。
在开发记忆分析器时,我们将任务分为四个阶段:
-
确定地址和分配大小
-
定义对象类型
-
寻找分配之间的联系
-
对收集的数据进行分析
确定地址和分配大小
第一步是确定转储文件中的地址和分配大小。为此,我们采用最简单的应用程序:

我们编译时不进行优化,以确保编译器在堆中创建一个新对象。接下来,我们在返回之前停止,生成一个内存转储文件,并记住存储在变量 foo 中的结构体的分配地址。现在,让我们尝试找出这笔内存分配的具体位置。为此,我们使用调试器( Windows 系统上为WinDbg)打开内存转储文件,并运行"!heap"命令。该命令将显示现有的堆段。我们暂时不深入探讨这些信息的细节,但我们会请求获取包含变量 foo 地址的堆段的更多详细信息。
实际上,我们会在多个屏幕上看到输出,但在这些信息中,我们会看到一个地址为以下地址的LFH(低碎片堆)段:
总的来说,这个小实验足以让我们明白,原则上,我们需要的信息可以从内存转储中提取出来。
接下来要解决的问题是如何以编程方式提取此类信息,而无需使用 WinDbg。
要查找 Windows 系统中的内存分配情况,首先需要学习的是《Windows内核》这本书。以下概述了书中一些对于解决我们的问题至关重要的关键点。
在当前版本的 Windows 操作系统中,有两个系统分配器:
-
NT堆
-
段堆
内存分配系统是多级的。当我们向操作系统内核请求内存时,它会逐页分配,每页大小为 64 KB。这个大小远大于我们最常用的相对简单的数据结构所需的大小。因此,需要一个用户空间分配器,它也被划分为多个级别:
-
LFH(前端)
-
用户空间后端
-
虚拟分配
第一层是前端,也称为低碎片堆 (LFH)。这一层专为分配不超过 16 KB 的小块内存而设计,主要用于在内存使用频繁的情况下优化内存分配,并在需要时释放内存以避免碎片化。
如果分配的大小超过 16 KB 但低于某个上限(大约 2 MB),则此类分配也由用户空间分配器处理,但会在其第二级进行。如果我们请求更大的分配,则会调用操作系统内核的 VirtualAlloc 函数。
既然我们在上一步已经确定调试器可以访问所需数据,那么现在只剩下一个问题:如何以编程方式获取这些数据。要获取分配器数据,我们需要分析堆结构。微软提供的 ntdll 调试符号包含有关此结构的信息。遗憾的是,《Windows 内核》一书并没有清楚地描述究竟需要从 ntdll 中获取哪些信息。
幸运的是,分配器分析是信息安全会议和论坛上的常见话题,因为对分配器的攻击是相当常见的黑客攻击场景。
基于此,我们实现了一个适用于 Windows 操作系统的软件算法,用于提取转储文件中的分配信息,包括分配的内存大小和分配的地址。
一方面,在 Linux 系统中查找内存分配信息比较容易,因为所有必要的文档和源代码都是公开的。另一方面,Linux 系统下可能存在种类繁多的内存分配器。
当然,如果你使用glibc开发的应用程序,它很可能使用ptmalloc内存分配器。但是,无需重新编译应用程序,你可以使用 LD_PRELOAD 环境变量轻松地将分配器替换为另一个分配器。
作为 1C:Enterprise 技术平台的一部分,我们一直推荐使用Google 开发的tcmalloc内存分配器。然而,在 1C:Enterprise 平台的最新版本之前,我们并未将此分配器包含在平台中,而是通过 LD_PRELOAD 启用。
在开发内存使用分析器时,我们从一开始就专注于使用这个特定的分配器,因此我们的工具并不具有通用性。
定义对象类型
确定了分配的地址和大小之后,我们就可以着手确定位于这些分配中的对象类型了。
与 Java 或 C# 等语言不同,C++ 缺少元数据。但是,它确实有typeid运算符。
假设我们已经编写了一个最简单的程序:

这里我们尝试打印存储在变量 foo 中的类型名称。而且成功了!
这似乎是解决问题的关键。然而,问题在于 Foo 结构体是非多态类型,所有信息都是在编译时获取并硬编码到程序中的。因此,我们无法使用这种方法来分析转储文件。
对于多态对象,情况稍好一些。默认情况下,此类内存分配会以指向 type_info 信息的指针开始。我们似乎可以从中读取所需的数据。
遗憾的是,1C:Enterprise 平台历来没有构建运行时类型信息(RTTI),这意味着在我们的案例中,转储文件中将缺少类型信息。然而,这并非唯一的解决方案。
在指向 type_info 的指针之后,内存中会存储一个执行虚方法表的指针。如果我们把这个数据与调试符号中包含的信息进行比较,就可以准确地确定此次分配中存储的数据类型。
此外,由于 1C:Enterprise 平台的特殊性,我们使用了相当多的动态多态对象作为根对象,称为 SCOM 类。这种方法在概念上与ATL类似。
如上所述,要根据虚方法表指针的值确定对象类型,我们需要调试信息。不同的操作系统有各自的调试信息读取机制。在 Windows 系统中,这是调试接口访问 (DIA);而在 Linux 系统中,则是 libdwarf。
使用这些解决方案的缺点是性能------两者都非常慢。因此,我们为我们的实用程序实现了一个独立的分析阶段,该阶段接收调试符号并从中提取必要的信息。然后,这些信息以一种适用于 Windows 和 Linux 的通用中间格式保存。
寻找分配之间的联系
分析器的下一步工作是查找内存分配之间的关系。假设我们在创建某种类型的对象时检测到内存泄漏。如果这个有问题的对象是另一个类的字段(属性),那么为了彻底调查问题,我们需要了解这个父类是在哪里创建的。
我们已经识别出一些内存分配(我们称之为根分配)。但这远非全部分配。为了分析转储文件中剩余的内存,我们可以遍历已知对象的属性,如果它们包含引用或指针,则利用这些信息来加深我们对转储文件内容的理解。当然,我们会在转储文件中遇到包含标准集合的字段。对于这些字段,我们可以使用一种基于调试器声明式描述(NatVis)的优化方法来获取指向元素的指针。
在 1C:Enterprise 平台中,我们经常使用我们自己实现的集合和容器。为了避免将关系遍历算法与特定应用程序紧密耦合,我们将此分析阶段实现为一个可脚本化的机制。我们选择Lua作为脚本语言。
因此,遍历子分配的脚本如下所示(在本例中,我们解析 std::map 的元素)。
以下是我们在 Lua 中的实现方式:

以下是NatVis中对类似结构的描述:

这里的区别在于我们增加了相当多的额外检查。与调试器提供的集合内容"粗略"视图不同,我们需要精确地确定分配及其内部集合是否有效。因此,对于 map 类型,我们会检查所有字段的值,包括调试器认为"无关紧要"的字段。这也是我们选择脚本式方法而非像 NatVis 那样的声明式方法的原因之一。
对收集的数据进行分析
一旦确定了转储文件中所有数据分配信息,我们就需要对其进行分析。
当我们面临数据分析的任务时,首先想到的可能是将数据放入数据库管理系统 (DBMS) 中,并在 DBMS 上运行一些分析查询。
我们就是这么做的,将数据存储在 PostgreSQL 数据库管理系统中。为什么选择 PostgreSQL 而不是专门的列式数据库或图数据库呢?在分析了转储调查中我们感兴趣的信息后,我们发现大多数情况下,只需几个相对简单的查询即可满足需求。而且,将数据存储在专用存储系统中的开销超过了这些简单查询在 PostgreSQL 中的执行时间。因此,我们没有采用复杂的解决方案。
剧透内容下方是文章作者之一 20 年前的一次个人经历,展示了他如何使用 MS SQL 在移动应用程序中查找内存泄漏!
来自几个世纪深处的历史
此外,还有其他几种机制可用于分析收集到的数据。例如,我们可以构建分配关系图,这有助于开发人员了解特定问题内存区域的来源、分配情况以及与该内存区域关联的对象。
此外,还有数据提取机制。例如,您可以提取内存转储中所有行的全部内容。以下是一个示例。
我们的程序相对简单。

该程序包含一个派生结构体,它继承自基结构体。基结构体是纯虚结构,这是必要的,以便我们的目标派生结构体能够使用动态多态性并存储指向虚方法表的指针。在这种情况下,我们并不关心 `foo()` 方法的具体实现。
在 Derived 结构体内部分配一个包含指向 Foo 结构体的指针的 std::vector。这些结构体不包含虚函数。接下来,在堆上为 Derived 结构体分配空间,并将三个元素(指向 Foo 结构体的指针)放入 vector 中。
之后,我们导出数据并运行分析器。分析结果如下:

我们在这里看到了什么?
首先,除了预期的 Foo 和 Derived 结构之外,我们还看到了一些额外信息。这些是一些系统/服务结构,我们并不关心;在实际的转储文件中,它们只属于统计误差。
其他信息均符合预期。我们看到有一个 Derived 对象,占用 48 字节的内存。我们还可以找到它的确切地址(此信息未显示在屏幕上)。此外,我们有三个 Foo 对象,这也符合预期。还有一个指向 Foo 对象的指针,它实际上是向量的主体,由单个实例表示。
我们还可以构建分配之间的关系图。例如,如果我们需要了解如何访问位于地址 0x2a5b3b9e670 的 Foo 对象,我们将得到相应的图(我们使用Graphviz来构建图):

我们从上到下读取图。在地址 ****fa90 处,我们找到了一个派生类型结构。
在它内部,偏移量为 0x8 处,有一个指向 Foo 对象的 std::vector 指针。从中可以导出三个引用,它们都指向同一内存区域------向量的主体,也就是指针实际所在的位置。这部分内存区域位于地址 ****1960,从那里,我们使用第一个指针导航到我们感兴趣的元素。
现在让我们开始真正的任务吧!
分析真实平台进程(例如rphost服务器集群进程)的转储文件是一项相当消耗资源的任务。在优化我们的工具之前,分析一个 100 GB 的转储文件预计需要几天时间。优化后,分析时间缩短至两小时。这假设一个大约 100 GB 的转储文件包含一个运行正常且没有任何异常的平台进程。那么,异常情况指的是什么呢?例如,我们遇到过这样一种情况:一个邮件库持续创建了一系列大型内存分配。结果,一个 100 GB 的转储文件 90% 的空间都被少量内存分配占据,因此分析速度非常快。但这只是一个非典型例子。
通常情况下,内存会被大量相对较小的分配所占用。
那么,我们需要哪些条件才能快速分析典型情况呢?服务器硬件是首选;分析过程高度可并行化,因此核心越多越好。内存方面,最好有足够的内存来容纳整个分配树。100 GB 的内存绰绰有余。由于转储文件本身可能无法完全放入内存,多余的数据将存储在磁盘上。因此,最好使用高速磁盘:固态硬盘 (SSD),或者更好的选择是 NVMe 固态硬盘。
我们开发的分析工具已经成功用于优化 1C:Enterprise 平台代码。
在平台版本 8.3.20 之前,此解决方案中的一个工作流程可能会占用大约 60 GB 的空间。
我们使用自主研发的工具分析了内存转储文件。结果表明,在平台版本 8.3.20 中,我们通过识别瓶颈并优化算法,提高了内存利用率------现在工作流程大约消耗 45 GB 内存。
我们还计划改进这一数字,将工作流程的内存消耗降低到 15 GB(根据我们乐观的预测)。
利用我们的工具,我们发现了内存使用不当的地方(即使这在源代码中可能不可见),更改/改进了算法,结果内存消耗减少了百分之几十。
代码可能编写正确,遵循最佳实践,但几乎总有优化的空间。重要的是要了解在哪些方面进行优化能够带来显著效果,同时又不会引入任何问题。
今天就到这里,下次博客再见