如何将大量数据写入文件?

4
我正在开发一个应用程序,它从巨大的文本文件(约2.5 GB)中读取行,将每一行操作成特定格式,然后将每一行写入文本文件。输出文本文件关闭后,程序会将数据“批量插入”(SQL Server)到我的数据库中。这个方法是可行的,只是速度比较慢。
我正在使用StreamReader和StreamWriter。
由于我必须操作文本,所以我基本上只能逐行读取文本;但是,我认为如果我制作一组行并每1000行左右写出一次该组行,它会至少加快一些速度。问题是(这可能仅仅是因为我的无知),我不能使用StreamWriter写入string[]。在探索StackOverflow和互联网的其余部分之后,我找到了File.WriteAllLines,它允许我将string[]写入文件,但我不认为我的电脑内存能够处理一次存储2.5 GB数据的操作。此外,文件被创建、填充和关闭,因此我需要制作大量较小的文件来拆分2 GB文本文件,以便将其插入数据库。所以我更愿意避免这种选择。
我能想到的一个解决方法是制作一个StringBuilder,并使用AppendLine方法添加每一行,以制作一个巨大的字符串。然后,我可以将该StringBuilder转换为字符串并将其写入文件。
但是,足够的猜测了。我已经实现了这种方法,但我想知道是否有人能够建议更好的方法来将数据块写入文件?
3个回答

11

使用 StreamWriter 提高输出速度有两个要点。

首先,确保输出文件与输入文件在不同的物理磁盘上。如果输入和输出在同一驱动器上,很多时候读取必须等待写入,而写入也必须等待读取。磁盘一次只能做一件事情。显然,并非每次读取或写入都需要等待,因为 StreamReader 会将内容读入缓冲区并从中解析出行,而 StreamWriter 会将内容写入缓冲区,然后在缓冲区满时将其推送到磁盘上。将输入和输出文件放在不同的驱动器上,可以使读取和写入操作重叠进行。

什么是重叠?操作系统通常会为您预读数据,因此在您处理数据时它可以缓存文件。当您进行写入操作时,操作系统通常会将其缓存起来,懒惰地将其写入磁盘。因此,在一定程度上存在一些异步处理。

第二个要点是增加缓冲区大小。对于 StreamReaderStreamWriter,默认缓冲区大小为4千字节。因此,每4K的读写都需要进行一个操作系统调用,而且很可能还会进行磁盘操作。

如果将缓冲区大小增加到64K,则可减少16倍操作系统调用和磁盘操作(不是严格的16倍,但很接近)。将缓冲区增加到64K可以削减超过25%的I/O时间,而且非常简单:

const int BufferSize = 64 * 1024;
var reader = new StreamReader(filename, Encoding.UTF8, true, BufferSize);
var writer = new StreamWriter(filename, Encoding.UTF8, BufferSize);
那两件事情将比你能做的任何其他事情更快地加速你的I/O。尝试使用StringBuilder在内存中构建缓冲区只是不必要的工作,因为它会糟糕地复制你可以通过增加缓冲区大小来实现的内容。如果不正确地执行,这样做还会使你的程序变慢。
我建议不要使用大于64 KB的缓冲区大小。在某些系统上,使用高达256KB的缓冲区可以获得稍微更好的结果,但在其他系统上,性能则显著下降——慢50%!我从未见过使用大于256 KB的缓冲区可以比使用64 KB的缓冲区表现更好的系统。根据我的经验,64 KB是最佳选择。
另外,你可以使用三个线程:一个读取器、一个处理器和一个写入器。他们通过队列进行通信。这可以将你的总时间从(输入时间+处理时间+输出时间)减少到接近max(输入时间、处理时间、输出时间)。而且在.NET中,设置它们非常简单。参见我的博客文章:Simple multithreading, Part 1Simple multithreading, Part 2

2
好建议。我对设置缓冲区大小一无所知。我还找到了一篇好文章:http://research.microsoft.com/pubs/64538/tr-2004-136.pdf - khinkle
@khinkle:感谢你提供的文章链接,内容非常好! - Jim Mischel
1
@khinkle:可能是的。文章表明它可以帮助减少碎片化。您需要创建FileStream,按照文章所示进行扩展,然后创建一个StreamWriter,将其传递给打开的FileStream。请注意,会有一些启动成本(创建和扩展文件),但如果您使用我提到的三线程方法,大部分(也许是全部)都可以并发地完成,输出行流入输出缓冲区。 - Jim Mischel
关于多线程的建议非常棒。我不知道为什么第一次阅读您的答案时错过了这一点。 - khinkle
@khinkle:我更喜欢使用BlockingCollection,原因有很多。首先,它的接口更容易使用。例如,GetConsumingEnumerable比编写消耗队列的循环要方便得多。而且CompleteAdding功能也非常好用。BlockingCollection为您提供了一个统一的接口,可以用于任何并发数据结构。例如,我可以将其用作并发堆栈或并发堆的接口,或者任何实现IProducerConsumerCollection的类。虽然它的默认值是ConcurrentQueue,但这真的非常方便。 - Jim Mischel
显示剩余3条评论

9
根据文档StreamWriter默认情况下不会在每次写入后自动刷新,因此它是缓冲的。
您也可以使用File类上的一些惰性方法,如下所示:
File.WriteAllLines("output.txt", 
    File.ReadLines("filename.txt").Select(ProcessLine));

其中ProcessLine的声明如下:

private string ProcessLine(string input) {
    string result =         // do some calculation on input
    return result;
}

由于ReadLines是懒加载的,WriteAllLines有一个懒加载的重载,因此它将流式传输文件而不是尝试读取整个文件。


+1 这只是一个非常优雅的解决方案。一行代码实现读取、处理和输出。真是太棒了。 - Jim Mischel

1

你想编写字符串吗?

类似于以下内容:

int cnt = 0;
StringBuilder s = new StringBuilder();
while(line = reader.readLine())
{
  cnt++;
  String x = (manipulate line);
  s.append(x+"\n");
  if(cnt%10000 == 0)
  {
     StreamWriter.write(s);
     s=new StringBuilder();
  }
}

由于下面的评论是正确的,所以进行了编辑,应该使用StringBuilder。


4
重复字符串连接对性能非常不利,因为每次都要分配新的字符串。这就是为什么StringBuilder存在的原因。 - recursive
好想法。当我在我的初始问题中建议使用 StringBuilder 时,我也是这样想的。 - khinkle
1
使用AppendLine而不是Append(x+'\n') - Jim Mischel
1
顺便提一下,while(line = reader.ReadLine())不能编译。你需要写成while((line = reader.ReadLine()) != null)或者在循环内使用while (!reader.EndOfStream),并将ReadLine放在循环内部。 - Jim Mischel

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