使用C#流读取大型文本文件

112
我得承担起处理大文件被加载到我们应用程序脚本编辑器(它类似于我们内部产品的VBA,用于快速宏)的可爱任务。大多数文件大小在300-400 KB左右,这样加载是没有问题的。但当它们超过100 MB时,这个过程就会变得困难(正如你所预料的那样)。
发生的情况是,文件被读取并推入一个RichTextBox中,然后进行导航 - 不要太担心这部分内容。
最初编写代码的开发人员只是使用StreamReader并执行以下操作:
[Reader].ReadToEnd()

这可能需要相当长的时间才能完成。

我的任务是将这段代码分解,将其分块读入缓冲区并显示进度条,同时提供取消选项。

一些假设:

  • 大多数文件的大小为30-40 MB
  • 文件内容为文本(而非二进制),有些是Unix格式,有些是DOS格式。
  • 一旦获取到内容,我们会确定使用的终止符。
  • 加载到richtextbox中后的渲染时间不是问题,只是初始文本加载时间较长。

现在是问题:

  • 我可以简单地使用StreamReader,然后检查Length属性(因此ProgressMax),发出一次针对固定缓冲区大小的读取,并在while循环内迭代,同时在后台工作器中进行,以便不阻塞主UI线程?完成后将stringbuilder返回给主线程。
  • 内容将被放入StringBuilder。如果长度可用,我可以使用流的大小初始化StringBuilder吗?

在您的专业意见中,这些是好主意吗?过去我曾遇到从流中读取内容时的一些问题,因为它总是会错过最后几个字节或其他东西,但如果是这种情况,我会问另一个问题。


32
30-40MB的脚本文件?天哪!我可不想审核那个…… - dthorpe
我知道这个问题有点老了,但是我前几天发现了它,并测试了MemoryMappedFile的建议,这无疑是最快的方法。通过readline方法读取一个7,616,939行345MB文件的比较,在我的机器上需要12个小时以上,而通过MemoryMappedFile执行相同的加载和读取只需要3秒钟。 - csonon
这只是几行代码而已。看看我正在使用的库,可以读取25GB及以上的大文件。https://github.com/Agenty/FileReader/ - Vikash Rathee
@VikashRathee 那个库使用 foreach (string line in File.ReadLines(path).Skip(skip))。那太糟糕了。 - mafu
13个回答

202

您可以通过使用 BufferedStream 来提高读取速度,像这样:

using (FileStream fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (BufferedStream bs = new BufferedStream(fs))
using (StreamReader sr = new StreamReader(bs))
{
    string line;
    while ((line = sr.ReadLine()) != null)
    {

    }
}

2013年3月更新

我最近编写了代码以读取和处理(在其中搜索文本)1 GB 左右的文本文件(比所涉及的文件要大得多),通过使用生产者/消费者模式实现了显著的性能提升。生产者任务使用 BufferedStream 读取文本行并将其传递到单独的消费者任务中进行搜索。

我利用这个机会学习了 TPL Dataflow,它非常适合快速编码这种模式。

为什么 BufferedStream 更快

缓冲区是内存中用于缓存数据的一块字节块,从而减少对操作系统的调用次数。缓冲区可以改善读写性能。一个缓冲区可以用于读取或写入,但不能同时进行。 BufferedStream 的 Read 和 Write 方法自动维护缓冲区。

2014年12月更新:效果因人而异

根据评论,FileStream 应该在内部使用 BufferedStream。当此答案首次提供时,我通过添加 BufferedStream 测量得到了显着的性能提升。当时我针对的是 .NET 3.x 平台上的 32 位系统。今天,针对 .NET 4.5 平台上的 64 位系统,我没有看到任何改善。

相关

我遇到了一个情况,即从 ASP.Net MVC 动作将大型生成的 CSV 文件流式传输到 Response 流时非常缓慢。在这种情况下,添加 BufferedStream 可以将性能提高 100 倍。有关更多信息,请参见 Unbuffered Output Very Slow


13
哥们,使用BufferedStream大不相同。+1 :) - Marcus
2
从IO子系统请求数据是有成本的。在旋转磁盘的情况下,您可能需要等待盘片旋转到位置以读取下一个数据块,或者更糟糕的是,等待磁头移动。虽然SSD没有机械部件来减慢速度,但访问它们仍然存在每个IO操作的成本。缓冲流读取的不仅仅是StreamReader请求的内容,从而减少了对操作系统的调用次数,最终减少了单独的IO请求次数。 - Eric J.
4
真的吗?在我的测试场景中这没有任何区别。根据 Brad Abrams 的说法,使用 BufferedStream 而不是 FileStream 没有任何好处。 - Nick Cox
3
您的结果可能因底层输入/输出子系统而异。在旋转磁盘和磁盘控制器上,如果数据不在其缓存中(并且还未被Windows缓存),则速度提升会非常大。Brad的专栏是在2004年撰写的。我最近测量了实际、显著的改进。 - Eric J.
4
根据这篇文章:https://dev59.com/nHRB5IYBdhLWcg3w1Kv0,这个是没用的。 FileStream已经在内部使用了缓冲区。 - Erwin Mayer
显示剩余21条评论

33

如果您阅读此网站上的性能和基准统计数据, 您会发现读取文本文件最快的方法是以下代码片段:

using (StreamReader sr = File.OpenText(fileName))
{
    string s = String.Empty;
    while ((s = sr.ReadLine()) != null)
    {
        //do your stuff here
    }
}

总共测试了大约9种不同的方法,但其中一种似乎在大多数情况下表现最好,甚至比其他读取器(如缓冲读取器)表现更好,正如其他读者所提到的那样。

2
这对于将一个19GB的postgres文件拆分成多个文件并将其转换为SQL语法非常有效。感谢那位从未正确执行我的参数的postgres人员。/叹气 - Damon Drake
这里的性能差异似乎对于真正大的文件非常值得,比如大于150MB(同时你应该使用StringBuilder将它们加载到内存中,因为它加载速度更快,不会在每次添加字符时创建新字符串)。 - Joshua G
网站上的基准测试太有缺陷了,根本没有对不同的技术进行排列组合来进行测试,所以毫无用处。 - mafu

16

你说你被要求在加载大文件时显示进度条。这是因为用户真的想看到文件加载的精确百分比,还是只是想要视觉反馈以表明有事情正在发生?

如果后者是真的,那么解决方案就变得简单多了。只需在后台线程上执行 reader.ReadToEnd(),并显示一个跑马灯类型的进度条,而不是一个正确的进度条。

我提出这个观点是因为在我的经验中,这种情况经常发生。当您编写数据处理程序时,用户肯定会对完成率感兴趣,但对于简单但缓慢的UI更新,他们更有可能只是想知道计算机没有崩溃。 :-)


3
用户能取消 ReadToEnd 调用吗? - Tim Scarborough
1
@Tim,发现得好。在这种情况下,我们回到了StreamReader循环。然而,它仍然会更简单,因为没有必要提前读取以计算进度指示器。 - Christian Hayter

9

使用后台工作线程仅读取有限数量的行。只有当用户滚动时才读取更多内容。

尽量不要使用ReadToEnd()。它是你会想“他们为什么要这么做?”的函数之一;它是脚本小子的帮手,适用于小型文件,但正如你所见,对于大型文件来说很糟糕...

那些告诉你使用StringBuilder的人需要更经常地阅读MSDN:

性能考虑 Concat和AppendFormat方法都将新数据连接到现有的String或StringBuilder对象。String对象的连接操作总是从现有字符串和新数据创建一个新对象。StringBuilder对象维护一个缓冲区,以容纳新数据的连接。如果有空间可用,则将新数据附加到缓冲区的末尾;否则,将分配一个新的、更大的缓冲区,将原始缓冲区中的数据复制到新缓冲区中,然后将新数据附加到新缓冲区。 对于String或StringBuilder对象的连接操作的性能取决于内存分配发生的频率。 String连接操作总是分配内存,而StringBuilder连接操作仅在StringBuilder对象缓冲区太小无法容纳新数据时分配内存。因此,如果连接固定数量的String对象,则String类更适合连接操作。在这种情况下,编译器甚至可以将各个连接操作合并为单个操作。如果连接任意数量的字符串,例如,如果循环连接随机数量的用户输入字符串,则StringBuilder对象更适合连接操作。
那意味着需要大量的内存分配,这会导致大量使用交换文件系统,模拟硬盘驱动器的部分行为像RAM内存,但硬盘驱动器非常缓慢。
对于单用户使用系统的人,StringBuilder选项看起来不错,但当你有两个或更多用户同时读取大文件时,就会出现问题。

你们真的太快了!不幸的是,由于宏的工作方式,整个流需要被加载。就像我之前提到的那样,请不要担心 rich text 部分。我们想要改进的是初始加载。 - Nicole Lee
所以你可以分部分工作,先读取前X行,应用宏,再读取第二个X行,应用宏,以此类推...如果您解释一下这个宏的作用,我们可以更精确地帮助您。 - Tufo

8
对于二进制文件,我发现最快的读取方式是这样的。
 MemoryMappedFile mmf = MemoryMappedFile.CreateFromFile(file);
 MemoryMappedViewStream mms = mmf.CreateViewStream();
 using (BinaryReader b = new BinaryReader(mms))
 {
 }

在我的测试中,它快了数百倍。


3
你有任何确凿的证据吗?为什么OP应该选择这个答案而不是其他答案?请深挖一些,给出更多细节。 - Dylan Corriveau
它比文件流慢大约10-20毫秒。 - Gray Programmerz

6
这应该足以让您开始了解。
class Program
{        
    static void Main(String[] args)
    {
        const int bufferSize = 1024;

        var sb = new StringBuilder();
        var buffer = new Char[bufferSize];
        var length = 0L;
        var totalRead = 0L;
        var count = bufferSize; 

        using (var sr = new StreamReader(@"C:\Temp\file.txt"))
        {
            length = sr.BaseStream.Length;               
            while (count > 0)
            {                    
                count = sr.Read(buffer, 0, bufferSize);
                sb.Append(buffer, 0, count);
                totalRead += count;
            }                
        }

        Console.ReadKey();
    }
}

7
我会将“var buffer = new char[1024]”移出循环:没必要每次都创建新的缓冲区。只需把它放在“while (count > 0)”之前即可。 - Tommy Carlier

5
所有的回答都非常好!但是,对于寻找答案的人而言,这些回答似乎有点不够完整。
标准字符串只能有大小X,取决于您的配置,一般为2GB到4GB。这些回答并不能真正满足提问者的问题。一种方法是使用字符串列表:
List<string> Words = new List<string>();

using (StreamReader sr = new StreamReader(@"C:\Temp\file.txt"))
{

string line = string.Empty;

while ((line = sr.ReadLine()) != null)
{
    Words.Add(line);
}
}

在处理时,有些人可能希望对行进行标记化和拆分。现在的字符串列表可能包含非常大量的文本。


5

虽然最被赞同的回答是正确的,但它缺乏使用多核处理的内容。在我的情况下,我使用 PLink,因为我有12个核心:

Parallel.ForEach(
    File.ReadLines(filename), //returns IEumberable<string>: lazy-loading
    new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
    (line, state, index) =>
    {
        //process line value
    }
);

值得一提的是,我曾在面试中被问到如何返回出现次数最多的前十个元素。
var result = new ConcurrentDictionary<string, int>(StringComparer.InvariantCultureIgnoreCase);
Parallel.ForEach(
    File.ReadLines(filename),
    new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
    (line, state, index) =>
    {
        result.AddOrUpdate(line, 1, (key, val) => val + 1);        
    }
);

return result
    .OrderByDescending(x => x.Value)
    .Take(10)
    .Select(x => x.Value);

Benchmarking: BenchmarkDotNet=v0.12.1, 操作系统=Windows 10.0.19042 Intel Core i7-8700K CPU 3.70GHz (Coffee Lake), 1个CPU,12逻辑和6物理核心 [主机]:.NET Framework 4.8 (4.8.4250.0),X64 RyuJIT DefaultJob:.NET Framework 4.8 (4.8.4250.0),X64 RyuJIT

方法 平均值 误差 标准差 Gen 0 Gen 1 Gen 2 已分配内存
GetTopWordsSync 33.03秒 0.175秒 0.155秒 1194000 314000 7000 7.06 GB
GetTopWordsParallel 10.89秒 0.121秒 0.113秒 1225000 354000 8000 7.18 GB

正如您所看到的,性能提高了75%。

但请注意,这7GB立即加载到内存中,由于它是一个blob,因此对GC施加了太大的压力。


5

请看以下的代码片段。你提到了大多数文件大小将会是30-40MB。这段代码声称在Intel Quad Core上可以在1.4秒内读取180MB:

private int _bufferSize = 16384;

private void ReadFile(string filename)
{
    StringBuilder stringBuilder = new StringBuilder();
    FileStream fileStream = new FileStream(filename, FileMode.Open, FileAccess.Read);

    using (StreamReader streamReader = new StreamReader(fileStream))
    {
        char[] fileContents = new char[_bufferSize];
        int charsRead = streamReader.Read(fileContents, 0, _bufferSize);

        // Can't do much with 0 bytes
        if (charsRead == 0)
            throw new Exception("File is 0 bytes");

        while (charsRead > 0)
        {
            stringBuilder.Append(fileContents);
            charsRead = streamReader.Read(fileContents, 0, _bufferSize);
        }
    }
}

Original Article


5
这类测试极不可靠,重复测试时会从文件系统缓存中读取数据。这比从磁盘读取数据的真实测试至少快一个数量级。一个180 MB的文件不可能只需不到3秒钟就能完成。请重新启动您的计算机,运行一次真实的测试以获取正确的数字。 - Hans Passant
8
"stringBuilder.Append"这行代码存在潜在危险,你需要将其替换为"stringBuilder.Append(fileContents, 0, charsRead)",以确保即使流已经提前结束,也不会添加完整的1024个字符。 - Johannes Rudolph
2
@JohannesRudolph,你的评论刚刚帮我解决了一个bug。你是怎么想到1024这个数字的? - OfirD

4
您最好使用处理内存映射文件的 这里。内存映射文件支持将在 .NET 4 中推出(我想...我是听别人说的),因此这个包装器使用 p/invokes 来完成相同的工作。 编辑:MSDN上查看其工作原理,这里是博客条目,指示在即将发布的 .NET 4 中如何完成此操作。我早些时候给出的链接是围绕 pinvoke 实现它的包装器。 您可以将整个文件映射到内存中,并在滚动文件时像滑动窗口一样查看它。

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