Linux系列:如何用perf跟蹤.NET程序的mmap泄露
一、背景
1. 講故事
如何跟蹤.NET程序的mmap泄露,這個問題困擾了我差不多一年的時間,即使在官方的github庫中也找不到切實可行的方案,更多海外大佬只是推薦valgrind這款工具,但這款工具底層原理是利用模擬器,它的地址都是虛擬出來的,你無法對valgrind 監(jiān)控的程序抓dump,并且valgrind顯示的調(diào)用棧無法映射出.NET函數(shù)以及地址,這幾天我仔仔細(xì)細(xì)的研究這個問題,結(jié)合大模型的一些幫助,算是找到了一個相對可行的方案。
二、mmap 導(dǎo)致的內(nèi)存泄露
1. 一個測試案例
為了方便講述,我們通過 C 調(diào)用 mmap 方法分配256個 4M 的內(nèi)存塊,即總計 1G 的內(nèi)存泄露,參考代碼如下:
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#define BLOCK_SIZE (4096 * 1024) // 每個塊 4096KB (4MB)
#define TOTAL_SIZE (1 * 1024 * 1024 * 1024) // 總計 1GB
#define BLOCKS (TOTAL_SIZE / BLOCK_SIZE) // 計算需要的塊數(shù)
void mmap_allocation() {
uint8_t* blocks[BLOCKS]; // 存儲每個塊的指針
// 使用 mmap 分配 1GB 內(nèi)存,分成多個 4MB 塊
for (size_t i = 0; i < BLOCKS; i++) {
blocks[i] = (uint8_t*)mmap(NULL, BLOCK_SIZE,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (blocks[i] == MAP_FAILED) {
perror("mmap 失敗");
return;
}
// 確保每個塊都被實際占用
memset(blocks[i], 20, BLOCK_SIZE);
}
printf("已經(jīng)使用 mmap 分配 1GB 內(nèi)存(分成 %d 個 %dKB 塊)!\n",
BLOCKS, BLOCK_SIZE/1024);
printf("程序?qū)和?10 秒,可以使用 top/htop 查看內(nèi)存使用情況...\n");
sleep(10);
}
int main() {
mmap_allocation();
return0;
}
為了能夠讓 C# 調(diào)用,我們將這個 c 編譯成 so 庫,即 windows 中的 dll 文件,參考命令如下:
root@ubuntu2404:/data2/c# gcc -shared -o Example_18_1_5.so -fPIC -g -O0 Example_18_1_5.c
root@ubuntu2404:/data2/c# ls -lh
total 24K
-rw-r--r-- 1 root root 1.2K May 7 10:47 Example_18_1_5.c
-rwxr-xr-x 1 root root 18K May 7 10:47 Example_18_1_5.so
接下來創(chuàng)建一個名為 MyConsoleApp 的 Console控制臺項目。
root@ubuntu2404:/data2# dotnet new console -n MyConsoleApp --framework net8.0 --use-program-main
The template "Console App" was created successfully.
Processing post-creation actions...
Restoring /data2/MyConsoleApp/MyConsoleApp.csproj:
Determining projects to restore...
Restored /data2/MyConsoleApp/MyConsoleApp.csproj (in 1.73 sec).
Restore succeeded.
root@ubuntu2404:/data2# cd MyConsoleApp
root@ubuntu2404:/data2/MyConsoleApp# dotnet run
Hello, World!
項目創(chuàng)建好之后,接下來就可以調(diào)用 Example_18_1_5.so 中的mmap_allocation
方法了,在真正調(diào)用之前故意用Console.ReadLine();
攔截,主要是方便用 perf 去介入監(jiān)控,最后不要忘了將生成好的 Example_18_1_5.so
文件丟到 bin 目錄下,參考代碼如下:
using System.Runtime.InteropServices;
namespaceMyConsoleApp;
classProgram
{
[DllImport("Example_18_1_5.so", CallingConvention = CallingConvention.Cdecl)]
public static extern void mmap_allocation();
static void Main(string[] args)
{
MyTest();
for (int i = 0; i < int.MaxValue; i++)
{
Console.WriteLine($"{DateTime.Now} :i={i} 執(zhí)行完畢,自我輪詢中...");
Thread.Sleep(1000);
}
Console.ReadLine();
}
static void MyTest()
{
Console.WriteLine("MyTest 已執(zhí)行,準(zhǔn)備執(zhí)行 mmap_allocation 方法");
Console.ReadLine();
mmap_allocation();
Console.WriteLine("MyTest 已執(zhí)行,準(zhǔn)備執(zhí)行 mmap_allocation 方法");
}
}
2. 使用 perf 監(jiān)控mmap事件
Linux 上的 perf 你可以簡單的理解成 Windows 上的 perfview,前者是基于 perf_events 子系統(tǒng),后者是基于 etw事件,這里就不做具體介紹了,這里我們用它監(jiān)控 mmap 的調(diào)用,因為拿到調(diào)用線程棧之后,就可以知道到底是誰導(dǎo)致的泄露。
為了能夠讓 perf 識別到 .NET 的托管棧,微軟做了一些特別支持,即開啟 export DOTNET_PerfMapEnabled=1
環(huán)境變量,截圖如下:
圖片
更多資料參考:https://learn.microsoft.com/zh-cn/dotnet/core/runtime-config/debugging-profiling
- 在
終端1
上啟動 C# 程序。
root@ubuntu2404:/data2/MyConsoleApp/bin/Debug/net8.0# export DOTNET_PerfMapEnabled=1
root@ubuntu2404:/data2/MyConsoleApp/bin/Debug/net8.0# dotnet MyConsoleApp.dll
MyTest 已執(zhí)行,準(zhǔn)備執(zhí)行 mmap_allocation 方法
終端2
上開啟 perf 對dontet程序的mmap進(jìn)行跟蹤。
root@ubuntu2404:/data2/MyConsoleApp# ps -ef | grep Console
root 3074 2197 0 11:14 pts/1 00:00:00 dotnet MyConsoleApp.dll
root 3241 3106 0 11:56 pts/3 00:00:00 grep --color=auto Console
root@ubuntu2404:/data2/MyConsoleApp# perf record -p 3074 -g -e syscalls:sys_enter_mmap
啟動跟蹤之后記得在 終端1
上按下Enter回車讓程序繼續(xù)執(zhí)行,當(dāng)跟蹤差不多(大量的內(nèi)存泄露)的時候,我們在 終端2
上按下 Ctrl+C
停止跟蹤,截圖如下:
圖片
root@ubuntu2404:/data2/MyConsoleApp# perf record -p 3074 -g -e syscalls:sys_enter_mmap
^C[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.139 MB perf.data (333 samples) ]
從輸出看當(dāng)前的 perf.data 有 333 個樣本,0.13M 的大小,由于在 linux 上分析不方便,而且又是二進(jìn)制的,所以我們將 perf.data 轉(zhuǎn)成 perf.txt 然后傳輸?shù)?windows 上分析,參考命令如下:
root@ubuntu2404:/data2/MyConsoleApp# ls
MyConsoleApp.csproj Program.cs bin obj perf.data
root@ubuntu2404:/data2/MyConsoleApp# perf script > perf.txt
root@ubuntu2404:/data2/MyConsoleApp# sz perf.txt
經(jīng)過仔細(xì)的分析 perf.txt 的 mmap 調(diào)用棧,很快就會發(fā)現(xiàn)有人調(diào)了 256 次 4M 的 mmap 分配吃掉了絕大部分內(nèi)存,那個上層的 memfd:doublemapper
就是 JIT 代碼所存放的內(nèi)存臨時文件,由于有 DOTNET_PerfMapEnabled=1
的加持,可以看到 [unknown]
前面的方法返回地址,截圖如下:
圖片
3. 這些地址對應(yīng)的 C# 方法是什么
本來我以為 JIT很給力,在 perf 生成的 /tmp/perf-3074.map
文件中弄好了符號信息,結(jié)果搜了下沒有對應(yīng)的方法名,比較尷尬。
root@ubuntu2404:/data2/MyConsoleApp# grep "7f42f3f11967" /tmp/perf-3074.map
root@ubuntu2404:/data2/MyConsoleApp# grep "7f42f3f11a90" /tmp/perf-3074.map
root@ubuntu2404:/data2/MyConsoleApp#
那怎么辦呢?只能抓dump啦,這也是我非常擅長的,可以用 dotnet-dump
抓一個,然后使用 !ip2md
觀察便知。
root@ubuntu2404:/data2/MyConsoleApp# dotnet-dump collect -p 3074
Writing full to /data2/MyConsoleApp/core_20250507_113516
Complete
root@ubuntu2404:/data2/MyConsoleApp# ls -lh
total 1.2G
-rw-r--r-- 1 root root 242 May 710:50 MyConsoleApp.csproj
-rw-r--r-- 1 root root 769 May 711:05 Program.cs
drwxr-xr-x 3 root root 4.0K May 710:51 bin
-rw------- 1 root root 1.2G May 711:35 core_20250507_113516
drwxr-xr-x 3 root root 4.0K May 710:51 obj
-rw------- 1 root root 164K May 711:16 perf.data
-rw-r--r-- 1 root root 874K May 711:21 perf.txt
root@ubuntu2404:/data2/MyConsoleApp# dotnet-dump analyze core_20250507_113516
Loading core dump: core_20250507_113516 ...
Ready to process analysis commands. Type 'help' to list available commands or 'help [command]' to get detailed help on a command.
Type 'quit' or 'exit' to exit the session.
> ip2md 7f42f3f11967
MethodDesc: 00007f42f3f9f320
Method Name: MyConsoleApp.Program.Main(System.String[])
Class: 00007f42f3fbb648
MethodTable: 00007f42f3f9f368
mdToken: 0000000006000002
Module: 00007f42f3f9cec8
IsJitted: yes
Current CodeAddr: 00007f42f3f11920
Version History:
ILCodeVersion: 0000000000000000
ReJIT ID: 0
IL Addr: 00007f437307e250
CodeAddr: 00007f42f3f11920 (MinOptJitted)
NativeCodeVersion: 0000000000000000
Source file: /data2/MyConsoleApp/Program.cs @ 12
> ip2md 7f42f3f11a90
MethodDesc: 00007f42f3f9f338
Method Name: MyConsoleApp.Program.MyTest()
Class: 00007f42f3fbb648
MethodTable: 00007f42f3f9f368
mdToken: 0000000006000003
Module: 00007f42f3f9cec8
IsJitted: yes
Current CodeAddr: 00007f42f3f11a50
Version History:
ILCodeVersion: 0000000000000000
ReJIT ID: 0
IL Addr: 00007f437307e2d2
CodeAddr: 00007f42f3f11a50 (MinOptJitted)
NativeCodeVersion: 0000000000000000
Source file: /data2/MyConsoleApp/Program.cs @ 28
> ip2md 7f42f3f13557
MethodDesc: 00007f42f42f42b8
Method Name: ILStubClass.IL_STUB_PInvoke()
Class: 00007f42f42f41e0
MethodTable: 00007f42f42f4248
mdToken: 0000000006000000
Module: 00007f42f3f9cec8
IsJitted: yes
Current CodeAddr: 00007f42f3f134d0
Version History:
ILCodeVersion: 0000000000000000
ReJIT ID: 0
IL Addr: 0000000000000000
CodeAddr: 00007f42f3f134d0 (MinOptJitted)
NativeCodeVersion: 0000000000000000
>
從 dotnet-dump 給的輸出看,可以清楚的看到調(diào)用關(guān)系為: Main -> MyTest -> ILStubClass.IL_STUB_PInvoke -> mmap_allocation -> mmap
。
至此真相大白于天下。
三、總結(jié)
這類問題的泄露真的費了我不少心思,曾經(jīng)讓我糾結(jié)過,迷茫過,我也搗鼓過 strace,最終都無法找出棧上的托管函數(shù),真的,目前 .NET 在 Linux 調(diào)試生態(tài)上還是很弱,好無奈,這篇文章我相信彌補了國內(nèi),甚至國外在這一塊領(lǐng)域的空白,也算是這一年來對自己的一個交代。