一:背景
1. 讲故事
如何跟踪.NET程序的mmap泄露,这个问题困扰了我差不多一年的时间,即使在官方的github库中也找不到切实可行的方案,更多海外大佬只是推荐valgrind这款工具,但这款工具底层原理是利用模拟器,它的地址都是虚拟出来的,你无法对valgrind 监控的程序抓dump,并且valgrind显示的调用栈无法映射出.NET函数以及地址,这几天我仔仔细细的研究这个问题,结合大模型的一些帮助,算是找到了一个相对可行的方案。
二:mmap 导致的内存泄露
1. 一个测试案例
为了方便讲述,我们通过 C 调用 mmap 方法分配256个 4M 的内存块,即总计 1G 的内存泄露,参考代码如下:
为了能够让 C# 调用,我们将这个 c 编译成 so 库,即 windows 中的 dll 文件,参考命令如下:
接下来创建一个名为 MyConsoleApp 的 Console控制台项目。
项目创建好之后,接下来就可以调用 Example_18_1_5.so 中的mmap_allocation
方法了,在真正调用之前故意用Console.ReadLine();
拦截,主要是方便用 perf 去介入监控,最后不要忘了将生成好的 Example_18_1_5.so
文件丢到 bin 目录下,参考代码如下:
2. 使用 perf 监控mmap事件
Linux 上的 perf 你可以简单的理解成 Windows 上的 perfview,前者是基于 perf_events 子系统,后者是基于 etw事件,这里就不做具体介绍了,这里我们用它监控 mmap 的调用,因为拿到调用线程栈之后,就可以知道到底是谁导致的泄露。
为了能够让 perf 识别到 .NET 的托管栈,微软做了一些特别支持,即开启 export DOTNET_PerfMapEnabled=1
环境变量,截图如下:
更多资料参考: https://learn.microsoft.com/zh-cn/dotnet/core/runtime-config/debugging-profiling
- 在
终端1
上启动 C# 程序。
-
终端2
上开启 perf 对dontet程序的mmap进行跟踪。
启动跟踪之后记得在 终端1
上按下Enter回车让程序继续执行,当跟踪差不多(大量的内存泄露)的时候,我们在 终端2
上按下 Ctrl+C
停止跟踪,截图如下:
从输出看当前的 perf.data 有 333 个样本,0.13M 的大小,由于在 linux 上分析不方便,而且又是二进制的,所以我们将 perf.data 转成 perf.txt 然后传输到 windows 上分析,参考命令如下:
经过仔细的分析 perf.txt 的 mmap 调用栈,很快就会发现有人调了 256 次 4M 的 mmap 分配吃掉了绝大部分内存,那个上层的 memfd:doublemapper
就是 JIT 代码所存放的内存临时文件,由于有 DOTNET_PerfMapEnabled=1
的加持,可以看到 [unknown]
前面的方法返回地址,截图如下:
3. 这些地址对应的 C# 方法是什么
本来我以为 JIT很给力,在 perf 生成的 /tmp/perf-3074.map
文件中弄好了符号信息,结果搜了下没有对应的方法名,比较尴尬。
那怎么办呢?只能抓dump啦,这也是我非常擅长的,可以用 dotnet-dump
抓一个,然后使用 !ip2md
观察便知。
从 dotnet-dump 给的输出看,可以清楚的看到调用关系为: Main -> MyTest -> ILStubClass.IL_STUB_PInvoke -> mmap_allocation -> mmap
。
至此真相大白于天下。
三:总结
这类问题的泄露真的费了我不少心思,曾经让我纠结过,迷茫过,我也捣鼓过 strace,最终都无法找出栈上的托管函数,真的,目前 .NET 在 Linux 调试生态上还是很弱,好无奈,这篇文章我相信弥补了国内,甚至国外在这一块领域的空白,也算是这一年来对自己的一个交代。