Mono 环境下跟踪和优化 .NET 程序内存分配
.NET 程序在 Mono 环境之下的内存分配和在 Microsoft .NET Framework 环境是不太相同的,虽然它们的兼容性很高,但仍有区别。尤其是在 GC 方面,Mono 的 GC 实现似乎仍有不足(在 3.0.6 版本下),据我观察,有一些大对象(估计是二代对象,譬如很大的 byte[]
数组)的释放仍然有些问题。下面就以 TCP 服务器端程序为例。
场景
假设在服务器端有这样的语句,用来备份 SQLite 数据库文件:
private void BackupDB(string dest)
{
byte[] content = getAllContent(this.DBFile);
File.WriteAllBytes(dest, Zip.Compress(content));
content = null;
}
private byte[] getAllContent(string path)
{
try
{
byte[] b;
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
b = new byte[fs.Length];
fs.Read(b, 0, b.Length);
}
return b;
}
catch
{
throw new Exception("数据库正在读写,请稍后再备份。");
}
}
备份数据库需要读取整个数据库。这段代码直接将整个数据库的本地文件镜像读出,并放在了一个 Byte[]
数组之中,作为 getAllContent
函数的返回值。在读取之后, getAllContent
方法的 b
中存储了大量的字节。另外,在 BackupDB
函数中,使用 Zip.Compress
函数压缩数据库后,也会返回一个较大的 byte[] 数组。
经过测试,这段代码在 Windows 的 .NET Framework 平台下工作没有问题。因为这些大对象可以几乎在使用后即刻被销毁。但是,在 Mono 下,即使在代码的最后显式调用 GC.Collect()
方法进行全代垃圾回收,也无法减少内存。
使用 Profiler 跟踪内存分配和垃圾回收
虽然我们可能不知道原因出在哪里,但是我们可以通过 Mono 自带的工具来跟踪内存分配。这里用到的工具是 Profiler,这个工具只支持 Mono 2.9+,如果你使用的发行版过于稳定,可能需要安装测试版本的 Mono 或手工编译安装 Mono 才可以使用这个工具。
Profiler 特别像在 Visual Studio 中的「性能分析」工具。它们的原理也大同小异。它们都是通过运行时采样和日志,来跟踪程序运行的状态。
具体来说,Profiler 会在托管程序运行时记录这些内容:
- 方法进入和退出
- 对象分配
- 垃圾回收
- JIT 编译
- 元数据加载
- 锁上下文
- 异常
另外,它还提供了堆快照的功能,可以在你的程序进行过一次垃圾回收之后,记录下目前托管堆的情况。
使用 Profile 日志
要使用 Profiler 进行分析,首先要以附加参数启动 Mono 代码:
mono –profile=log program.exe
在程序运行结束后,在当前目录下会生成一个 output.mlpd
文件。这个文件稍后可以被用来做性能分析。在程序运行结束之后,输入命令
mprof-report output.mlpd
会生成分析报告。
你也可以使用一些参数,常用参数有
mono –profile=log:report program.exe
这样可以不保留日志,而是直接产生分析报告,有时候这可能很有用,因为日志文件实在是太大了。
mono –profile=log:noalloc program.exe
noalloc
会导致日志中不记录分配情况。
mono –profile=log:nocalls program.exe
nocalls
不记录调用方法。如果不使用 nocalls,要预留足够大的硬盘空间。在我的实验中,几分钟产生了 500MB 甚至 2GB 的数据(即使取消一些日志,文件仍然很大)。
mono –gc=sgen –profile=log:heapshot program.exe
这可以记录托管堆的快照。
mono –profile=log:sample program.exe
CPU 采样,和 .NET 自带的特别像,通过一般不超过 10% 的性能损耗,在运行时动态采样,以便获取关于 CPU 时间消耗在什么运算中的第一手资料。
分析日志
现在我们开始分析日志,输入命令
mprof-report output.mlpd
即可看到结果。
详细的 Mono Profiler 使用方法请参考它的官方文档。
两处优化
使用 Stream 代替 byte[]
如果可能,在操作大量 byte[]
数据的地方,考虑使用 Stream 对象。比如,如果你使用 FileStream 操作大文件,你将不需要将文件性一次读入内存。进行此项优化成功节约了 20% 的内存。但是要注意:
- 确保 Stream 的回收,尽可能使用
using
关键字来管理 Stream 的释放 - 如果你使用的是
MemoryStream
,其内部实现其实是一个动态变长的byte[]
数组,当它增长到最大的时候,文件仍然是完全放在了内存之中。 - 最重要的是,你对数据的处理必须是流式的,如果你只是把 FileStream 分块读入到一个
byte[]
,DataTable
,MemoryStream
等中并作为参数进行某种操作(比如压缩,如上述代码中的 Zip.Compress(byte[] 对象)),那么你仍然无法节约内存。你必须确保你对流的操作是分块流式进行的。
压缩是典型的非流式操作,因为压缩算法几乎都是基于上下文的,而不只是当前位置。
使用外部命令代替大内存操作
编写另外一个可执行文件,这个文件不能是 dll,so 等模块,保证其内存空间的独立,然后将耗费内存的操作放在这个程序中,将数据的位置作为参数。好处是这个程序处完数据会退出,所有资源得到了充分的释放。
为什么不使用 GC.Collect()
- GC.Collect() 进行全代垃圾回收,暂停所有线程的运行,而且资源消耗大,对于对速度要求高的程序(比如服务器端程序)不太合适
- GC 未必可以正确的回收所有垃圾(如本文代码在 Mono 环境下就无法正常回收)
- 外置程序可以在多核计算机中和原服务器中并行执行,因为数据处理很多时候是高 IO 操作,不会卡住服务器程序,逻辑简单性能又高
通过弱引用(WeekReference)管理缓存
这并不是标准库推荐的做法,但是如果你想简单的防止程序缓存占用过多的资源,可以使用它。详细介绍可以移步 MSDN。
题外话:我帮你整理了包括 AI 写作、绘画、视频(自媒体制作)零门槛 AI 课程 + 国内可直接顺畅使用的软件。想让自己快速用上 AI 工具来降本增效,辅助工作和生活?限时报名。
© 转载需附带本文链接,依据 CC BY-NC-SA 4.0 发布。