为什么使用shell循环处理文本被认为是不好的做法?


196

在POSIX Shell中,通常使用while循环处理文本是否被认为是不好的做法?

正如StéphaneChazelas指出的,不使用shell循环的一些原因是概念可靠性易读性性能安全性

答案解释了可靠性易读性方面:

while IFS= read -r line <&3; do
  printf '%s\n' "$line"
done 3< "$InputFile"

为了提高性能,从文件或管道中读取时,while循环和读取速度非常慢,因为内置读取外壳一次读取一个字符。

怎么样的概念安全性方面?


相关(硬币的另一面):如何yes这么快地写入文件?
通配符

1
内置的读取外壳程序一次不读取单个字符,而是一次读取一行。wiki.bash-hackers.org/commands/builtin/read
A.Danischewski

@ A.Danischewski:这取决于您的外壳。在中bash,它一次读取一个缓冲区大小,dash例如尝试。另请参见unix.stackexchange.com/q/209123/38906
cuonglm '16

Answers:


256

是的,我们看到许多事情,例如:

while read line; do
  echo $line | cut -c3
done

或更糟的是:

for line in `cat file`; do
  foo=`echo $line | awk '{print $2}'`
  echo whatever $foo
done

(别笑,我看过很多)。

通常来自shell脚本初学者。这些只是您在C或python等命令性语言中会做的天真的文字翻译,但这不是您在shell中做事的方式,并且这些示例效率很低,完全不可靠(可能导致安全问题),并且如果您曾经管理过,要修复大多数错误,您的代码将变得难以辨认。

从概念上讲

在C或大多数其他语言中,构造块仅比计算机指令高一级。您告诉处理器该做什么,然后告诉下一步。您可以用手拿住处理器并对其进行微管理:打开该文件,读取很多字节,然后执行此操作,并对其进行操作。

Shell是一种高级语言。有人可能说这甚至不是一门语言。它们位于所有命令行解释器之前。该工作由您运行的那些命令完成,而shell仅用于编排它们。

Unix引入的伟大的事情之一是管道和默认情况下所有命令都处理的默认stdin / stdout / stderr流。

在45年的时间里,我们发现没有比该API更好的方法来利用命令的功能并使它们协作完成任务。这可能是当今人们仍然使用shell的主要原因。

您拥有切割工具和音译工具,您可以简单地执行以下操作:

cut -c4-5 < in | tr a b > out

Shell只是在做管道(打开文件,设置管道,调用命令),当一切准备就绪时,它就在外壳没有做任何事情的情况下流动。这些工具可以按照自己的步调同时进行工作,并有足够的缓冲,以使它们不会相互阻塞,它们既美观又简单。

尽管调用工具需要付出一定的代价(我们将在性能点上进行开发)。这些工具可能用C语言编写了成千上万的指令。必须创建一个过程,必须对该工具进行加载,初始化,清理,销毁和等待过程。

调用cut就像打开厨房抽屉,拿起刀子,使用,清洗,干燥,再放回抽屉中。当您这样做时:

while read line; do
  echo $line | cut -c3
done < file

就像文件的每一行一样,read从厨房抽屉中拿出工具(这很笨拙,因为它不是为此而设计的),读取一行,清洗读取的工具,然后将其放回抽屉中。然后为echocut工具安排一次会议,从抽屉中取出它们,调用它们,清洗它们,将它们干燥,然后将它们放回抽屉中,依此类推。

其中一些工具(readecho)内置在大多数shell中,但这在这里几乎没有什么不同,因为echocut仍然需要在单独的进程中运行。

这就像切洋葱,但要洗刀,然后将其放回每片之间的厨房抽屉中。

在这里,最明显的方法是cut从抽屉中取出工具,将整个洋葱切成薄片,然后在整个工作完成后将其放回抽屉中。

在外壳程序中,尤其是在处理文本时,IOW会调用尽可能少的实用程序并使它们配合任务,而不是依次运行数千个工具来等待每个工具启动,运行,清理,然后再运行下一个工具。

进一步阅读Bruce的正确答案。Shell中的低级文本处理内部工具(可能除外zsh)是有限的,麻烦的,并且通常不适合常规文本处理。

性能

如前所述,运行一个命令是有代价的。如果该命令不是内置的,则成本很高,但是即使内置了该命令,代价也很大。

而且,shell还没有设计成可以像这样运行,它们没有成为高性能编程语言的幌子。它们不是,它们只是命令行解释器。因此,在这方面很少进行优化。

而且,shell在单独的进程中运行命令。这些构建块不共享公共内存或状态。当您在C中执行fgets()fputs()时,这就是stdio中的函数。stdio为所有stdio函数保留用于输入和输出的内部缓冲区,以避免过于频繁地执行昂贵的系统调用。

相应的,甚至内置shell实用程序(readechoprintf)无法做到这一点。read是要读一行。如果读取的字符超过了换行符,则意味着您运行的下一个命令将丢失该字符。因此read必须一次读取一个字节的输入(如果输入是常规文件,则某些实现方案会进行优化,因为它们可以读取大块并进行查找,但这仅适用于常规文件,bash例如只能读取128个字节的块,仍然比文本实用程序少很多)。

在输出端也是如此,echo不能仅缓冲其输出,它必须立即将其输出,因为您运行的下一个命令将不会共享该缓冲区。

显然,顺序运行命令意味着您必须等待它们,这是一个小的调度程序,它可以使您从外壳到工具再返回进行控制。这也意味着(与在管道中使用长时间运行的工具实例相反),您无法在可用时同时利用多个处理器。

在我的快速测试中,在那个while read循环和(应该是)等效循环之间,cut -c3 < file我的测试中的CPU时间比率约为40000(一秒对半天)。但是,即使您仅使用shell内置函数:

while read line; do
  echo ${line:2:1}
done

(此处带有bash),仍然约为1:600(一秒钟对10分钟)。

可靠性/可读性

正确编写代码非常困难。我所举的例子经常在野外看到,但它们有很多错误。

read是可以执行许多不同操作的便捷工具。它可以读取用户的输入,将其分解为单词以存储在不同的变量中。 read line不能读取一行输入的,或者它在非常特殊的方式读取一行。它实际上是从输入中读取单词,这些单词之间用$IFS斜杠分隔,反斜杠可用于转义分隔符或换行符。

$IFS输入为默认值时,例如:

   foo\/bar \
baz
biz

read line会存储"foo/bar baz"到中$line,而不是" foo\/bar \"您期望的那样。

要阅读一行,您实际上需要:

IFS= read -r line

这不是很直观,但是就是这样,请记住,shell并不是那样使用的。

相同echoecho扩展序列。您不能将其用于任意内容,例如随机文件的内容。您需要printf在这里代替。

当然,通常会忘记引用每个人都喜欢的变量。因此,更多:

while IFS= read -r line; do
  printf '%s\n' "$line" | cut -c3
done < file

现在,还有一些警告:

  • 除了zsh,如果输入包含NUL字符,则该方法不起作用,而至少GNU文本实用程序不会有此问题。
  • 如果最后一个换行符之后有数据,则将跳过该数据
  • 在循环内,stdin被重定向,因此您需要注意其中的命令不会从stdin中读取。
  • 对于循环中的命令,我们不会关注它们是否成功。通常,错误(磁盘已满,读取错误...)条件的处理较差,通常比使用正确的条件更差。

如果我们要解决上述一些问题,那就变成:

while IFS= read -r line <&3; do
  {
    printf '%s\n' "$line" | cut -c3 || exit
  } 3<&-
done 3< file
if [ -n "$line" ]; then
    printf '%s' "$line" | cut -c3 || exit
fi

这变得越来越难以理解。

通过参数将数据传递给命令或在变量中检索其输出还有许多其他问题:

  • 参数大小的限制(某些文本实用程序的实现也有限制,尽管达到这些效果通常不会带来太大问题)
  • NUL字符(也是文本实用程序的问题)。
  • -(或+有时)开头的参数作为选项
  • 通常在诸如这些循环使用的各种命令的各种怪癖exprtest...
  • 各种shell的(有限的)文本操作运算符,它们以不一致的方式处理多字节字符。
  • ...

安全注意事项

当您开始使用shell 变量command的参数时,您正在输入一个雷区。

如果您忘记引用变量,忘记选项标记末尾,在具有多字节字符的语言环境中工作(如今已成为常态),那么您肯定会引入一些迟早会成为漏洞的错误。

当您可能想使用循环时。

待定


24
清晰(生动),易读且非常有用。再一次感谢你。实际上,这是我在Internet上任何地方看到的关于Shell脚本和编程之间的根本区别的最好解释。
2015年

2
这些文章可帮助初学者了解Shell脚本并了解它们之间的细微差别。应该将引用变量添加为$ {VAR:-default_value}以确保您不会得到null。并设置-o名词设置以引用未定义的值时对您大吼大叫。
unsignedzero

6
@ A.Danischewski,我想您不明白要点了。是的cut,例如有效。cut -f1 < a-very-big-file是高效的,就像使用C语言编写时一样高效。在shell循环中,对于shell循环的cut每一行都调用了效率低下且容易出错的方法,a-very-big-file这正是该答案的重点。这与您关于编写不必要的代码的最后声明是一致的,这使我认为也许我不理解您的评论。
斯特凡Chazelas

5
“在45年的时间里,我们发现没有比该API更好的方法来利用命令的功能并使它们协作完成任务。” -实际上,PowerShell首先通过传递结构化数据而不是字节流解决了可怕的解析问题。Shell尚未使用它的唯一原因(这个想法已经存在了很长时间,并且当现在标准的列表和字典容器类型成为主流时,它在Java的某个时候基本上已经明确了)是他们的维护者尚未达成共识。使用的通用结构化数据格式(
。– ivan_pozdeev

6
@OlivierDulac我认为这有点幽默。该部分将永远待定。
muru

43

就概念和易读性而言,shell通常对文件感兴趣。它们的“可寻址单元”是文件,“地址”是文件名。Shell具有各种测试文件存在性,文件类型,文件名格式(从globbing开始)的方法。Shell具有很少的用于处理文件内容的原语。Shell程序员必须调用另一个程序来处理文件内容。

如前所述,由于文件和文件名的方向,在shell中进行文本操作确实很慢,但是还需要一种不清楚且扭曲的编程风格。


25

有一些复杂的答案,为我们中的极客提供了许多有趣的细节,但这确实非常简单-在shell循环中处理大型文件太慢了。

我认为发问者在一种典型的Shell脚本中很有趣,它可能会先进行一些命令行解析,环境设置,检查文件和目录以及进行更多的初始化,然后再进行其主要工作:面向行的文本文件。

对于第一个部分(initialization),shell命令通常很慢并不重要-它仅运行几十个命令,也许有几个短循环。即使我们没有效率地编写该部分,通常也要花费不到一秒钟的时间来完成所有初始化,这很好-它只会发生一次。

但是,当我们终于赶在处理大文件,这可能会对线路的数千或数百万,这是不精的shell脚本进行第二次的显著部分(即使它只有几十毫秒)的每一行,因为这可能要花几个小时。

那时候我们需要使用其他工具,而Unix shell脚本的魅力在于它们使我们很容易做到这一点。

而不是使用循环查看每一行,我们需要通过命令管道传递整个文件。这意味着,shell不会调用它们数千或数百万次,而只会调用它们一次。的确,这些命令将具有循环逐行处理文件的循环,但它们不是Shell脚本,并且被设计为快速有效。

Unix有许多出色的内置工具,从简单到复杂,我们可以使用它们来构建管道。我通常从简单的开始,仅在必要时使用更复杂的。

我也将尝试使用大多数系统上可用的标准工具,并尝试使我的用法具有可移植性,尽管并非总是可能的。而且,如果您最喜欢的语言是Python或Ruby,也许您不会介意确保将其安装在您的软件上需要运行的每个平台上的额外努力:-)

简单的工具包括headtailgrepsortcuttrsedjoin(在合并时,2档),以及awk单行,等等。某些人可以使用模式匹配和sed命令来完成,这真是令人惊讶。

当它变得更加复杂,并且您真的必须对每行应用一些逻辑时,awk是一个不错的选择-单行(有些人将整个awk脚本放在“一行”中,尽管那不是很容易理解)或简短的外部脚本。

作为awk一种解释型语言(如您的外壳程序),它可以如此高效地进行逐行处理是令人惊讶的,但是它是为此目的而专门构建的,而且速度非常快。

然后还有Perl大量其他脚本语言非常擅长处理文本文件,并且还附带了许多有用的库。

最后,如果您需要最大的速度和较高的灵活性,那么可以使用旧的C语言(尽管文本处理有点乏味)。但这可能很浪费您的时间来为遇到的每个不同的文件处理任务编写新的C程序。我经常使用CSV文件,因此我用C编写了一些通用实用程序,可以在许多不同的项目中重复使用。实际上,这扩大了我可以从Shell脚本调用的“简单,快速的Unix工具”的范围,因此我可以仅编写脚本来处理大多数项目,这比每次编写和调试定制的C代码要快得多!

最后的提示:

  • 不要忘记使用来启动您的主shell脚本export LANG=C,否则许多工具会将您原始的ASCII文件视为Unicode,从而使它们慢得多
  • export LC_ALL=C如果要sort在任何环境下都希望产生一致的排序,也可以考虑设置!
  • 如果您需要sort数据,那将比其他所有时间花费更多的时间(和资源:CPU,内存,磁盘),因此请尽量减少sort命令的数量和它们正在排序的文件的大小
  • 在可能的情况下,单个管道通常是最有效的-依次运行带有中间文件的多个管道可能更具可读性和调试性,但会增加程序花费的时间

6
经常不必要地使用许多简单工具(特别是提到的工具,例如head,tail,grep,sort,cut,tr,sed等)的管道,特别是如果您在该管道中已经有一个awk实例可以做的话这些简单工具的任务也是如此。要考虑的另一个问题是,在管道中,您不能简单可靠地将状态信息从管道前侧的进程传递到后侧的进程。如果将此类简单程序的流水线用于awk程序,则您只有一个状态空间。
Janis'3

14

对,但是...

斯特凡Chazelas的正确答案是基于委托每一个文本运行到特定的二进制文件,像观grepawksed和其他人。

由于能够自己完成很多事情,因此分叉可能会变得更快(甚至比运行另一个解释器来完成所有工作)。

例如,看一下这篇文章:

https://stackoverflow.com/a/38790442/1765658

https://stackoverflow.com/a/7180078/1765658

测试和比较...

当然

无需考虑用户输入安全性

不要在下编写Web应用程序!

但是对于许多服务器管理任务,可以使用代替,使用内置的bash可能非常有效。

我的意思是:

诸如bin utils之类的编写工具与系统管理不是同一类工作。

所以不是同一个人!

系统管理员必须知道shell,他们可以写原型通过使用他的首选(和最有名的)工具。

如果这个新实用程序(原型)真的有用,那么其他人可以通过使用一些更合适的语言来开发专用工具。


1
好例子。您的方法肯定比lololux方法更有效,但是请注意tensibai的答案(执行此IMO的正确方法,即不使用外壳循环)比您的方法快几个数量级。如果使用,速度会快很多bash。(在我的系统上的测试中,使用ksh93的速度快3倍)。bash通常是最慢的外壳。zsh在该脚本上,Even的速度是其两倍。您还遇到了一些不带引号的变量和的用法问题read。因此,您实际上在这里说明了我的很多观点。
斯特凡Chazelas

@StéphaneChazelas我同意,bash可能是当今人们可以使用的最慢的shell,但无论如何,它是使用最广泛的。
F. Hauri

@StéphaneChazelas我已在答案中发布了Perl版本
F. Hauri

1
@Tensibai,你会发现POSIXshawk中桑达grepedexcutsortjoin...所有猛砸相比更可靠 Perl的。
通配符

1
在与U&L有关的所有系统中,@ Tensibai bash默认情况下未安装其中的大多数系统(Solaris,FreeBSD,HP / UX,AIX,大多数嵌入式Linux系统...)。bash只有在苹果MacOS和GNU系统(我想这是你所谓的大多是中发现的主要分布),但许多系统也有它作为一个可选包(如zshtclpython...)
斯特凡Chazelas
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.