为什么“吞吐”文件不是一个好的做法?

42

为什么在普通文本文件输入输出中,“ slurping” 文件不是一个好的实践,何时使用它?

例如,为什么我不应该使用这些操作?

File.read('/path/to/text.txt').lines.each do |line|
  # do something with a line
end
或者
File.readlines('/path/to/text.txt').each do |line|
  # do something with a line
end

2
我绝对不认为使用readreadlines是一种不好的做法。除非你处理的应用程序中输入的大小是无限的,否则避免使用这些简单而方便的函数会显得过于早熟的优化。 - Max
11
使用防御性编程并不是过早优化。如果你读了我的回答,你会看到在最后一个段落中提到它们是有用的,但人们必须意识到盲目使用它们的危险。当它们引起问题时,往往很难调试,因为它们似乎是神秘地消失的假错误。 - the Tin Man
3
这个问题涉及到“普通文本文件的输入/输出”。如果您正在处理数百或数千个并发请求或读取多个千兆字节文件,则可能会出现很多问题,而不仅仅是“读取”。在我看来,这个问题应该真正关注的是“什么时候”使用一次性读取文件(slurping a file)不是一个好的做法,因为确实有一些情况下使用“read”是合适的。 - Max
2
@Max,你不必处理几千兆字节的文件。例如,日志文件的大小增长非常快。我认为通常阅读整个文件是一种毫无思考的行为。在我的观点里,它应该在经过仔细考虑后才使用。无论如何……我认为我们意见一致。 - akostadinov
3
@max,不必读取多个 GB 的文件来看出使用逐行 I/O 的优势。基准测试清楚地表明,在 1MB 处,foreach 已经超越了使用 slurping。从那时起,foreach稳步地拉开了与read类型I/O之间的差距。一次读取和处理一条记录一百万次比一次性读取一百万行并同时处理它们更不容易出现故障。 - the Tin Man
显示剩余2条评论
3个回答

90

我们经常看到有关读取文本文件并逐行处理的问题,使用的是readreadlines的变体,这些方法在一次操作中将整个文件加载到内存中。

read的文档表示:

  

打开文件,可选地搜索给定的偏移量,然后返回长度字节(默认为文件的其余部分)。 [...]

readlines的文档表示:

  

将指定名称的整个文件读取为单独的行,并将这些行作为数组返回。 [...]

读取一个小文件不是什么大问题,但当传入数据的缓冲区增长时,内存必须进行调整,这会消耗CPU时间。此外,如果数据占用太多空间,则操作系统必须参与才能使脚本运行并开始向磁盘写入,这将使程序崩溃。对于Web主机或需要快速响应的系统,它将使整个应用程序受到损害。

“Slurping”通常基于对文件I/O速度的误解或认为读取然后拆分缓冲区比每次读取一行更好。

这里有一些测试代码,用于演示由于“slurping”而导致的问题。

将其保存为“test.sh”:

echo Building test files...

yes "abcdefghijklmnopqrstuvwxyz 123456890" | head -c 1000       > kb.txt
yes "abcdefghijklmnopqrstuvwxyz 123456890" | head -c 1000000    > mb.txt
yes "abcdefghijklmnopqrstuvwxyz 123456890" | head -c 1000000000 > gb1.txt
cat gb1.txt gb1.txt > gb2.txt
cat gb1.txt gb2.txt > gb3.txt

echo Testing...

ruby -v

echo
for i in kb.txt mb.txt gb1.txt gb2.txt gb3.txt
do
  echo
  echo "Running: time ruby readlines.rb $i"
  time ruby readlines.rb $i
  echo '---------------------------------------'
  echo "Running: time ruby foreach.rb $i"
  time ruby foreach.rb $i
  echo
done

rm [km]b.txt gb[123].txt 
它创建了逐渐增加的五个文件。1K 文件被轻松处理并且非常常见。曾经认为 1MB 的文件很大,但现在很常见。在我的环境中,1GB 是常见的,而超过 10GB 的文件会周期性地出现,因此了解 1GB 及以上发生的情况非常重要。 请将此内容保存为“readlines.rb”。它本身不做任何实际操作,只是内部逐行读取整个文件,并将其附加到一个数组中,然后返回该数组,似乎会很快,因为所有内容都是用 C 写的。
lines = File.readlines(ARGV.shift).size
puts "#{ lines } lines read"

将此内容保存为"foreach.rb":

lines = 0
File.foreach(ARGV.shift) { |l| lines += 1 }
puts "#{ lines } lines read"

在我的笔记本电脑上运行 sh ./test.sh ,我得到了以下结果:

Building test files...
Testing...
ruby 2.1.2p95 (2014-05-08 revision 45877) [x86_64-darwin13.0]

读取1K文件:

Running: time ruby readlines.rb kb.txt
28 lines read

real    0m0.998s
user    0m0.386s
sys 0m0.594s
---------------------------------------
Running: time ruby foreach.rb kb.txt
28 lines read

real    0m1.019s
user    0m0.395s
sys 0m0.616s

读取 1MB 文件:

Running: time ruby readlines.rb mb.txt
27028 lines read

real    0m1.021s
user    0m0.398s
sys 0m0.611s
---------------------------------------
Running: time ruby foreach.rb mb.txt
27028 lines read

real    0m0.990s
user    0m0.391s
sys 0m0.591s

读取 1GB 文件:

Running: time ruby readlines.rb gb1.txt
27027028 lines read

real    0m19.407s
user    0m17.134s
sys 0m2.262s
---------------------------------------
Running: time ruby foreach.rb gb1.txt
27027028 lines read

real    0m10.378s
user    0m9.472s
sys 0m0.898s

读取2GB文件:

Running: time ruby readlines.rb gb2.txt
54054055 lines read

real    0m58.904s
user    0m54.718s
sys 0m4.029s
---------------------------------------
Running: time ruby foreach.rb gb2.txt
54054055 lines read

real    0m19.992s
user    0m18.765s
sys 0m1.194s

读取3GB的文件:

Running: time ruby readlines.rb gb3.txt
81081082 lines read

real    2m7.260s
user    1m57.410s
sys 0m7.007s
---------------------------------------
Running: time ruby foreach.rb gb3.txt
81081082 lines read

real    0m33.116s
user    0m30.790s
sys 0m2.134s
注意,每当文件大小增加一倍时,readlines 运行时间将减慢两倍,并且使用 foreach 的速度会线性减慢。在 1MB 处,我们可以看到有些东西影响了“抽吸式”I/O,但不影响逐行读取。而且,因为现在 1MB 的文件非常普遍,如果我们不提前考虑,就容易看到它们会在程序的生命周期内减慢文件处理速度。这种情况发生一两秒钟并不会太明显,但如果一分钟发生多次,到年底时会对性能产生严重影响。
数年前,在处理大型数据文件时我遇到了这个问题。我使用的 Perl 代码会周期性地停止,因为它在加载文件时重新分配内存。重写代码以不抽取数据文件,而是逐行读取和处理它,从需要五分钟运行时间到少于一分钟的巨大速度改善,并教会了我一个重要的课程。
有时候"抽吸"文件很有用,特别是如果你必须跨越行边界做一些事情,然而,如果你必须那样做,值得花费一些时间思考替代方法。例如,考虑维护一个由最后“n”行构建的小缓冲区并扫描它。这将避免由于尝试读取和保持整个文件而引起的内存管理问题。这在一个与 Perl 相关的博客 "Perl Slurp-Eaze" 中讨论,其中涵盖了使用完整文件读取的“何时”和“为什么”的合理性,并适用于 Ruby。
阅读 "如何搜索文件文本以查找模式并将其替换为给定值",以了解不要"抽吸"文件的其他优秀原因。

3
我明白你在这里的用意。这是一个好主意,会帮助新手了解Ruby。我建议添加一些建议,告诉他们应该做什么。 - Mark Thomas
我会逐步完善这个答案,但同时其他人也可以添加额外的答案。 - the Tin Man
1
对于1MB,我看不到“影响 slurping 的东西……”数字只相差约3%。你需要一个更好的测试设置来可靠地测量这3%。如果你关心这3%,你应该使用C语言。 - Meier
文件系统缓存怎么样?这可能会有助于foreach,使每次读取都来自缓存,而无需为整个文件分配内存的开销。 - yeyo
只要缓存比文件大,使用foreach可能会有所帮助,就像 slurping 一样。如果文件比缓存大,它将强制进行额外的读取,这可能会对 slurping 产生一些影响,但我认为它不会像超出可用内存并使系统开始分页那样严重。 - the Tin Man

8
这有点老了,但我有点惊讶没有人提到通过读取输入文件来使用程序在管道中几乎变得无用了。在管道中,输入文件可能很小但却很慢。如果您的程序正在 slurping(注:一次性读取整个文件),这意味着它不会在数据可用时立即处理数据,而是要等待输入完成,可能需要等很长时间。多长时间?它可能是任何东西,例如,如果我在一个大层次结构中进行 grep 或 find,则可能需要几小时或几天左右。它也可能设计成不完成,像一个无限的文件。例如,journalctl -f 将继续输出系统中发生的任何事件而不停止;tshark 将输出其所看到的网络活动而不停止;ping 将继续 ping 而不停止。/dev/zero 是无限的,/dev/urandom 也是无限的。
唯一可以接受 slurping 的时候可能只存在于配置文件中,因为程序在完成读取之前可能无法执行任何操作。

1
是的,在管道中吞吐数据很可能会让不知情的人感到困惑,因为程序看起来会卡住,按下CTRL+C将终止程序而没有结果。只有当他们使用小文件进行测试时,才能看到输出,而大文件要么会使代码崩溃,要么会导致处理延迟,因为机器分配内存。后者就是我从中学到的教训。 - the Tin Man

3
为什么“ slurping”文件不是正常文本文件I/O的好做法?
锡罐人说得对。我还想补充一点:
- 在许多情况下,将整个文件读入内存是不可行的(因为文件太大或字符串操作具有指数O()空间) - 通常情况下,您无法预测文件大小(上述特殊情况) - 如果存在替代选项(例如逐行),则应始终尝试注意内存使用情况,并且一次性读取所有文件(即使在微不足道的情况下)也不是好的做法。 我从经验中知道VBS在这方面很糟糕,人们被迫通过命令行处理文件。
此概念不仅适用于文件,而且适用于任何其他过程,在该过程中,内存大小快速增长,并且您必须一次处理每个迭代(或行)。 {{link1:生成器函数}}通过逐个处理进程或行读取来帮助您,以便不必处理所有数据。
作为一个额外说明,Python在读取文件时非常聪明,它的open()方法默认按行读取。请参见"改善你的Python:'yield'和生成器解释",其中解释了生成器函数的一个很好的用例示例。

1
我喜欢这个“它的open()方法默认设计为逐行读取”。这是一种更聪明的方式,因为这使默认路径变得更加安全,人们必须明确地要求读取整个文件。如果他们必须考虑后果,他们会更少犯错误。 - the Tin Man

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