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。

在 Silverlight 中绘制扇形

        /// <summary>
        /// 获得扇形的 Path 对象。
        /// </summary>
        /// <param name="x">图形左上角的坐标之 x。</param>
        /// <param name="y">图形左上角的坐标之 y。</param>
        /// <param name="rotationAngle">扇形的角度。</param>
        /// <param name="radius">扇形的半径大小,更改其值会影响扇形大小。</param>
        /// <param name="angle">扇形张开的角度,支持0 - 360 度,使用弧度。</param>
        /// <returns></returns>
        public static Path GetSectorPath(double x, double y, double rotationAngle, double radius, double angle, Color col)
        {
            // 对整圆的特殊处理
            if (angle > Math.PI * 1.9999)
                angle = Math.PI * 1.9999;
 
            var path = new Path
            {
                Fill = new SolidColorBrush(col),
                RenderTransformOrigin = new Point(radius, radius),
                Stretch = Stretch.None,
                UseLayoutRounding = false
            };
 
            //绘制起始直线
            var startLine = new LineSegment()
            {
                Point = new Point(radius * 2, radius)
            };
            //绘制弧线
            var arcLine = new ArcSegment()
            {
                IsLargeArc = angle > Math.PI ? true : false,
                Size = new Size(radius, radius),
                Point = new Point(radius * Math.Cos(angle) + radius, radius * Math.Sin(angle) + radius),
                SweepDirection = SweepDirection.Clockwise
            };
            //绘制结束直线
            var endLine = new LineSegment()
            {
                Point = new Point(radius, radius)
            };
            //把三条线段集合在一起。
            var segments = new PathSegmentCollection { startLine, arcLine, endLine };
 
            //为直线的Data赋值,由三段封闭的线段绘制成一个扇形
            path.Data = new PathGeometry()
            {
                Figures = new PathFigureCollection() { new PathFigure()
                                                           {
                                                               StartPoint = new Point(radius, radius),
                                                               Segments = segments
                                                           }}
            };
 
            //设置扇形对称轴偏转角度。
            path.RenderTransform = new CompositeTransform()
            {
                Rotation = rotationAngle,
                CenterX = radius, // 很重要,设置旋转中心。在 Path.RenderTransformOrigen 设置无效。
                CenterY = radius
            };
 
            path.SetValue(Canvas.LeftProperty, x);
            path.SetValue(Canvas.TopProperty, y);
            return path;
        }

这段代码的主要思路如下:
1、使用 Path 对象,组合两条边和一个弧来形成扇形。
2、弧线和边均以 X 轴正方向为基准绘制。(一条边为与 X 轴正方向重合的边,其他线条以此为起点顺时针方向绘制)
3、绘制完成后,使用 Path 的 RenderTransform 属性,将其旋转到合适的开始角度。
4、使用 Sin 和 Cos 函数计算相关坐标。

C# : 新台幣轉換為金額大寫(轉國字大寫)

最近在做一個項目的台灣本地化,需要用到新台幣的金額大寫。用 .NET 實現了轉換金額大寫的 RMB(人民幣)和 NTD(新台幣)方法。

經過測試,該方法很可靠。

注意:轉換出的結果為台灣正體。如果需要使用人民幣的大陸簡體大寫寫法,需要轉換為簡體即可。單擊此處瞭解如何進行簡繁轉換。

        /// <summary>
        /// 將新台幣或人民幣轉換為金額大寫形式。
        /// </summary>
        /// <param name="d" />要轉換的數值</param>
        /// <returns>返回轉換的結果。</returns>
        public static string GetMoneyUpper(decimal d, MoneyType moneyType)
        {
            string o = d.ToString();
            string dq = "", dh = "";
            if (o.Contains("."))
            {
                dq = o.Split('.')[0];
                dh = o.Split('.')[1];
            }
            else
            {
                dq = o;
            }
            string ret = GetMoney(dq, true, "圓", moneyType) + GetMoney(dh, false, "", moneyType);
            if (ret.Contains("厘") || ret.Contains("分"))
                return cc.ToTraditional(ret);
            if (moneyType != MoneyType.NTD)
            {
                ret = ret.Replace("參", "叁");
            }
            return cc.ToTraditional(ret + "整");
        }
        private static string GetMoney(string number, bool left, string lastdw, MoneyType type) // 內部函數。
        {
            string[] NTD  = new string[10] { "零", "壹", "貳", "參", "肆", "伍", "陸", "柒", "捌", "玖" };
            string[] DW = new string[8] { "厘", "分", "角", "", "拾", "佰", "仟", "萬" };
            int first = 4;
            string str = "";
            if (!left)
            {
                first = 1;
                if (number.Length == 1)
                {
                    number += "00";
                }
                else if (number.Length == 2)
                {
                    number += "0";
                }
                else number = number.Substring(0, 3);
 
            }
            else
            {
                if (number.Length >= 9)
                {
                    return GetMoney(number.Substring(0, number.Length - 8), true, "億", type) + GetMoney(number.Substring(number.Length - 8, 8), true, "圓", type);
                }
                if (number.Length >= 5)
                {
                    return GetMoney(number.Substring(0, number.Length - 4), true, "萬", type) + GetMoney(number.Substring(number.Length - 4, 4), true, "圓", type);
                }
            }
            bool has0 = false;
            for (int i = 0; i < number.Length; ++i)
            {
                int w = number.Length - i + first - 2;
                if (int.Parse(number[i].ToString()) == 0)
                {
                    has0 = true;
                    continue;
                }
                else
                {
                    if (has0)
                    {
                        if (type == MoneyType.RMB)
                            str += "零";
                        has0 = false;
                    }
                }
                str += NTD [int.Parse(number[i].ToString())];
                str += DW[w];
            }
            if (left)
                str += lastdw;
            return str;
        }

    public enum MoneyType
    {
        /// <summary>
        /// 表示新台幣。
        /// </summary>
        NTD,
        /// <summary>
        /// 表示人民幣。
        /// </summary>
        RMB
    }

C#中文简繁转换的类

本文英文版:http://ceeji.net/en/blog/2010/03/c-convert-between-simplified-chinese-and-traditional-chinese/

简体中文 (Simplified Chinese)和繁体中文(传统汉字,Traditional Chinese)之间的转换,单纯使用字字对照转换是绝对无法准确的。所以,在使用下面的方法进行了 C# 简繁转换后,务必对转换结果进行修正,才能保证可以使用。
修正主要包括:
1、一对多关系时根据词义换用字;
2、两岸(或港台和内地)词汇差异,如“网上邻居”和“網路上的芳鄰”、“最终用户许可协议”和“使用者授權合約”。

    public static class SystemChineseConverter
    {
        internal const int LOCALE_SYSTEM_DEFAULT = 0x0800;
        internal const int LCMAP_SIMPLIFIED_CHINESE = 0x02000000;
        internal const int LCMAP_TRADITIONAL_CHINESE = 0x04000000;
 
        /// <summary> 
        /// 使用OS的kernel.dll做為簡繁轉換工具,只要有裝OS就可以使用,不用額外引用dll,但只能做逐字轉換,無法進行詞意的轉換 
        /// <para>所以無法將電腦轉成計算機</para> 
        /// </summary> 
        [DllImport("kernel32", CharSet = CharSet.Auto, SetLastError = true)]
        internal static extern int LCMapString(int Locale, int dwMapFlags, string lpSrcStr, int cchSrc, [Out] string lpDestStr, int cchDest);
 
        /// <summary> 
        /// 繁體轉簡體 
        /// </summary> 
        /// <param name="pSource">要轉換的繁體字:體</param> 
        /// <returns>轉換後的簡體字:体</returns> 
        public static string ToSimplified(string pSource)
        {
            String tTarget = new String(' ', pSource.Length);
            int tReturn = LCMapString(LOCALE_SYSTEM_DEFAULT, LCMAP_SIMPLIFIED_CHINESE, pSource, pSource.Length, tTarget, pSource.Length);
            return tTarget;
        }
 
        /// <summary> 
        /// 簡體轉繁體 
        /// </summary> 
        /// <param name="pSource">要轉換的繁體字:体</param> 
        /// <returns>轉換後的簡體字:體</returns> 
        public static string ToTraditional(string pSource)
        {
            String tTarget = new String(' ', pSource.Length);
            int tReturn = LCMapString(LOCALE_SYSTEM_DEFAULT, LCMAP_TRADITIONAL_CHINESE, pSource, pSource.Length, tTarget, pSource.Length);
            return tTarget;
        }
    }

C#中取程序运行目录和某文件所在目录的方法

本文英文版:http://ceeji.net/en/blog/2010/03/csharp-how-to-get-the-executablepath-without-filename/

        /// <summary>
        /// 返回程序运行路径,带\
        /// </summary>
        /// <returns>程序运行路径,带\。</returns>
        public static string GetPath()
        {
            string str = System.Windows.Forms.Application.ExecutablePath;
            return str.Substring(0, str.LastIndexOf('\\') + 1);
        }
        /// <summary>
        /// 获得指定文件的路径,带\。
        /// </summary>
        /// <param name="str"></param>
        /// <returns></returns>
        public static string GetPath(string str)
        {
            return str.Substring(0, str.LastIndexOf('\\') + 1);
        }
1 2 3