百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

Linux系列:如何用perf跟踪.NET程序的mmap泄露

ahcoder 2025-05-30 12:11 5 浏览

一:背景

1. 讲故事

如何跟踪.NET程序的mmap泄露,这个问题困扰了我差不多一年的时间,即使在官方的github库中也找不到切实可行的方案,更多海外大佬只是推荐valgrind这款工具,但这款工具底层原理是利用模拟器,它的地址都是虚拟出来的,你无法对valgrind 监控的程序抓dump,并且valgrind显示的调用栈无法映射出.NET函数以及地址,这几天我仔仔细细的研究这个问题,结合大模型的一些帮助,算是找到了一个相对可行的方案。

二:mmap 导致的内存泄露

1. 一个测试案例

为了方便讲述,我们通过 C 调用 mmap 方法分配256个 4M 的内存块,即总计 1G 的内存泄露,参考代码如下:


#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) // 计算需要的块数

void mmap_allocation() {
uint8_t* blocks[BLOCKS]; // 存储每个块的指针

// 使用 mmap 分配 1GB 内存,分成多个 4MB 块
for (size_t i = 0; i < BLOCKS; i++) {
blocks[i] = (uint8_t*)mmap(, 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("已经使用 mmap 分配 1GB 内存(分成 %d 个 %dKB 块)!\n",
BLOCKS, BLOCK_SIZE/1024);
printf("程序将暂停 10 秒,可以使用 top/htop 查看内存使用情况...\n");
sleep(10);
}

int main() {
mmap_allocation();
return0;
}

为了能够让 C# 调用,我们将这个 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

接下来创建一个名为 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!

项目创建好之后,接下来就可以调用 Example_18_1_5.so 中的mmap_allocation方法了,在真正调用之前故意用Console.ReadLine();拦截,主要是方便用 perf 去介入监控,最后不要忘了将生成好的 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} 执行完毕,自我轮询中...");

Thread.Sleep(1000);
}

Console.ReadLine();
}

static void MyTest()
{
Console.WriteLine("MyTest 已执行,准备执行 mmap_allocation 方法");
Console.ReadLine();
mmap_allocation();
Console.WriteLine("MyTest 已执行,准备执行 mmap_allocation 方法");
}
}

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. 终端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 已执行,准备执行 mmap_allocation 方法

  1. 终端2 上开启 perf 对dontet程序的mmap进行跟踪。

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回车让程序继续执行,当跟踪差不多(大量的内存泄露)的时候,我们在 终端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) ]

从输出看当前的 perf.data 有 333 个样本,0.13M 的大小,由于在 linux 上分析不方便,而且又是二进制的,所以我们将 perf.data 转成 perf.txt 然后传输到 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

经过仔细的分析 perf.txt 的 mmap 调用栈,很快就会发现有人调了 256 次 4M 的 mmap 分配吃掉了绝大部分内存,那个上层的 memfd:doublemapper 就是 JIT 代码所存放的内存临时文件,由于有 DOTNET_PerfMapEnabled=1 的加持,可以看到 [unknown] 前面的方法返回地址,截图如下:

3. 这些地址对应的 C# 方法是什么

本来我以为 JIT很给力,在 perf 生成的 /tmp/perf-3074.map 文件中弄好了符号信息,结果搜了下没有对应的方法名,比较尴尬。


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 给的输出看,可以清楚的看到调用关系为: Main -> MyTest -> ILStubClass.IL_STUB_PInvoke -> mmap_allocation -> mmap

至此真相大白于天下。

三:总结

这类问题的泄露真的费了我不少心思,曾经让我纠结过,迷茫过,我也捣鼓过 strace,最终都无法找出栈上的托管函数,真的,目前 .NET 在 Linux 调试生态上还是很弱,好无奈,这篇文章我相信弥补了国内,甚至国外在这一块领域的空白,也算是这一年来对自己的一个交代。

相关推荐

当 Linux 根分区 (/) 已满时如何释放空间?

根分区(/)是Linux文件系统的核心,包含操作系统核心文件、配置文件、日志文件、缓存和用户数据等。当根分区满载时,系统可能出现无法写入新文件、应用程序崩溃甚至无法启动的情况。常见原因包括:「日志文件...

linux系统监控工具小神器:btop(linux网络监控工具)

top是大家常用的实时系统监控工具,今天给大家介绍一款非常酷炫的实时系统监控工具btop,用了之后你一定会爱上它!btop是一个高级的实时系统监控工具,它是传统top命令的现代替代品,提供了丰富...

又一全新恶意软件曝光!专门针对Windows、Linux 和 macOS 用户

近日,网络安全研究人员发现了一个利用“CheanaStealer”恶意软件的复杂网络钓鱼活动,该恶意软件是通过一个VPN钓鱼网站传播的。这次攻击的主要目标是各种操作系统的用户,包括Wind...

Java程序员必备的Linux命令全解析

Java程序员必备的Linux命令全解析作为一名Java开发者,除了精通Java语法和框架外,掌握一些基础的Linux命令也是十分必要的。这不仅能提高你的工作效率,还能让你更好地管理和部署Java应用...

Linux基础知识之shell实现用户管理功能

[root@k8s-mastershell]#moreusermanager.sh#!/bin/bashRED='\033[0;31m'GREEN='\033[...

惊艳!Linux 中迷人的 Shell 脚本工具

如果您是Linux操作系统爱好者或正在将自己定义为一个爱好者,那么与shell脚本交叉的路径是完全不可避免的。根据定义,shell脚本是设计用于在Unix/Linuxshell环境中执...

【shell编程】你的第一个sh脚本(shell脚本编程教程)

vimhello.sh#!/bin/bash#注释echo-e"HelloWorld!\a\n"exit0第一行#!/bin/bash,作用是宣告这个文件内的语...

linux之bash、sh和dash(linux里bash命令)

linux系统里有sh、bash、dash等多种shell的解释器命令,其中sh是shll的缩写,是linux系统默认的shell解释器,bash则是sh命令的增强版,dash则是从netbsd派生而...

14、linux命令-du(linux命令-s)

14、linux命令-du常用命令du-sh/*#显示指定目录下每个文件或目录的容量大小,并且以易读方式显示(常用)。du命令概述du命令作用是估计文件系统的磁盘已使用量,常用于查看文件或目录...

Manjaro Linux:属于我的Linux体验

从Debiantesting切换回Manjarotesting前端使用好久的Manjarotesting切换到了Debiantesting,就是因为有一些包只有deb版本,适配了Debia...

小狼毫 0.17.0 更新,解锁输入新姿势!

0.17.0版本(2025年5月17日发布)这次更新可不少东西呢!先把librime升级到1.13.1版本啦,也不知道这升级之后会带来啥新变化,用用就知道咯。之前老是出问题的托...

Kali Linux 初始配置(kali linux2019默认用户名和密码)

1.更新源&升级系统sudoaptupdate&&sudoaptupgrade-ysudoaptdist-upgrade-y作用:确保所有工具和系统补丁为最新,避免...

怎样利用锤子手机和讯飞手机输入法,让电脑动起来

在大家看来,老罗的发布会捧红了科大讯飞。小编当时就被老罗洗脑了,立刻下载了讯飞输入法体验了一番。后来小编突发奇想,我经常使用向日葵远程控制电脑,如果远程控制电脑时使用讯飞,能否在电脑上完成语音输入?或...

装好KALI之后,急需做的两个事情,更新源和添加输入法

每次当我们装完系统之后,突然发现很茫然,为什么要装这个系统?也就是说我们压根儿就不知道装这个系统是为了做什么。而且刚装好的系统体验起来,好像也并没有网上说的那么好。之前想做的种种操作现在也就不了了之了...

Linux 依赖问题“硬核”解决方案 | 技术

编者按:本文介绍了一些另类的暴力破解RPM和DEB软件包依赖关系的方法,对陷入依赖陷阱而不可自拔的人来说,有时候这也是一种绝地求生之路。至于说这样做是否合适,那就是一件见仁见智的事情了,不过这...