Linux中排查 .NET Core性能问题

使用createdump排查性能问题

本篇中着眼分析性能问题,并介绍如何使用 createdump 和 ProcDump 在 Linux 中手动捕获 .NET Core 内存转储文件。

重现问题

本部分我们将使用 BuggyAmb Load Generator 功能来排查此性能问题。 这是一项“实验性”功能,可同时向任何有问题的资源发送多达 6 个请求。 它限制为 6 个,因为它使用 jQuery 和 Ajax 调用发出请求。 Web 浏览器将大多数 Ajax 请求的限制设置为给定 URL 的六个并发请求。

若要重现问题,请打开 “问题页”,选择 “Load Generator”,然后在 “慢” 方案中发送六个请求。

image-20230524214552553下面列表显示最终应在浏览器中看到的内容。 显示的响应时间很高。 预期的响应时间小于一秒。 当你从应用程序登陆页中选择 “Expected Results ”链接时,你可以期待看到这一点。

image-20230524215056772

监视症状

首先定义问题并了解症状。 在尝试通过生成负载重现问题时,将用于htop监视托管 ASP.NET Core应用程序的进程的进程内存和 CPU 使用情况。

在尝试重现问题之前,首先为应用程序应如何执行设置基线。 使用Load Generator功能选择**“Expected Results**”或将多个请求发送到**“Expected Results**”方案。 然后,检查问题未显示时 CPU 和内存使用情况的外观。 你将用于 htop 检查 CPU 和内存使用情况。

运行 htop并筛选它,以仅显示属于运行BuggyAmb应用程序的用户的进程。 在这种情况下,目标 ASP.NET Core应用程序的用户是 root。 按 U 键从列表中选择该 www-data 用户。 同时按 Shift+H 隐藏线程。

由于尚未重现性能问题,请注意,所有 CPU 和内存使用情况统计信息目前都很低。

image-20230524215930406

现在,返回到客户端浏览器,并使用Load Generator向**“Slow**”方案发送六个请求。 之后,快速返回到 Linux 设备,并观察进程资源消耗情况 htop。 应该会看到,BuggyAmb应用程序的 CPU 使用量显著增加,内存使用量会上下波动。

image-20230524220122544

最终处理所有请求后,CPU 和内存使用量会减少。 CPU 和内存使用趋势都应让你怀疑在处理请求期间应用程序中可能存在大量 GC (垃圾回收器) 使用情况。

收集核心转储文件

排查性能问题时,会捕获和分析连续内存转储文件。 捕获多个转储文件背后的理念很简单:进程转储是进程内存的快照。 它不包含过去的信息。 若要排查性能问题,应捕获多个手动内存转储文件或核心转储文件,以便可以比较线程和堆等。

使用以下建议选项按需捕获手动内存转储文件:

  • Createdump
  • Procdump
  • Dotnet-dump
Createdump

Createdump 与 .NET Core 运行时一起包含在一起。 它位于运行时目录中。 可以使用 dotnet --list-runtimes 命令查找运行时目录路径。

image-20230524221359095

由于BuggyAmb是.NET Core 3.1 应用程序,因此创建ump 的完整路径是:

/usr/share/dotnet/shared/Microsoft.NETCore.App/3.1.32/createdump

此命令的最简单形式是 createdump <PID>。 这将为目标进程编写一个核心转储。 可以通过添加-f开关来指示工具在何处创建转储文件: createdump <PID> -f <filepath>

依照旧例,我们还是在在目录中 ~/dumps/ 创建转储文件。

你将捕获相隔 10 秒的 BuggyAmb 进程的两个连续内存转储文件。 在重现“缓慢响应的请求”问题时,必须捕获转储文件。 若要开始,首先需要查找进程的 PID。 使用或htop``systemctl status buggyamb.service命令。

image-20230524223526915

若要创建转储文件,请执行以下步骤:

  1. 创建第一个文件: sudo /usr/share/dotnet/shared/Microsoft.NETCore.App/3.1.32/createdump 3149 -f ~/dumps/coredump.manual.1.%d.
  2. 写入第一个转储文件后等待 10 秒。
  3. 创建第二个文件: sudo /usr/share/dotnet/shared/Microsoft.NETCore.App/3.1.32/createdump 3149 -f ~/dumps/coredump.manual.2.%d

最后,应有两个内存转储文件。 请注意每个转储文件的大小。

image-20230524223629088

在lldb中分析转储文件

前面我们已经在lldb 中打开转储文件。 这里我们在两个不同的 SSH 会话中打开 lldb 中的这两个文件。

我们的目标是定位一个可能导致性能的问题,而且我们已经知道,当出现问题时,CPU 和内存使用率很高。 若要检查托管内存,可以使用该 dumpheap -stat 命令。

这个还比较难抓到,我抓了6个;

image-20230524231850298

image-20230524231630534

如果线程的 GC 模式设置为 “抢占”,则表示 GC 可以随时挂起此线程。 相比之下,协作模式意味着 GC 必须等待线程切换到抢占模式,然后才能挂起它。 当线程运行托管代码时,它处于协作模式。

首先在协作模式下检查线程。 协作线程的调试器线程 ID 在示例列表中为 24 。 重复练习时,ID 会有所不同。 通过运行 thread select 24切换到线程,然后运行 clrstack 以列出托管调用堆栈。 BuggyAmb应用程序的“慢”页正在执行字符串 concat 操作。

image-20230524232733480

image-20230524232926814

这应该会使你产生怀疑,因为你应该知道字符串连接操作成本高昂。 这是因为 .NET 中的字符串对象是不可变的,这意味着在分配它们后无法更改它们的值。

这里就很像了, 但是,应尝试验证它是否正确。 在查看托管堆之前,当你已定位在线程上下文上时,请检查从此线程引用的对象,以尝试确定字符串和 string[] 对象值是什么。 运行 dso,并专注于字符串和字符串数组。

image-20230524233546357

先尝试检查字符串数组。使用对象的地址运行 dumpobj 。 但是,请注意,这仅演示了所需对象是数组。 SOS 提供用于 dumparray 调查数组的命令。 运行 dumparray 00007f2694577930 以获取数组中项的列表。 (请记住,正在检查的转储文件中数组对象的地址将有所不同。)

image-20230524234037119

dumpobj使用数组中包含的结果字符串地址再次运行该命令。 选择一些地址并进行调查。

image-20230524234153801

请注意,如果字符串较大,则 lldb (或 SOS) 可能不会显示字符串值。 在这种情况下,选项之一是使用 lldb 的本机命令来检查本机内存地址。 这类似于使用 d* 命令 (例如, dc 在 WinDbg 中) 。

以下命令读取给定内存位置的本机内存,并显示前 384 个字节。 该列表使用其中一个字符串地址来演示它。 正在运行的命令是 memory read -c 384 00007f289a16fa30

image-20230524234728958

线程堆栈引用的字符串数似乎证实了字符串 concat 问题导致性能问题的理论。

但是,调查尚未完成。 你有两个内存转储文件。 因此,你将比较托管内存堆,并检查堆在时间上是如何变化的。

dumpheap -stat在每个转储文件中运行该命令。 下面来自第一个文件。 在下面的列表中,有 125148 个字符串对象,字符串对象的总大小约为 397 MB。 另请注意,内存可能已碎片化,碎片的原因似乎与字符串数组对象和 System.Data.DataRow 对象有关。

image-20230524235144572

继续,在第二个转储文件中运行相同的 dumpheap -stat 命令。 应该会看到碎片统计信息的更改,但这在此调查的上下文中并不重要。 重要的部分是字符串对象的数量以及这些对象的大小显著增加。

stat

同时,对象数 System.Data.DataRow 也会增加。

你可能怀疑存在涉及大型对象堆 (LOH) 的问题。 因此,你可能想要检查 LOH 对象。 在这种情况下,应运行 dumpheap -stat -min 85000 命令。 以下列表包含第一个内存转储的 LOH 统计信息。

min

下面是第二个内存转储的 LOH 统计信息。

min2

这也清楚地显示了堆的增加。 这一切似乎都与 string 对象相关。

最后,如果你要从 LOH 中选择一个“实时”对象来查找其根,该怎么办? 在这种情况下,“Live”表示对象植根于某个位置,因此应用程序正在积极使用该对象,以便 GC 进程不会将其删除。

处理这种情况很容易。 运行 dumpheap -stat -min 85000 -live。 此命令仅显示根于某处的对象。 在此示例中,只有正确的对象实例 string 位于 LOH 中。

image-20230525000928865

使用对象的 string MT 地址获取这些实时对象的地址列表。 运行 dumpheap -mt 00007f28e3500f90 -min 85000 -live

image-20230525001019408

现在,从生成的列表中随机选取一个地址。 在以下截图中,将显示列表中的第三个地址。 可以通过运行 dumpobj来尝试检查所选地址。 但是,由于这是一个大对象,调试器不会显示该值。 因此,请再检查一次本机内存地址,你会看到这是一个 string 对象,类似于在页面上响应缓慢的产品表列表中找到的对象。

memory read -c 128 00007f28aa1b1440

image-20230525001220539

检查列出的对象的根。 若要执行此操作,请使用 SOS gcroot 命令。 此命令仅以最简单的形式将对象的地址用作参数。 如你所见,这 string 植根于运行“慢”页的线程。 甚至应该会看到源文件名和行号信息。

gcroot 00007f28aa1b1440

image-20230525001421214

查看源文件名和行号信息取决于要排查的位置以及是否正确设置符号。 在最坏的情况下,至少可以恢复线程 ID。 在以下列表中, 1aa8 是托管线程 ID。 如果运行 clrthreads,可以找到相应的线程 ID。

image-20230525001648164

如上图所示,托管线程 ID 为1aa8 的调试器线程 ID 为 29切换到线程 29,并检查托管调用堆栈。 如前所述,此线程还应执行字符串 concat 操作。

image-20230525001938497

此证据证实了一种理论,即该问题与大量字符串串联操作有关,这些操作会创建在处理“慢”页期间触发的越来越大的字符串。

此时,如何给出解决方案暂不在讨论范围内。 但是,请注意,使用 StringBuilder 类实例而不是字符串 concat 操作可以轻松实现解决方案。

ProcDump

根据Linux版本的ProcDump的官方页面,ProcDump 是 Linux 从适用于 Windows 的工具的 Sysinternals 套件重新构想经典 ProcDump 工具。

与 Windows 版本相比,Linux 版本存在一些限制。 它不支持该工具的 Windows 版本提供的每项功能。 例如,无法将其配置为在进程崩溃或引发第一次机会异常时收集核心转储文件。

但是,它仍然很强大。下面命令行选项列表证明了工具的强大之处:

1
2
3
4
5
6
-C: Trigger core dump generation when CPU exceeds or equals specified value (0 to 100 * nCPU)
-c: Trigger core dump generation when CPU is less than specified value (0 to 100 * nCPU)
-M: Trigger core dump generation when memory commit limit exceeds or equals specified value (MB)
-m: Trigger core dump generation when memory commit limit is less than specified value (MB)
-T: Trigger core dump generation when thread count exceeds or equals specified value.
-F: Trigger core dump generation when filedescriptor count exceeds or equals specified value.

回想一下,在安装 .NET Core 之前,系统会指示你添加 Microsoft 包存储库。 ProcDump 使用相同的存储库。 因此,可以使用 sudo apt install procdump 该命令直接安装该工具。

image-20230531224038548

可以使用 ProcDump 监视 CPU、内存、线程或文件描述符使用情况。

当目标进程 CPU 或内存使用量达到特定阈值或低于限制值时,可以使用 ProcDump 捕获内存转储文件。 但是,对于本练习,你将使用最简单的方法来调用该工具: procdump <PID> 这会手动创建进程的转储文件。

捕获同一进程的内存转储文件。 请注意,必须使用 sudo>a0>运行命令。

ProcDump在何处创建核心转储文件

这是需要搞清楚的事情;当 ProcDump 用于捕获核心转储文件时,会放在哪个路径;

ProcDump 输出不清楚核心转储文件的创建位置。 输出只是写入文件的名称,而不写入实际路径。

由于其他工具通常使用 /tmp//var/lib/systemd/coredump/ 目录,因此你可能认为 ProcDump 也使用其中一个目录。 但是,情况并非如此。 相反,ProcDump 捕获的转储文件会在 ASP.NET Core应用程序工作目录中创建。

应用程序的工作目录在服务控制单元文件中定义。 如以下屏幕截图所示,示例应用程序的工作目录为 /var/BuggyAmb_v1.1。 因此,ProcDump 为此应用程序创建的任何转储文件都将放入 /var/BuggyAmb_v1.1 目录中。

根据内存使用情况捕获转储文件
  • 假设在托管的 ASP.NET Core应用程序上,你正经历着高内存消耗。
  • 高内存消耗随机发生,你不知道如何重现问题。 你只知道当托管应用程序的进程的提交内存使用量达到 750 MB 时,问题才会启动。
  • 由于无法持续监视内存使用情况,因此需要自动执行转储收集过程。 你的目标是在内存使用量超过 750 MB 阈值后捕获同一进程的两个连续转储文件。
  • 需要捕获两个内存转储文件,第一个和第二个内存转储文件的生成间隔至少为五秒。

根据 ProcDump 帮助,下面是必须使用的开关:

  • -M:当内存提交超过或等于指定值时触发核心转储文件生成 (MB)
  • -n:退出前要写入的核心转储文件数 (默认值为 1)
  • -s:连续几秒钟写入转储文件 (默认值为 10)
  • -d:将诊断日志写入 Syslog
  • -p:进程的 PID

运行 sudo procdump 14693 -n 2 -s 5 -M 750 命令。 你会看到,ProcDump 会等到满足传递给它的参数定义的条件,或者等到你决定通过按 Ctrl+C 结束监视阶段为止。

虽然 ProcDump 正在监视内存使用情况,但通过再次使用 Load Generator Web 应用程序的功能向**“Slow**”方案发送六个请求来重现相同的问题。 内存使用量达到阈值后,ProcDump 将创建转储文件。 以下屏幕截图显示了两个捕获的转储文件。

image-20230531231443983

image-20230531231700584

使用 createdump 和 ProcDump 创建的转储文件在包含的信息方面是相同的。 你可以选择你认为最适合在排查此类问题时遇到的方案的任何工具。

Dotnet-dump

dotnet-dump 是收集和分析核心转储文件的另一种方法,无需使用本机调试器,例如 Linux 上的 lldb。 此工具还允许运行 SOS 命令,以便分析崩溃和垃圾回收器 (GC) 相关问题。 Dotnet-dump 仅在 .NET Core 3.0 SDK 及更高版本中可用。

如果尚未安装 dotnet-dump,现在可以通过运行以下命令来安装它:

1
dotnet tool install -g dotnet-dump

若要手动捕获核心转储文件,可以使用该 dotnet-dump collect 命令。 例如,dotnet-dump collect -p 14693。 该工具将收集使用 createdump 或 ProcDump 收集的相同核心转储文件。

dotnet-dump 最重要的功能是,它可以用于打开 .NET Core 转储文件并运行 SOS 命令,与 lldb 相同。 仍需设置符号并安装 SOS 扩展,以便能够使用 dotnet-dump 分析转储文件 。

使用dotnet-dump收集核心转储文件

若要使用 dotnet-dump 收集核心转储文件,将使用该 dotnet-dump collect <PID> 命令。 此时,确定 PID 应该是一项简单的任务。 但是,如果需要帮助,此命令包含一个 ps 参数。 该 dotnet-dump ps 命令列出了可以从中收集转储文件的 dotnet 进程。

如果尝试运行该 dotnet-dump ps 命令,将遇到一些意外结果。

image-20230531232457871

此列表中显示两个进程。 但是,其中一个显示为提升的进程,其路径不能由 dotnet-dump ps 命令确定。 若要查找其路径,请运行 cat /proc/<PID>/cmdline 命令检查进程命令行信息。

image-20230531232851802

命令的输出 (cat /proc/14693/cmdline) 告诉你这是BuggyAmb应用程序。

尽管该 dotnet-dump ps 命令无法检索进程路径,但它仍应该能够生成必要的转储文件来进行故障排除。 尝试使用 dotnet-dump collect 命令捕获核心转储文件。 首先,尝试工具能够为其显示进程路径的演示应用程序。 (dotnet-dump collect -p 6164 尝试练习) 时,具体进程 PID 将有所不同。

生成内存转储文件成功。 现在,请尝试为在使用 dotnet-dump ps 该命令时工具无法列出进程路径的BuggyAmb应用程序收集核心转储文件。 运行 dotnet-dump collect -p 14693,并注意到此操作意外失败。

image-20230531233443138

返回以下错误消息:

System.Net.Internals.SocketExceptionFactory+ExtendedSocketException (13): Permission denied /tmp/dotnet-diagnostic-14693-293576-socket

当你注意到该 dotnet-dump ps 命令无法获取进程路径时,你可能怀疑尝试生成核心转储文件会失败。

从上一条错误消息中,可以确定涉及权限问题。 但是比如有这两个进程(第一个托管 .NET 5 示例应用程序和第二个托管 .NET Core 3 应用程序)之间的区别是什么? 比较每个进程的Service文件。 应该能够相对轻松地发现差异:运行每个进程的用户帐户不同

列表的左侧对应于第一个演示应用程序,其中一切正常工作,应用程序以 <用户名> (运行,或者在设置环境并登录) 时使用的用户帐户运行。 右侧对应于BuggyAmb程序,转储文件收集失败。 应用程序以 www-data 用户身份运行。

那么怎么处理呢?Github issue讨论

摘要上述讨论:如果将服务进程作为与用于登录的帐户的用户不同的用户运行,则应按如下所示运行 dotnet-dump collect 该命令。

下面是命令的格式:

1
sudo -H -u <user name of service> bash -c "<full path to dotnet tools>/dotnet-dump collect -p <PID> -o <output path>"

可以使用该命令确定 dotnet-dump 工具的 whereis dotnet-dump 完整路径。

image-20230531234152176

输出路径应是用户具有“写入”权限的目录。 通常,可以在 /tmp 目录下创建目录,然后稍后将转储文件复制到主目录。

使用dotnet-dump打开和分析转储文件

这里我们可以打开以前的使用 createdump 捕获的转储文件之一。也可以打开使用 dotnet-dump 捕获的核心转储文件。

若要使用 dotnet-dump 打开转储文件,请运行 dotnet-dump analyze ~/dumps/coredump.manual.2.11724 ,计算机上的内存转储文件名称会有所不同。 这是之前在 lldb 中使用的托管调试器引擎。 如果符号配置正确且 SOS 安装正确,则可以运行任何 SOS 命令,就像使用 lldb 一样。 在以下列表中,可以看到 clrstack 命令正在运行。

image-20230531235313158

  • 由于这不是本机调试器,因此无法运行 lldb 本机调试器命令,例如之前指示在 lldb 中使用的内存读取命令。 只能运行 SOS 命令。
  • 自动完成不起作用。 在 lldb 中,可以开始键入命令,然后按 TAB 键自动完成命令,就像在命令行和 shell 中一样。 该功能不适用于 dotnet-dump。

打开核心转储文件时,请借此机会练习一些新的 SOS 命令。

如何检查转储中的 CPU 使用情况? 该 threadpool 命令可能有助于显示此信息。 它报告“服务器的 CPU 总使用量” (不仅针对正在调试) 的进程,而且还提供生成内存转储文件时计算机上的资源消耗的一般概念。 以下屏幕截图显示服务器的总 CPU 使用率约为 82%。

image-20230531235552453

相同的输出还显示线程池配置,在上一列表中,有 11 个辅助线程可用,其中 6 个线程正在运行。 排查线程池配置问题时,此信息会很有用。

是否要对所有正在运行的线程的调用堆栈进行分组并显示合并显示,类似于 Visual Studio 并行堆栈 面板? 运行 pstacks 以获取类似的结果。

image-20230601000526686

若要进一步了解可用的 SOS 命令,请打开 SOS 帮助,并单独查看每个命令,以熟悉 SOS 调试的功能。

如果你不算安装 lldb ,其实 Dotnet-dump 也非常有用。 下面通过引入另一种工具来分析 GC 堆。

Dotnet-gcdump

Dotnet-gcdump 是另一个有用的工具。 它在 .NET Core 3.1 或更高版本中可用。

此工具背后的理念是,在很多情况下,你不需要完整的流程转储即可完成调查,而你主要希望查看托管堆。 那么,为什么不直接捕获堆信息并生成有关它的报表呢? 最重要的是,此工具生成的 GC 转储文件是可移植的,可以在 Windows 计算机上进行分析。 由于此类转储文件仅包含 GC 堆信息,因此无法在 lldb 中打开它来调查线程或线程调用堆栈。 但是,可以在 PerfView 或 Visual Studio 中打开它。

虽然听起来很有希望,但是有一件事需要注意: Dotnet-gcdump 触发进程中的 Gen 2 GC 集合来收集所需的数据。 在生产环境中谨慎使用此工具。 不要使用它,除非你知道你肯定必须这样做。

何时可能需要使用此工具? 如果进程已处于无响应状态 (挂起) 状态,并且无法从这种情况中恢复,并且你打算重启应用程序。 然后,可以在重启前捕获 gcdump,以便在以后的阶段中至少可以分析一组信息。

此工具捕获的信息对以下任务非常有用:

  • 按堆上的类型比较对象数。
  • 分析对象根。
  • 确定哪些对象对哪些类型具有引用。
  • 有关堆上对象的其他统计分析。

如果尚未安装此工具,请立即执行此操作。 只需运行以下命令:

dotnet tool install --global dotnet-gcdump

使用 dotnet-gcdump 命令收集 gcdump

同一规则适用于 dotnet-gcdump 应用于 dotnet-dump 工具的规则:如果为其他用户运行进程,则必须以以下格式运行该命令:

1
sudo -H -u <user name of service> bash -c "<full path to dotnet tools>/dotnet-gcdump collect -p <PID> -o <output path>"

现在,应拥有使用该工具收集进程信息所需的一切。 目标是收集两组数据,就像使用核心转储文件一样。 目标进程再次针对其他用户运行。 因此,必须对启动命令使用“bash”格式。

我这里具体的命令书写如下:

1
sudo -H -u root bash -c "/home/bomir/.dotnet/tools/dotnet-gcdump collect -p 3439 -o /tmp/gcreport1.gcdump"

image-20230601220042251

将这两个 gcdump 文件复制到基于 Windows 的计算机,并在 Visual Studio 2022中打开它们。 以下截图显示了为此列表创建的两个 gcdump 文件的比较结果。 如你所见, String 对象类型值之间有很大的差异。 还可以获取有关对象的一些根信息。

这个是比较有用的信息;

或者,可以在 PerfView 中打开 gcdump 报表。

如果只需要查看托管堆,则这可能是最快选项。 但要记住,在生产环境中使用 dotnet-gcdump 时要小心,因为如前所述,它会在进程中触发完整的 GC,这可能会导致长时间暂停。 请在生产环境中谨慎使用此工具,并且仅在排查内存问题时才必要。