掌握外部命令:优化Bash脚本性能

通过掌握外部命令的使用,解锁Bash脚本中隐藏的性能提升。本指南解释了重复生成如`grep`或`sed`等进程所带来的显著开销。学习实用、可操作的技术,用高效的Bash内置命令替换外部调用,利用强大的工具进行批量操作,并优化文件读取循环,从而在高吞吐量自动化任务中大幅缩短执行时间。

掌握外部命令:优化Bash脚本性能

最快的Bash脚本通常是启动程序最少的那个。

Bash擅长胶水工作:读取文件、决定做什么、启动另一个工具、检查退出状态、然后继续。它不是一种高性能的数据处理语言。陷阱在于,人们使用Bash时,仿佛每个微小的字符串操作都需要sed,每个比较都需要expr,每个文件循环都需要一个全新的grep。这种风格在十行代码时还行,但到了20万行就会变得痛苦。

代价是进程启动。当脚本运行grepsedawkcuttrdatebasename时,shell必须创建另一个进程并等待它。一次调用不是问题。但在大循环内的一次调用就是一个值得修复的模式。

首先查找循环内的命令:

grep -nE 'for |while ' script.sh
grep -nE 'grep|sed|awk|cut|tr|expr|basename|dirname|cat' script.sh

这并不意味着每个匹配都是坏的。对整个文件执行一次awk通常没问题。但每行启动一次sed,就会把维护脚本变成部署期间的意外故障。

用Bash自身替换微小的外部调用

最容易的胜利是算术、字符串长度、前缀、后缀和简单替换。Bash已经知道如何做这些。

外部算术:

# 使用外部 'expr' 工具
RESULT=$(expr $A + $B)

内置算术:

RESULT=$((A + B))

外部字符串替换:

MY_STRING="hello world"
NEW_STRING=$(echo "$MY_STRING" | sed 's/world/universe/')

参数扩展:

MY_STRING="hello world"
NEW_STRING=${MY_STRING/world/universe}
printf '%s\n' "$NEW_STRING"
任务 低效方法(外部) 高效方法(内置)
子串提取 `echo "$STR" cut -c 1-5`
长度检查 expr length "$STR" ${#STR}
移除后缀 basename "$file" .log ${file%.log}
移除路径 basename "$path" ${path##*/}
移除文件名 dirname "$path" ${path%/*}
替换第一个匹配 sed 's/foo/bar/' ${value/foo/bar}
替换所有匹配 sed 's/foo/bar/g' ${value//foo/bar}

在Bash条件语句中优先使用[[ ... ]]。它是一个shell关键字,能干净地处理模式匹配,并避免一些在[ ... ]中出现的引号问题。

if [[ $name == *.log && -s $name ]]; then
  printf '非空日志: %s\n' "$name"
fi

不要过度使用。Bash模式替换不是完整的正则引擎。如果规则确实复杂,一次awkperl处理比巧妙的shell扩展更清晰,通常也更快。

批量工作而非重复工作

如果一个工具可以在一次运行中处理多个输入,就给它多个输入。这对grepawksedfind、压缩工具、上传客户端以及任何连接到网络服务的工具尤其重要。

这个循环为每个文件启动一个grep

for file in *.log; do
  grep "ERROR" "$file" > "${file}.errors"
done

如果你只需要一个合并结果,使用一个grep

grep "ERROR" *.log > all_errors.txt

如果你需要每个文件的输出,考虑是否真的需要拆分。有时下游工具可以从grep -H读取文件名前缀:

grep -H "ERROR" *.log > errors-with-filenames.txt

对于面向行的转换,将简单的grep | awk链合并为一个awk程序:

awk '/data/ {print $1}' input.txt | sort > output.txt

这仍然运行了sort,这没问题。排序正是外部工具应该做的工作。有用的改变是移除无用的cat和单独的grep

无需cat读取文件

标准的行读取循环之所以无聊是有原因的:

while IFS= read -r line; do
  printf '处理中: %s\n' "$line"
done < file.txt

IFS=保留前导和尾随空白。-r阻止read将反斜杠视为转义。重定向使循环保持在当前shell中,如果循环更新了你稍后需要的变量,这一点很重要。

这个版本看起来无害,但通常更差:

cat file.txt | while read -r line; do
  count=$((count + 1))
done
printf '%s\n' "$count"

在Bash中,管道段通常运行在子shell中,因此count可能不会在父shell中更新。它还启动了cat,没有任何好处。

当输入确实由命令产生时,使用进程替换:

while IFS= read -r file; do
  printf '大文件: %s\n' "$file"
done < <(find /var/log -type f -size +100M)

这里find在做实际工作。将循环保持在当前shell仍然有用。

谨慎使用find -exec ... +xargs

文件循环是意外缓慢的常见来源:

for file in $(find . -name '*.tmp'); do
  rm "$file"
done

这在空格处会出错,并且重复启动rm。使用批量执行:

find . -name '*.tmp' -exec rm -f {} +

+形式将多个路径传递给每个rm调用。较旧的\;形式为每个路径运行一次命令。

对于受益于并发的命令,xargs -P可以减少挂钟时间:

xargs -n 1 -P 4 curl -fsS -O < urls.txt

当涉及文件名时使用-0

find uploads -type f -print0 | xargs -0 -n 50 -P 4 ./process-file

并行不是免费的。四个curl作业可能比一个快。四十个可能会被API限流或使小主机饱和。

在重写所有内容之前先测量

正确的优化取决于时间花在哪里。首先使用简单的时间测量:

time ./script.sh

对于进程密集型脚本,Linux上的strace -c可以显示脚本是否在创建进程、打开文件或等待I/O上花费时间:

strace -f -c ./script.sh

Shell跟踪可以揭示重复的命令:

PS4='+ $SECONDS ${BASH_SOURCE}:${LINENO}: '
bash -x ./script.sh

如果脚本95%的时间在等待数据库导出,替换${value/foo/bar}不会起作用。但如果它运行了30万次sed,就会有效果。

知道何时外部工具更好

目标 最佳工具(通常) 备注
字段提取和过滤 awk 对于表格文本比Bash循环更好。
流编辑 sed 适合对文件进行一次处理。
文件遍历 find 比解析ls更安全。
JSON jq 不要用cut解析JSON。
并行作业 xargs -P或GNU parallel 添加限制并处理失败。
大型文本处理 awkperl、Python 通常比巧妙的Bash更清晰。

Bash内置命令很快,但可维护性仍然重要。我宁愿维护一个清晰的awk脚本,也不愿维护40行只有原作者才懂的脆弱参数扩展。

实用的审查清单

当Bash脚本感觉缓慢时,按此顺序检查:

  1. 查找循环内的外部命令。
  2. 用Bash扩展替换简单的算术和字符串操作。
  3. 移除无用的cat调用。
  4. 使用grepawksedfind -exec ... +xargs批量处理文件参数。
  5. 当变量必须在循环后存活时,将行读取循环保持在当前shell中。
  6. 再次测量。

你不需要把每个脚本都变成基准测试练习。大的收益通常来自几个明显的地方:每行一个命令、每个文件一个命令或每个API项一个命令。修复这些,保持脚本可读,并在运行时间不再是问题时停止。