字符串是邪恶的

2025-05-24

字符串是邪恶的

内容

将内存分配从 7.5GB 减少到 32KB

内容

  1. 问题背景
  2. 建立基线
  3. 轻松获胜1
  4. 轻松获胜2
  5. 分裂从来都不酷
  6. 清单并不总是好的
  7. 池化字节数组
  8. 再见 StringBuilder
  9. 跳过逗号
  10. 类和结构之间的战争
  11. 再见 StreamReader
  12. TLDR - 给我一张桌子

问题背景

Codeweavers是一家金融服务软件公司,我们的部分业务是帮助客户批量导入数据到我们的平台。为了提供服务,我们需要所有客户(包括英国各地的贷款机构和制造商)提供最新信息。每次导入的数据都可能包含数百兆未压缩的数据,这些数据通常每天都会导入。

这些数据随后将用于支持我们的实时计算。目前,由于会影响内存使用,此导入过程必须在工作时间以外进行。

在本文中,我们将探讨导入过程中潜在的优化方案,尤其是在减少内存占用方面。如果您想亲自尝试,可以使用此代码生成示例输入文件,所有讨论的代码都可以在这里找到

建立基线

当前实现使用StreamReader并将每一行传递给lineParser

using (StreamReader reader = File.OpenText(@"..\..\example-input.csv"))
{
    try
    {
        while (reader.EndOfStream == false)
        {
            lineParser.ParseLine(reader.ReadLine());
        }
    }
    catch (Exception exception)
    {
        throw new Exception("File could not be parsed", exception);
    }
}
Enter fullscreen mode Exit fullscreen mode

我们最初最简单的行解析器实现看起来像这样:-

public sealed class LineParserV01 : ILineParser
{
    public void ParseLine(string line)
    {
        var parts = line.Split(',');

        if (parts[0] == "MNO")
        {
            var valueHolder = new ValueHolder(line);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

该类ValueHolder稍后将在导入过程中用于将信息插入数据库:-

public class ValueHolder
{
    public int ElementId { get; }
    public int VehicleId { get; }
    public int Term { get; }
    public int Mileage { get; }
    public decimal Value { get; }

    public ValueHolder(string line)
    {
        var parts = line.Split(',');

        ElementId = int.Parse(parts[1]);
        VehicleId = int.Parse(parts[2]);
        Term = int.Parse(parts[3]);
        Mileage = int.Parse(parts[4]);
        Value = decimal.Parse(parts[5]);
    }
}
Enter fullscreen mode Exit fullscreen mode

作为命令行应用程序运行此示例并启用监控:-

public static void Main(string[] args)
{
    AppDomain.MonitoringIsEnabled = true;

    // do the parsing

    Console.WriteLine($"Took: {AppDomain.CurrentDomain.MonitoringTotalProcessorTime.TotalMilliseconds:#,###} ms");
    Console.WriteLine($"Allocated: {AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize / 1024:#,#} kb");
    Console.WriteLine($"Peak Working Set: {Process.GetCurrentProcess().PeakWorkingSet64 / 1024:#,#} kb");

    for (int index = 0; index <= GC.MaxGeneration; index++)
    {
        Console.WriteLine($"Gen {index} collections: {GC.CollectionCount(index)}");
    }
}
Enter fullscreen mode Exit fullscreen mode

我们今天的主要目标是减少分配的 内存。简而言之,分配的内存越少,垃圾收集器需要做的工作就越少。垃圾收集器针对三代对象进行操作,我们也将对其进行监控。垃圾收集是一个复杂的话题,超出了本文的讨论范围;但一个好的经验法则是,短寿命对象永远不应该被提升到第 0 代之后。

我们可以看到V01以下统计数据:-

Took: 8,750 ms
Allocated: 7,412,303 kb
Peak Working Set: 16,720 kb
Gen 0 collections: 1809
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

解析一个三百兆的文件需要分配近 7.5 GB 的内存,这显然不太理想。既然我们已经确定了基准,接下来让我们找到一些简单的解决方案……

轻松获胜1

眼尖的读者应该已经注意到了,我们用了string.Split(',')两次;一次是在行解析器中,另一次是在 的构造函数中ValueHolder。这很浪费时间,我们可以重载 的构造函数ValueHolder来接受一个string[]数组,然后在解析器中对行进行一次拆分。经过这个简单的修改后, 的统计数据V02现在是:

Took: 6,922 ms
Allocated: 4,288,289 kb
Peak Working Set: 16,716 kb
Gen 0 collections: 1046
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

太棒了!我们从 7.5GB 降到了 4.2GB。但对于处理一个 300MB 的文件来说,这仍然是一个很大的内存分配。

轻松获胜2

快速分析输入文件后发现,其中有10,047,435行,我们只对以 为前缀的行感兴趣。这意味着我们处理了多余的行,MNO而这些行是不必要的。快速修改一下,只解析以 为前缀的行10,036,46610,969V03MNO

public sealed class LineParserV03 : ILineParser
{
    public void ParseLine(string line)
    {
        if (line.StartsWith("MNO"))
        {
            var valueHolder = new ValueHolder(line);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

99.89%这意味着我们推迟拆分整行,直到我们知道它是我们感兴趣的行。不幸的是,这并没有节省太多内存。主要是因为我们只对文件中的行感兴趣。V03以下统计数据:

Took: 8,375 ms
Allocated: 4,284,873 kb
Peak Working Set: 16,744 kb
Gen 0 collections: 1046
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

现在是时候使用值得信赖的分析器了,在本例中是dotTrace:-

.NET 生态系统中的字符串是不可变的。这意味着我们对 a 所做的任何操作string 都会返回一个全新的副本。因此,调用string.Split(',')每一行(记住,有些10,036,466行是我们感兴趣的)都会返回被拆分成几个较小字符串的行。每行至少包含五个我们要处理的部分。这意味着在导入过程的生命周期中,我们至少会创建50,182,330 strings..!接下来,我们将探索如何消除 .. 的使用string.Split(',')

分裂从来都不酷

我们感兴趣的典型线条看起来像这样:-

MNO,3,813496,36,30000,78.19,,
Enter fullscreen mode Exit fullscreen mode

调用string.Split(',')上述代码将返回以下string[]内容:-

'MNO'
'3'
'813496'
'36'
'30000'
'78.19'
''
''
Enter fullscreen mode Exit fullscreen mode

现在我们可以对导入的文件做出一些保证:-

  • 每行的长度不固定
  • 逗号分隔的部分数量是固定的
  • 我们仅使用每行的前三个字符来确定我们对该行的兴趣
  • 这意味着我们感兴趣的是五个部分,但部分长度未知
  • 部分不会改变位置(例如MNO始终是第一部分)

保证建立后,我们现在可以为给定的行构建一个所有逗号位置的短期索引:-

private List<int> FindCommasInLine(string line)
{
    var list = new List<int>();

    for (var index = 0; index < line.Length; index++)
    {
        if (line[index] == ',')
        {
            list.Add(index);
        }
    }

    return list;
}
Enter fullscreen mode Exit fullscreen mode

一旦我们知道每个逗号的位置,我们就可以直接访问我们关心的部分并手动解析该部分。

private decimal ParseSectionAsDecimal(int start, int end, string line)
{
    var sb = new StringBuilder();

    for (var index = start; index < end; index++)
    {
        sb.Append(line[index]);
    }

    return decimal.Parse(sb.ToString());
}

private int ParseSectionAsInt(int start, int end, string line)
{
    var sb = new StringBuilder();

    for (var index = start; index < end; index++)
    {
        sb.Append(line[index]);
    }

    return int.Parse(sb.ToString());
}
Enter fullscreen mode Exit fullscreen mode

综合起来:-

public void ParseLine(string line)
{
    if (line.StartsWith("MNO"))
    {
        var findCommasInLine = FindCommasInLine(line);

        var elementId = ParseSectionAsInt(findCommasInLine[0] + 1, findCommasInLine[1], line); // equal to parts[1] - element id
        var vehicleId = ParseSectionAsInt(findCommasInLine[1] + 1, findCommasInLine[2], line); // equal to parts[2] - vehicle id
        var term = ParseSectionAsInt(findCommasInLine[2] + 1, findCommasInLine[3], line); // equal to parts[3] - term
        var mileage = ParseSectionAsInt(findCommasInLine[3] + 1, findCommasInLine[4], line); // equal to parts[4] - mileage
        var value = ParseSectionAsDecimal(findCommasInLine[4] + 1, findCommasInLine[5], line); // equal to parts[5] - value
        var valueHolder = new ValueHolder(elementId, vehicleId, term, mileage, value);
    }
}
Enter fullscreen mode Exit fullscreen mode

跑步V04揭示了以下统计数据:-

Took: 9,813 ms
Allocated: 6,727,664 kb
Peak Working Set: 16,872 kb
Gen 0 collections: 1642
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

哎呀,情况比想象的还要糟糕。这是一个很容易犯的错误,但 dotTrace 可以帮助我们……

为每一行的每个部分构建一个非常昂贵。幸运的是,这有一个快速解决方案,我们在构造时StringBuilder构造一个,并在每次使用前清除它。现在有以下统计数据:StringBuilderV05V05

Took: 9,125 ms
Allocated: 3,199,195 kb
Peak Working Set: 16,636 kb
Gen 0 collections: 781
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

呼,我们又回到了下降趋势。一开始是 7.5GB,现在降到了 3.2GB。

清单并不总是好的

此时,dotTrace 已成为优化过程中不可或缺的一部分。查看V05dotTrace 的输出:

构建逗号位置的短期索引开销很大。因为 any 的底层List<T>只是一个标准T[]数组。框架会在添加元素时调整底层数组的大小。这在典型场景中非常有用且非常方便。然而,我们知道需要处理六个部分(但我们只关注其中五个部分),因此至少需要为七个逗号创建索引。我们可以针对此进行优化:

private int[] FindCommasInLine(string line)
{
    var nums = new int[7];
    var counter = 0;

    for (var index = 0; index < line.Length; index++)
    {
        if (line[index] == ',')
        {
            nums[counter++] = index;
        }
    }

    return nums;
}
Enter fullscreen mode Exit fullscreen mode

V06统计数据:-

Took: 8,047 ms
Allocated: 2,650,318 kb
Peak Working Set: 16,560 kb
Gen 0 collections: 647
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

2.6GB 已经很不错了,但如果我们强制编译器使用byte这种方法,而不是编译器默认使用,会发生什么情况呢int:-

private byte[] FindCommasInLine(string line)
{
    byte[] nums = new byte[7];
    byte counter = 0;

    for (byte index = 0; index < line.Length; index++)
    {
        if (line[index] == ',')
        {
            nums[counter++] = index;
        }
    }

    return nums;
}
Enter fullscreen mode Exit fullscreen mode

重新运行V06:-

Took: 8,078 ms
Allocated: 2,454,297 kb
Peak Working Set: 16,548 kb
Gen 0 collections: 599
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

2.6GB 已经很不错了,2.4GB 甚至更好。这是因为 an的范围比 aint大得多byte

池化字节数组

V06现在有一个byte[]数组,用于保存每行每个逗号的索引。它是一个短暂存在的数组,但会被多次创建。我们可以byte[]通过使用 .NET 生态系统中的最新功能来消除为每行创建新数组的成本;Systems.Buffers。Adam Sitnik 对此进行了详细的分析,并解释了为什么应该使用它。使用时需要记住的重要一点ArrayPool<T>.Shared是,在使用完毕后必须始终归还租用的缓冲区,否则会导致应用程序内存泄漏。

看起来是这样V07的:-

public void ParseLine(string line)
{
    if (line.StartsWith("MNO"))
    {
        var tempBuffer = _arrayPool.Rent(7);

        try
        {
            var findCommasInLine = FindCommasInLine(line, tempBuffer);
            // truncated for brevity
        }
        finally
        {
            _arrayPool.Return(tempBuffer, true);
        }
    }
}

private byte[] FindCommasInLine(string line, byte[] nums)
{
    byte counter = 0;

    for (byte index = 0; index < line.Length; index++)
    {
        if (line[index] == ',')
        {
            nums[counter++] = index;
        }
    }

    return nums;
}
Enter fullscreen mode Exit fullscreen mode

V07有以下统计数据:-

Took: 8,891 ms
Allocated: 2,258,272 kb
Peak Working Set: 16,752 kb
Gen 0 collections: 551
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

从最初的 7.5GB 降到了 2.2GB。这已经很不错了,但我们还没完。

再见 StringBuilder

分析V07揭示了下一个问题:

在和解析器StringBuilder.ToString()内部调用非常昂贵。是时候弃用它,并编写我们自己的1解析器了,不再依赖字符串,也不再调用/ 。根据性能分析器显示,这应该可以节省大约 1GB 的内存。编写我们自己的解析器后,现在的运行速度为:decimalintStringBuilder intdecimalint.parse()decimal.parse()intdecimalV08

Took: 6,047 ms
Allocated: 1,160,856 kb
Peak Working Set: 16,816 kb
Gen 0 collections: 283
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

1.1GB 比我们之前的(2.2GB)有了巨大的改进,甚至比基线(7.5GB)更好。

1代码可以在这里找到

跳过逗号

直到V08我们的策略是找到每一行上每个逗号的索引,然后使用该信息创建一个子字符串,然后通过调用int.parse()/来解析该子字符串decimal.parse()V08不推荐使用子字符串,但仍然使用逗号位置的短暂索引。

另一种策略是通过计算前面逗号的数量跳到我们感兴趣的部分,然后解析所需数量的逗号之后的任何内容,并在遇到下一个逗号时返回。

我们之前已保证:-

  • 每个部分前面都有一个逗号。
  • 并且一行中每个部分的位置不会改变。

这也意味着我们可以弃用租用的byte[]数组,因为我们不再构建短期索引:-

public sealed class LineParserV09 : ILineParser
{
    public void ParseLine(string line)
    {
        if (line.StartsWith("MNO"))
        {
            int elementId = ParseSectionAsInt(line, 1); // equal to parts[1] - element id
            int vehicleId = ParseSectionAsInt(line, 2); // equal to parts[2] - vehicle id
            int term = ParseSectionAsInt(line, 3); // equal to parts[3] - term
            int mileage = ParseSectionAsInt(line, 4); // equal to parts[4] - mileage
            decimal value = ParseSectionAsDecimal(line, 5); // equal to parts[5] - value
            var valueHolder = new ValueHolder(elementId, vehicleId, term, mileage, value);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

不幸的是V09,它并没有节省我们的内存,但它确实减少了所花费的时间:-

Took: 5,703 ms
Allocated: 1,160,856 kb
Peak Working Set: 16,572 kb
Gen 0 collections: 283
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

另一个好处是V09它读起来更接近原始实现。

类和结构之间的战争

这篇博文不会讨论类和结构体的区别或优缺点。这个话题已经讨论过 很多 了。在这个特定的上下文中,使用 是有益的struct。将ValueHolder其更改为具有以下统计数据:-structV10

Took: 5,594 ms
Allocated: 768,803 kb
Peak Working Set: 16,512 kb
Gen 0 collections: 187
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

终于,我们已经低于 1GB 的限制了。另外,请注意,不要struct盲目使用,务必测试你的代码,确保用例正确。

再见 StreamReader

V10行解析器本身来看,它实际上不需要分配。dotTrace 揭示了剩余分配发生的位置:-

这有点尴尬,框架占用了我们的内存。我们可以在比StreamReader:-更低的级别与文件进行交互。

private static void ViaRawStream(ILineParser lineParser)
{
    var sb = new StringBuilder();

    using (var reader = File.OpenRead(@"..\..\example-input.csv"))
    {
        try
        {
            bool endOfFile = false;
            while (reader.CanRead)
            {
                sb.Clear();

                while (endOfFile == false)
                {
                    var readByte = reader.ReadByte();

                    // -1 means end of file
                    if (readByte == -1)
                    {
                        endOfFile = true;
                        break;
                    }

                    var character = (char)readByte;

                    // this means the line is about to end so we skip
                    if (character == '\r')
                    {
                        continue;
                    }

                    // this line has ended
                    if (character == '\n')
                    {
                        break;
                    }

                    sb.Append(character);
                }

                if (endOfFile)
                {
                    break;
                }

                var buffer = new char[sb.Length];

                for (int index = 0; index < sb.Length; index++)
                {
                    buffer[index] = sb[index];
                }

                lineParser.ParseLine(buffer);
            }
        }
        catch (Exception exception)
        {
            throw new Exception("File could not be parsed", exception);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

V11统计数据:-

Took: 5,594 ms
Allocated: 695,545 kb
Peak Working Set: 16,452 kb
Gen 0 collections: 169
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

嗯,695MB 仍然比 768MB 好。好吧,这并没有达到我预期的提升(而且相当令人失望)。不过,我们记得之前遇到过这个问题,并且已经解决了。V07我们以前经常会用这种方法ArrayPool<T>.Shared来避免很多小问题byte[]。我们也可以在这里做同样的事情:-

private static void ViaRawStream(ILineParser lineParser)
{
    var sb = new StringBuilder();
    var charPool = ArrayPool<char>.Shared;

    using (var reader = File.OpenRead(@"..\..\example-input.csv"))
    {
        try
        {
            bool endOfFile = false;
            while (reader.CanRead)
            {
                // truncated for brevity

                char[] rentedCharBuffer = charPool.Rent(sb.Length);

                try
                {
                    for (int index = 0; index < sb.Length; index++)
                    {
                        rentedCharBuffer[index] = sb[index];
                    }

                    lineParser.ParseLine(rentedCharBuffer);
                }
                finally
                {
                    charPool.Return(rentedCharBuffer, true);
                }
            }
        }
        catch (Exception exception)
        {
            throw new Exception("File could not be parsed", exception);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

的最终版本V11有以下统计数据:-

Took: 6,781 ms
Allocated: 32 kb
Peak Working Set: 12,620 kb
Gen 0 collections: 0
Gen 1 collections: 0
Gen 2 collections: 0
Enter fullscreen mode Exit fullscreen mode

是的,只分配了 32kb 内存。这正是我期待的高潮。

通过 TwitterLinkedInGitHub找到我

TLDR-给我一张桌子

版本 耗时(毫秒) 已分配(kb) 峰值工作集(kb) Gen 0 集合
01 8,750 7,412,303 16,720 1,809
02 6,922 4,288,289 16,716 1,046
03 8,375 4,284,873 16,744 1,046
04 9,813 6,727,664 16,872 1,642
05 8,125 3,199,195 16,636 781
06 8,078 2,454,297 16,548 599
07 8,891 2,258,272 16,752 551
08 6,047 1,160,856 16,816 283
09 5,703 1,160,856 16,572 283
10 5,594 768,803 16,512 187
11 6,781 三十二 12,620 0
文章来源:https://dev.to/indy_singh_uk/strings-are-evil-9f9
PREV
15+ 轻松便捷的服务,免费部署您的静态 WebApp 🤑
NEXT
Kubernetes Docker 弃用了?等等,Docker 在 Kubernetes 中已经弃用了?我该怎么办?