为什么我无法利用计算机的4GB内存处理少于2GB的C#信息?

6
场景:我需要处理超过1.5GB的文本和csv文件,以进行数学处理。我尝试使用SQL Server Express,但是即使使用BULK导入加载信息也需要很长时间,理想情况下,我需要将整个数据集存储在内存中以减少硬盘IO。
有超过120,000,000条记录,但即使我尝试将信息筛选为一个列(在内存中),我的C#控制台应用程序也会消耗约3.5GB的内存来处理只有125MB(实际读入700MB)的文本。
似乎对字符串和字符串数组的引用没有被GC收集,即使将所有引用设置为null并使用using关键字封装IDisposable。
我认为罪魁祸首是String.Split()方法,它为每个逗号分隔的值创建了一个新的字符串。
你可以建议我甚至不应该将不需要的*列读入字符串数组,但这忽略了重点:如何将这个完整的数据集放入内存中,以便我可以在C#中并行处理它?
我可以优化统计算法并使用复杂的调度算法协调任务,但这是我希望在遇到内存问题之前就能处理的事情,而不是因为内存问题而处理。
我已经包括了一个完整的控制台应用程序,模拟了我的环境,并应该有助于复制问题。
感谢您提前的帮助。
using System;
using System.Collections.Generic;
using System.Text;
using System.IO;

namespace InMemProcessingLeak
{
    class Program
    {
        static void Main(string[] args)
        {
            //Setup Test Environment. Uncomment Once
            //15000-20000 files would be more realistic
            //InMemoryProcessingLeak.GenerateTestDirectoryFilesAndColumns(3000, 3);
            //GC
            GC.Collect();
            //Demostrate Large Object Memory Allocation Problem (LOMAP)
            InMemoryProcessingLeak.SelectColumnFromAllFiles(3000, 2);
        }
    }

    class InMemoryProcessingLeak
    {
        public static List<string> SelectColumnFromAllFiles(int filesToSelect, int column)
        {
            List<string> allItems = new List<string>();
            int fileCount = filesToSelect;
            long fileSize, totalReadSize = 0;

            for (int i = 1; i <= fileCount; i++)
            {
                allItems.AddRange(SelectColumn(i, column, out fileSize));
                totalReadSize += fileSize;
                Console.Clear();
                Console.Out.WriteLine("Reading file {0:00000} of {1}", i, fileCount);
                Console.Out.WriteLine("Memory = {0}MB", GC.GetTotalMemory(false) / 1048576);
                Console.Out.WriteLine("Total Read = {0}MB", totalReadSize / 1048576);
            }
            Console.ReadLine();
            return allItems;

        }

        //reads a csv file and returns the values for a selected column
        private static List<string> SelectColumn(int fileNumber, int column, out long fileSize)
        {
            string fileIn;
            FileInfo file = new FileInfo(string.Format(@"MemLeakTestFiles/File{0:00000}.txt", fileNumber));
            fileSize = file.Length;
            using (System.IO.FileStream fs = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                using (System.IO.StreamReader sr = new System.IO.StreamReader(fs))
                {
                    fileIn = sr.ReadToEnd();
                }
            }

            string[] lineDelimiter = { "\n" };
            string[] allLines = fileIn.Split(lineDelimiter, StringSplitOptions.None);

            List<string> processedColumn = new List<string>();

            string current;
            for (int i = 0; i < allLines.Length - 1; i++)
            {
                current = GetColumnFromProcessedRow(allLines[i], column);
                processedColumn.Add(current);
            }

            for (int i = 0; i < lineDelimiter.Length; i++) //GC
            {
                lineDelimiter[i] = null;
            }
            lineDelimiter = null;

            for (int i = 0; i < allLines.Length; i++) //GC
            {
                allLines[i] = null;
            }
            allLines = null;
            current = null;

            return processedColumn;
        }

        //returns a row value from the selected comma separated string and column position
        private static string GetColumnFromProcessedRow(string line, int columnPosition)
        {
            string[] entireRow = line.Split(",".ToCharArray());
            string currentColumn = entireRow[columnPosition];
            //GC
            for (int i = 0; i < entireRow.Length; i++)
            {
                entireRow[i] = null;
            }
            entireRow = null;
            return currentColumn;
        }

        #region Generators
        public static void GenerateTestDirectoryFilesAndColumns(int filesToGenerate, int columnsToGenerate)
        {
            DirectoryInfo dirInfo = new DirectoryInfo("MemLeakTestFiles");
            if (!dirInfo.Exists)
            {
                dirInfo.Create();
            }
            Random seed = new Random();

            string[] columns = new string[columnsToGenerate];

            StringBuilder sb = new StringBuilder();
            for (int i = 1; i <= filesToGenerate; i++)
            {
                int rows = seed.Next(10, 8000);
                for (int j = 0; j < rows; j++)
                {
                    sb.Append(GenerateRow(seed, columnsToGenerate));
                }
                using (TextWriter tw = new StreamWriter(String.Format(@"{0}/File{1:00000}.txt", dirInfo, i)))
                {
                    tw.Write(sb.ToString());
                    tw.Flush();
                }
                sb.Remove(0, sb.Length);
                Console.Clear();
                Console.Out.WriteLine("Generating file {0:00000} of {1}", i, filesToGenerate);
            }
        }

        private static string GenerateString(Random seed)
        {
            StringBuilder sb = new StringBuilder();
            int characters = seed.Next(4, 12);
            for (int i = 0; i < characters; i++)
            {
                sb.Append(Convert.ToChar(Convert.ToInt32(Math.Floor(26 * seed.NextDouble() + 65))));
            }
            return sb.ToString();
        }

        private static string GenerateRow(Random seed, int columnsToGenerate)
        {
            StringBuilder sb = new StringBuilder();

            sb.Append(seed.Next());
            for (int i = 0; i < columnsToGenerate - 1; i++)
            {
                sb.Append(",");
                sb.Append(GenerateString(seed));
            }
            sb.Append("\n");

            return sb.ToString();
        }
        #endregion
    }
}

*这些其他列将在程序的整个生命周期中被顺序和随机访问,因此每次从磁盘读取都是一个巨大的负担。

**环境说明:4GB DDR2 SDRAM 800、Core 2 Duo 2.5Ghz、.NET Runtime 3.5 SP1、Vista 64。


除了下面的答案外,我注意到你使用了基于数组的List<T>。据我所知,数组大小每次增加到其当前容量的两倍。因此,一旦达到特定限制,这可能会造成很大的障碍。 - J. Tihon
3个回答

14

是的,String.Split会为每个“片段”创建一个新的字符串对象-那就是它的作用。

现在,请记住,.NET中的字符串是Unicode(实际上是UTF-16),加上对象开销,每个字符串的字节成本大约是20 + 2*n,其中n是字符数。

这意味着,如果您有大量小字符串,与涉及文本数据的大小相比,它将占用大量内存。例如,将80个字符的行拆分为10个8个字符的字符串将在文件中占用80个字节,但在内存中却占用了10 *(20 + 2 * 8)= 360个字节-4.5倍的膨胀!

我怀疑这不是垃圾回收问题-当不需要时,我建议您删除额外的语句将变量设置为null-只是太多数据的问题。

建议的是,您逐行读取文件(使用TextReader.ReadLine()而不是TextReader.ReadToEnd())。显然,如果您不需要整个文件,则将其全部存储在内存中是浪费的。


非常有用的答案。正如MSalters所建议的那样,如果我想一次处理所有信息,似乎需要以不同的方式表示数据。 - exceptionerror
是的 - 尽管最终仍会遇到问题。如果您能够找出一种以流式处理数据的方式,那么解决方案将具有更好的可扩展性。 - Jon Skeet
你会推荐类似“push” linq 这样的东西,以便我可以提取跨文件的关系信息而无需循环吗? - exceptionerror
这取决于你需要做什么,但是是的,Push LINQ非常适合在大数据集上进行聚合。 - Jon Skeet

3
我建议逐行阅读而不是整个文件,或者阅读1-2MB的块。
更新: 根据Jon的评论,我进行了4种方法的实验:
- StreamReader.ReadLine(默认和自定义缓冲区大小), - StreamReader.ReadToEnd - 上面列出的我的方法。
读取一个180MB的日志文件:
- ReadLine 毫秒:1937 - ReadLine 更大的缓冲区,ascii 毫秒:1926 - ReadToEnd 毫秒:2151 - 自定义毫秒:1415
自定义的StreamReader是:
StreamReader streamReader = new StreamReader(fileStream, Encoding.Default, false, 16384)

StreamReader的缓冲区默认为1024。

关于内存消耗(实际问题!)-使用了约800mb。而我提供的方法仍然使用StringBuilder(它使用字符串),因此内存消耗不会更少。


我强烈建议使用“using”语句来避免在发生异常时保持流处于打开状态,并将“bytesRead”重命名为“charactersRead”。 - Jon Skeet
我会编辑我的答案,以解决自己的矛盾并使用您的建议更新那个3年前的代码。16384缓冲区大小是主要的不同之处,这来自于在microsoft.public.dotnet.languages.csharp上讨论C ++与C#文本大小性能的讨论。 - Chris S
TextReader和StreamReader按字节读取,当我使用它们读取1.5MB的日志文件并逐行解析时,速度非常慢。 - Chris S
这是Jon的代码:http://pastebin.com/m6e8a98bd。它很简陋,但我每次都能得到大致相同的结果。我不知道为什么它更快,但我的猜测是在循环中没有对\n和\r进行分支,并使用Decoder进行解码。 - Chris S
4个单独的文件而不是重新启动。ReadLine方法在前两次尝试时更快,然后使用char[]方法的时间大大减少。 - Chris S
显示剩余2条评论

2
现代GC语言利用大量廉价内存来卸载内存管理任务。这会带来一定的开销,但是你的典型业务应用程序实际上并不需要那么多信息。许多程序只需要少于一千个对象就可以运行。手动管理这么多对象是一件繁琐的工作,但即使每个对象有一千字节的开销也不会有影响。
在你的情况下,每个对象的开销正在成为一个问题。例如,您可以将每个列表示为一个对象,使用单个字符串和整数偏移数组实现。要返回单个字段,只需返回子字符串(可能作为桥接器)。

似乎我已经用尽了可用的C#最佳实践,而你的答案指向了下一个最佳选择。我真的很喜欢C#,但我想知道,如果将来遇到其他类似这样的数据密集型挑战,学习和使用C++/CLI是否是个好主意。 - exceptionerror
请考虑使用本地的C++;在这些情况下它可以非常高效。是的,你需要编写很多代码来实现C#中已经包含的功能。但这正是关键所在;你是为数不多的无法承担.Net默认值的人之一。 - MSalters
几年前,当我尝试使用一种一次性的 .net 数据库转换工具时,我遇到了非常类似的问题。我无法使 .net 版本快速运行,但一个非常简单的 c++ OLEDB 应用程序却可以快速运行。我认为我使用的 .net 库在内存方面非常低效。 - gbjbaanb
差别从10小时缩短到了大约10分钟。我知道可能是我的问题,但我尝试了我能想到的一切来让它更快地运行。有时候你只需要更好的工具来完成某些工作。 - gbjbaanb

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接