掌握外部命令:优化Bash脚本性能
通过掌握外部命令的使用,解锁Bash脚本中隐藏的性能提升。本指南解释了重复生成如`grep`或`sed`等进程所带来的显著开销。学习实用、可操作的技术,用高效的Bash内置命令替换外部调用,利用强大的工具进行批量操作,并优化文件读取循环,从而在高吞吐量自动化任务中大幅缩短执行时间。
掌握外部命令:优化Bash脚本性能
最快的Bash脚本通常是启动程序最少的那个。
Bash擅长胶水工作:读取文件、决定做什么、启动另一个工具、检查退出状态、然后继续。它不是一种高性能的数据处理语言。陷阱在于,人们使用Bash时,仿佛每个微小的字符串操作都需要sed,每个比较都需要expr,每个文件循环都需要一个全新的grep。这种风格在十行代码时还行,但到了20万行就会变得痛苦。
代价是进程启动。当脚本运行grep、sed、awk、cut、tr、date或basename时,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模式替换不是完整的正则引擎。如果规则确实复杂,一次awk或perl处理比巧妙的shell扩展更清晰,通常也更快。
批量工作而非重复工作
如果一个工具可以在一次运行中处理多个输入,就给它多个输入。这对grep、awk、sed、find、压缩工具、上传客户端以及任何连接到网络服务的工具尤其重要。
这个循环为每个文件启动一个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 |
添加限制并处理失败。 |
| 大型文本处理 | awk、perl、Python |
通常比巧妙的Bash更清晰。 |
Bash内置命令很快,但可维护性仍然重要。我宁愿维护一个清晰的awk脚本,也不愿维护40行只有原作者才懂的脆弱参数扩展。
实用的审查清单
当Bash脚本感觉缓慢时,按此顺序检查:
- 查找循环内的外部命令。
- 用Bash扩展替换简单的算术和字符串操作。
- 移除无用的
cat调用。 - 使用
grep、awk、sed、find -exec ... +或xargs批量处理文件参数。 - 当变量必须在循环后存活时,将行读取循环保持在当前shell中。
- 再次测量。
你不需要把每个脚本都变成基准测试练习。大的收益通常来自几个明显的地方:每行一个命令、每个文件一个命令或每个API项一个命令。修复这些,保持脚本可读,并在运行时间不再是问题时停止。