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[]DataTableMemoryStream 等中并作为参数进行某种操作(比如压缩,如上述代码中的 Zip.Compress(byte[] 对象)),那么你仍然无法节约内存。你必须确保你对流的操作是分块流式进行的。

压缩是典型的非流式操作,因为压缩算法几乎都是基于上下文的,而不只是当前位置。

使用外部命令代替大内存操作

编写另外一个可执行文件,这个文件不能是 dll,so 等模块,保证其内存空间的独立,然后将耗费内存的操作放在这个程序中,将数据的位置作为参数。好处是这个程序处完数据会退出,所有资源得到了充分的释放。

为什么不使用 GC.Collect()

  • GC.Collect() 进行全代垃圾回收,暂停所有线程的运行,而且资源消耗大,对于对速度要求高的程序(比如服务器端程序)不太合适
  • GC 未必可以正确的回收所有垃圾(如本文代码在 Mono 环境下就无法正常回收)
  • 外置程序可以在多核计算机中和原服务器中并行执行,因为数据处理很多时候是高 IO 操作,不会卡住服务器程序,逻辑简单性能又高

通过弱引用(WeekReference)管理缓存

这并不是标准库推荐的做法,但是如果你想简单的防止程序缓存占用过多的资源,可以使用它。详细介绍可以移步 MSDN。

题外话:我帮你整理了包括 AI 写作、绘画、视频(自媒体制作)零门槛 AI 课程 + 国内可直接顺畅使用的软件。想让自己快速用上 AI 工具来降本增效,辅助工作和生活?限时报名

当前页阅读量为: